diff --git a/AGENTS.md b/AGENTS.md index abe7a788..2e1ae77a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ | `src/main/java/com/youlai/boot/client/` | 家属端:App 用户、通讯录、设备操作、文件 | | `src/main/java/com/youlai/boot/device/` | 设备管理:sn 设备、截图、壁纸、策略、告警、APK 安装、硬件/网络/安全/系统信息 | | `src/main/java/com/youlai/boot/open/` | 家属端开放认证:短信登录、密码登录、注册、重置密码 | -| `src/main/java/com/youlai/boot/common/config/` | `FilePath`(本地存储路径)、`WebMvcConfig`(静态资源映射) | +| `src/main/java/com/youlai/boot/common/config/` | `StorageDir`(对象存储业务目录) | **其余所有目录(`auth/`、`system/`、`framework/`、`support/`、`config/`、`module/`、`codegen/` 等)都来自上游,尽量保持不动。** @@ -137,11 +137,11 @@ xxx/ - `/api/v1/client/**`:家属端,仅接受 `clientType=client` 的令牌 - `/api/v1/open/**`:免登录(登录/注册/找回密码) - 后台接口(`/api/v1/device`、`/api/v1/app/**`、`/api/v1/developers` 等):走 Spring Security,按需 `@PreAuthorize` -- 我方新增接口的业务文件统一通过 `FileService#uploadFile(MultipartFile, String directory)` 存到 `StorageDir` 定义的固定目录(`app/icon`、`app/package-icon`、`app/package`、`app/screenshot`、`app/avatar`、`app/wallpaper`、`app/category-icon`、`app/file`),实际存储由 `storage.type`(s3 / aliyun / local)决定;不按上传日期分目录。 +- 我方新增接口的业务文件统一通过 `FileService#uploadFile(MultipartFile, String directory)` 存到 `StorageDir` 定义的固定目录(`app/icon`、`app/package-icon`、`app/package`、`app/screenshot`、`app/avatar`、`app/wallpaper`、`app/category-icon`、`app/file`、`tablet/screenshot`),实际存储由 `storage.type`(s3 / aliyun / local)决定;不按上传日期分目录。 - 上游 `FileService#uploadFile(MultipartFile)` 及其 `Aliyun/S3/Local` 三个实现保持 gitee 行为(`yyyyMMdd/` 日期目录,本地实现不算哈希),只由上游 `POST /api/v1/files` 使用,不得修改其语义。 - 头像:家属端走 `POST /api/v1/client/files/avatar`,落 `app/avatar`。管理端头像继续用上游 `POST /api/v1/files`,`system/controller/FileController` 保持 gitee 原样(不再提供 `/api/v1/files/avatar`)。 - **文件上传规则的改动边界**:涉及存储目录、对象命名、扩展名校验、URL 生成等上传规则调整时,只允许改我方新增的接口与实现(`/api/v1/client/files/**`、`/api/v1/sn/**` 等);gitee 上游已有的上传接口(`POST /api/v1/files`、`system/controller/FileController` 及其调用的 `FileService` 原方法)保持上游原样,不因我方需求改其入参、出参、校验与行为。 -- `FilePath` + `/static/{子目录}/**` 仅用于设备截图等纯本地资源,以及存量历史文件的访问。 +- 所有业务文件(安装包、图标、截图、壁纸、头像等)统一经 `FileService` 落 `StorageDir` 目录,不落本地磁盘、不提供 `/static` 访问映射。 - 新增后台菜单/表结构请补充 `sql/ttstd/` 脚本,保持与代码同步。 ## 5. 提交约定 diff --git a/sql/ttstd/sn_device_wallpaper.sql b/sql/ttstd/sn_device_wallpaper.sql index 9a6d0dec..5010d9d2 100644 --- a/sql/ttstd/sn_device_wallpaper.sql +++ b/sql/ttstd/sn_device_wallpaper.sql @@ -3,7 +3,7 @@ -- 说明:设备的壁纸改为「一图一行」的多行存储,支持显式排序(sort_no)。 -- mode 区分壁纸模式:0-单个壁纸(1 行),1-轮换壁纸(最多 7 行)。 -- sort_no 升序即轮换展示顺序;单个壁纸固定为第 0 行。 --- image_url 为可访问的完整图片 URL,如 /static/wallpaper/SN_xxx_1.jpg。 +-- image_url 为对象存储返回的完整图片 URL。 -- 迁移说明:旧表 sys_sn_device_wallpaper(单行 JSON)已被本表取代; -- 如需清理旧表请手工执行:DROP TABLE IF EXISTS `sys_sn_device_wallpaper`; -- ============================================================================= diff --git a/src/main/java/com/youlai/boot/app/category/model/document/AppCategoryDocument.java b/src/main/java/com/youlai/boot/app/category/model/document/AppCategoryDocument.java index 0acc2e36..f0ef8433 100644 --- a/src/main/java/com/youlai/boot/app/category/model/document/AppCategoryDocument.java +++ b/src/main/java/com/youlai/boot/app/category/model/document/AppCategoryDocument.java @@ -30,7 +30,7 @@ public class AppCategoryDocument { @Field("name") private String name; - @Schema(description = "分类图标(手动上传图片的访问 URL,如 /static/category_icon/{md5}.png)") + @Schema(description = "分类图标(手动上传图片的访问 URL)") @Field("icon") private String icon; diff --git a/src/main/java/com/youlai/boot/app/packages/controller/AppPackageController.java b/src/main/java/com/youlai/boot/app/packages/controller/AppPackageController.java index bc2f541d..622948f0 100644 --- a/src/main/java/com/youlai/boot/app/packages/controller/AppPackageController.java +++ b/src/main/java/com/youlai/boot/app/packages/controller/AppPackageController.java @@ -12,18 +12,11 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; -import java.io.BufferedInputStream; -import java.io.File; -import java.io.FileInputStream; import java.io.IOException; -import java.io.OutputStream; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; import java.util.List; /** @@ -96,35 +89,14 @@ public class AppPackageController { return Result.success(true); } - @Operation(summary = "下载安装包(云存储直接重定向,历史本地文件返回文件流)") + @Operation(summary = "下载安装包(重定向到对象存储访问地址)") @GetMapping("/{id}/download") public void download(@Parameter(description = "安装包ID") @PathVariable String id, HttpServletResponse response) throws IOException { - // 云存储:直接重定向到对象访问地址,不占用服务端带宽 String fileUrl = appPackageService.getFileUrl(id); - if (fileUrl != null && (fileUrl.startsWith("http://") || fileUrl.startsWith("https://"))) { - response.sendRedirect(fileUrl); - return; - } - File file = appPackageService.getPackageFile(id); - if (file == null) { + if (fileUrl == null) { response.sendError(HttpStatus.NOT_FOUND.value(), "安装包文件不存在"); return; } - String originalName = appPackageService.getOriginalName(id); - String encodedName = URLEncoder.encode(originalName, StandardCharsets.UTF_8).replace("+", "%20"); - response.reset(); - response.setContentType("application/octet-stream"); - response.setContentLengthLong(file.length()); - response.setHeader(HttpHeaders.CONTENT_DISPOSITION, - "attachment; filename=\"" + encodedName + "\"; filename*=UTF-8''" + encodedName); - try (OutputStream out = response.getOutputStream(); - BufferedInputStream in = new BufferedInputStream(new FileInputStream(file))) { - byte[] buffer = new byte[8192]; - int len; - while ((len = in.read(buffer)) != -1) { - out.write(buffer, 0, len); - } - out.flush(); - } + response.sendRedirect(fileUrl); } } diff --git a/src/main/java/com/youlai/boot/app/packages/service/AppPackageService.java b/src/main/java/com/youlai/boot/app/packages/service/AppPackageService.java index 9bd9b2e9..c6f2119b 100644 --- a/src/main/java/com/youlai/boot/app/packages/service/AppPackageService.java +++ b/src/main/java/com/youlai/boot/app/packages/service/AppPackageService.java @@ -38,7 +38,7 @@ public interface AppPackageService { AppPackageVO detail(String id); /** - * 删除(同时删除本地文件) + * 删除(同时删除存储对象) */ void removeById(String id); @@ -52,29 +52,9 @@ public interface AppPackageService { /** * 获取安装包文件访问地址。 - *

- * 云存储(s3 / aliyun)返回完整 http(s) URL,Controller 可直接重定向; - * 历史本地数据返回 {@code /static/packages/...} 相对路径。 * * @param id 安装包ID - * @return 访问地址,不存在返回 null + * @return 完整访问 URL,不存在返回 null */ String getFileUrl(String id); - - /** - * 读取历史本地安装包文件(本地存储时代的数据)。 - * 文件不存在时返回 null,由 Controller 返回 404。 - * - * @param id 安装包ID - * @return 安装包文件,不存在返回 null - */ - java.io.File getPackageFile(String id); - - /** - * 获取安装包原始文件名(用于下载时的 Content-Disposition 文件名)。 - * - * @param id 安装包ID - * @return 原始文件名,不存在返回 null - */ - String getOriginalName(String id); } diff --git a/src/main/java/com/youlai/boot/app/packages/service/impl/AppPackageServiceImpl.java b/src/main/java/com/youlai/boot/app/packages/service/impl/AppPackageServiceImpl.java index 5bf3ccef..8734302e 100644 --- a/src/main/java/com/youlai/boot/app/packages/service/impl/AppPackageServiceImpl.java +++ b/src/main/java/com/youlai/boot/app/packages/service/impl/AppPackageServiceImpl.java @@ -11,7 +11,6 @@ import com.youlai.boot.app.packages.model.query.AppPackagePageQuery; import com.youlai.boot.app.packages.model.vo.AppPackageVO; import com.youlai.boot.app.packages.repository.AppPackageRepository; import com.youlai.boot.app.packages.service.AppPackageService; -import com.youlai.boot.common.config.FilePath; import com.youlai.boot.common.config.StorageDir; import com.youlai.boot.common.exception.BusinessException; import com.youlai.boot.support.file.model.FileInfo; @@ -204,29 +203,10 @@ public class AppPackageServiceImpl implements AppPackageService { if (doc == null) { return; } - // 删除安装包对象:云端(http URL)走存储服务,历史本地文件(/static/packages/)直接删磁盘 - if (isRemoteUrl(doc.getFileUrl())) { - deleteRemoteQuietly(doc.getFileUrl(), "安装包"); - } else if (doc.getFileName() != null) { - FileUtil.del(FilePath.getPackagePath() + doc.getFileName()); - } - // 删除图标对象:云端走存储服务,历史 /static 本地图标已下线映射,仅清理磁盘文件 - if (isRemoteUrl(doc.getIconUrl())) { - deleteRemoteQuietly(doc.getIconUrl(), "图标"); - } else if (doc.getIconUrl() != null && doc.getIconUrl().startsWith("/static/app_icon/")) { - String iconName = doc.getIconUrl().substring("/static/app_icon/".length()); - FileUtil.del(FilePath.getApkIconPath() + iconName); - } - // 删除截图:云端走存储服务,历史本地截图清理磁盘文件 + deleteRemoteQuietly(doc.getFileUrl(), "安装包"); + deleteRemoteQuietly(doc.getIconUrl(), "图标"); if (doc.getScreenshots() != null) { - for (String shotUrl : doc.getScreenshots()) { - if (isRemoteUrl(shotUrl)) { - deleteRemoteQuietly(shotUrl, "截图"); - } else if (shotUrl != null && shotUrl.startsWith("/static/screenshot/")) { - String shotName = shotUrl.substring("/static/screenshot/".length()); - FileUtil.del(FilePath.getScreenshotPath() + shotName); - } - } + doc.getScreenshots().forEach(shotUrl -> deleteRemoteQuietly(shotUrl, "截图")); } appPackageRepository.deleteById(id); } @@ -236,28 +216,6 @@ public class AppPackageServiceImpl implements AppPackageService { appPackageRepository.updateCategories(id, categories); } - @Override - public File getPackageFile(String id) { - AppPackageDocument doc = appPackageRepository.findById(id); - if (doc == null || doc.getFileName() == null) { - return null; - } - File file = new File(FilePath.getPackagePath() + doc.getFileName()); - if (!file.exists() || !file.isFile()) { - return null; - } - return file; - } - - @Override - public String getOriginalName(String id) { - AppPackageDocument doc = appPackageRepository.findById(id); - if (doc == null || doc.getOriginalName() == null) { - return "package"; - } - return doc.getOriginalName(); - } - @Override public String getFileUrl(String id) { AppPackageDocument doc = appPackageRepository.findById(id); @@ -284,10 +242,6 @@ public class AppPackageServiceImpl implements AppPackageService { return slash >= 0 ? path.substring(slash + 1) : path; } - private boolean isRemoteUrl(String url) { - return url != null && (url.startsWith("http://") || url.startsWith("https://")); - } - /** * 删除云存储对象:失败只记日志,不阻断数据库记录删除 */ diff --git a/src/main/java/com/youlai/boot/client/controller/ClientController.java b/src/main/java/com/youlai/boot/client/controller/ClientController.java index f921d96f..951ea585 100644 --- a/src/main/java/com/youlai/boot/client/controller/ClientController.java +++ b/src/main/java/com/youlai/boot/client/controller/ClientController.java @@ -303,13 +303,10 @@ public class ClientController { return Result.failed("设备不存在,请确认SN是否正确"); } - // 已绑定给当前用户:直接视为已绑定 + // 已绑定给其他用户则拒绝;绑定给当前用户时仍下发一次确认请求, + // 用于设备重置/密钥变更后的重新授权(设备端仍需用户同意) if (StringUtils.hasText(deviceInfo.getSnMobile()) - && deviceInfo.getSnMobile().equals(clientUser.getMobile())) { - return Result.failed("该设备已绑定到当前账号"); - } - // 已绑定给其他用户则拒绝 - if (StringUtils.hasText(deviceInfo.getSnMobile())) { + && !deviceInfo.getSnMobile().equals(clientUser.getMobile())) { return Result.failed("该设备已被其他用户绑定"); } @@ -558,9 +555,7 @@ public class ClientController { if (savedFile == null) { return Result.failed("图片保存失败"); } - String imageUrl = StringUtils.hasText(savedFile.getUrl()) - ? savedFile.getUrl() - : "/static/wallpaper/" + savedFile.getFileName(); + String imageUrl = savedFile.getUrl(); boolean sent = devicePictureService.sendPicture(sn, imageUrl, favorite != null && favorite == 1, savedFile.getFileSize()); if (!sent) { return Result.failed("图片已保存,但推送设备失败,请检查设备是否在线"); @@ -735,10 +730,8 @@ public class ClientController { return null; } String url = stored.getUrl(); - int slash = url.lastIndexOf('/'); WallpaperFileDTO dto = new WallpaperFileDTO(); - dto.setFileName(slash >= 0 ? url.substring(slash + 1) : url); dto.setUrl(url); dto.setFileSize(file.getSize()); dto.setMd5(md5); diff --git a/src/main/java/com/youlai/boot/common/config/FilePath.java b/src/main/java/com/youlai/boot/common/config/FilePath.java deleted file mode 100644 index 4a2202fc..00000000 --- a/src/main/java/com/youlai/boot/common/config/FilePath.java +++ /dev/null @@ -1,85 +0,0 @@ -package com.youlai.boot.common.config; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.File; - -public class FilePath { - - /** - * 有可能部署在docker中,所以需要使用绝对路径 - */ -// @Value("${file.upload-dir-unix}") - private static final String unixUploadDir = "/home/tt/ttstd/uploads" ; - -// @Value("${file.upload-dir-windows}") - private static final String windowsUploadDir = "uploadFile"; - - private static Logger logger = LoggerFactory.getLogger(FilePath.class); - - - public static final String TABLET_PATH = "tablet"; - public static final String AVATAR_PATH = "avatar"; - public static final String APK_ICON_PATH = "apkIcon"; - public static final String SCREENSHOT_PATH = "screenshot"; - public static final String WALLPAPER_PATH = "wallpaper"; - public static final String PACKAGE_PATH = "packages"; - public static final String CATEGORY_ICON_PATH = "category_icon"; - - public static String getRootPath() { - String osName = System.getProperty("os.name"); - logger.info("osName: {}", osName); - String projectPath = System.getProperty("user.dir"); - logger.info("projectPath: {}", projectPath); - if (osName.contains("Windows")) { - return projectPath + File.separator + windowsUploadDir; - } else { - return projectPath + File.separator + windowsUploadDir; - } - } - - public static String getAvatarPath() { - return getRootPath() + File.separator + TABLET_PATH + File.separator + AVATAR_PATH + File.separator; - } - - /** - * 历史应用图标目录(旧版本按 md5 落盘,现仅用于兼容存量 icon_url 与清理) - */ - public static String getApkIconPath() { - return getRootPath() + File.separator + TABLET_PATH + File.separator + APK_ICON_PATH + File.separator; - } - - public static String getScreenshotPath() { - return getRootPath() + File.separator + TABLET_PATH + File.separator + SCREENSHOT_PATH + File.separator; - } - - /** - * 壁纸存储绝对目录 - */ - public static String getWallpaperPath() { - return getRootPath() + File.separator + TABLET_PATH + File.separator + WALLPAPER_PATH + File.separator; - } - - public static String getPackagePath() { - return getRootPath() + File.separator + TABLET_PATH + File.separator + PACKAGE_PATH + File.separator; - } - - public static String getCategoryIconPath() { - return getRootPath() + File.separator + TABLET_PATH + File.separator + CATEGORY_ICON_PATH + File.separator; - } - - /** - * 截图存储绝对目录 - */ - public static String getScreenshotDir() { - return getRootPath() + File.separator + TABLET_PATH + File.separator + SCREENSHOT_PATH; - } - - /** - * 截图相对访问路径(用于拼接存储到数据库的 filePath) - */ - public static String getScreenshotRelativePath() { - return TABLET_PATH + "/" + SCREENSHOT_PATH + "/"; - } -} diff --git a/src/main/java/com/youlai/boot/common/config/StorageDir.java b/src/main/java/com/youlai/boot/common/config/StorageDir.java index 43fec459..09071de0 100644 --- a/src/main/java/com/youlai/boot/common/config/StorageDir.java +++ b/src/main/java/com/youlai/boot/common/config/StorageDir.java @@ -42,6 +42,9 @@ public interface StorageDir { /** 安装包应用截图 */ String SCREENSHOT = "app/screenshot"; + /** 设备端上报的屏幕截图/拍照 */ + String DEVICE_SCREENSHOT = "tablet/screenshot"; + /** 用户头像(管理端 / 家属端) */ String AVATAR = "app/avatar"; diff --git a/src/main/java/com/youlai/boot/common/config/WebMvcConfig.java b/src/main/java/com/youlai/boot/common/config/WebMvcConfig.java deleted file mode 100644 index 9c5c407f..00000000 --- a/src/main/java/com/youlai/boot/common/config/WebMvcConfig.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.youlai.boot.common.config; - -import org.springframework.context.annotation.Configuration; -import org.springframework.http.CacheControl; -import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; - -import java.util.concurrent.TimeUnit; - -/** - * Web MVC 配置 - * - * @author TTSTD - * @since 2026-08-05 - */ -@Configuration -public class WebMvcConfig implements WebMvcConfigurer { - - /** - * 静态资源映射 - */ - @Override - public void addResourceHandlers(ResourceHandlerRegistry registry) { - // 历史应用图标静态资源映射:/static/app_icon/{md5}.png -> 磁盘 tablet/apkIcon 目录 - // 新上传图标已改存云存储,此处仅为兼容存量 app_icon / app_package 记录中的相对地址, - // 避免映射缺失导致旧图标整片 404。文件名为内容 MD5,可安全使用长缓存。 - String legacyIconLocation = "file:" + FilePath.getApkIconPath().replace("\\", "/"); - registry.addResourceHandler("/static/app_icon/**") - .addResourceLocations(legacyIconLocation) - .setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic().immutable()); - - // 设备截图静态资源映射:/static/screenshot/{fileName} -> 磁盘 tablet/screenshot 目录 - // 截图内容会变化(删除/重截),且文件名 md5 可能因画面相同而重复, - // 故不使用长缓存,避免浏览器复用旧图(表现为 304 + 图片不更新)。 - String screenshotLocation = "file:" + FilePath.getScreenshotPath().replace("\\", "/"); - registry.addResourceHandler("/static/screenshot/**") - .addResourceLocations(screenshotLocation) - .setCacheControl(CacheControl.noCache()); - - // 应用安装包静态资源映射:/static/packages/{fileName} -> 磁盘 tablet/packages 目录 - // 安装包内容为内容寻址命名(含 md5),内容变更文件名必变,可安全使用长缓存。 - String packageLocation = "file:" + FilePath.getPackagePath().replace("\\", "/"); - registry.addResourceHandler("/static/packages/**") - .addResourceLocations(packageLocation) - .setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic().immutable()); - - // 分类图标静态资源映射:/static/category_icon/{fileName} -> 磁盘 tablet/category_icon 目录 - // 分类图标为手动上传图片,文件名内容寻址(含 md5),变更必变文件名,可安全使用长缓存。 - String categoryIconLocation = "file:" + FilePath.getCategoryIconPath().replace("\\", "/"); - registry.addResourceHandler("/static/category_icon/**") - .addResourceLocations(categoryIconLocation) - .setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic().immutable()); - - // 壁纸静态资源映射:/static/wallpaper/{fileName} -> 磁盘 tablet/wallpaper 目录 - // 壁纸内容会变化(更换/删除),且文件名含时间戳(同一图片重复上传文件名不同), - // 不使用长缓存,避免设备/家属端复用旧图。 - String wallpaperLocation = "file:" + FilePath.getWallpaperPath().replace("\\", "/"); - registry.addResourceHandler("/static/wallpaper/**") - .addResourceLocations(wallpaperLocation) - .setCacheControl(CacheControl.noCache()); - } -} diff --git a/src/main/java/com/youlai/boot/device/controller/MobileController.java b/src/main/java/com/youlai/boot/device/controller/MobileController.java index 9cf18aeb..af5ce21e 100644 --- a/src/main/java/com/youlai/boot/device/controller/MobileController.java +++ b/src/main/java/com/youlai/boot/device/controller/MobileController.java @@ -1,7 +1,7 @@ package com.youlai.boot.device.controller; import com.youlai.boot.common.annotation.Log; -import com.youlai.boot.common.config.FilePath; +import com.youlai.boot.common.config.StorageDir; import com.youlai.boot.common.enums.ActionTypeEnum; import com.youlai.boot.common.enums.LogModuleEnum; import com.youlai.boot.common.exception.BusinessException; @@ -33,6 +33,8 @@ import com.youlai.boot.device.model.vo.DeviceWallpaperVO; import com.youlai.boot.device.converter.DevicePolicyConverter; import com.youlai.boot.device.service.*; import com.youlai.boot.framework.security.signature.DeviceSignatureVerifier; +import com.youlai.boot.support.file.model.FileInfo; +import com.youlai.boot.support.file.service.FileService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; @@ -44,7 +46,6 @@ import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; -import java.io.File; import java.time.LocalDateTime; import java.util.HashMap; import java.util.List; @@ -76,6 +77,7 @@ public class MobileController { private final DeviceSignatureVerifier deviceSignatureVerifier; private final DeviceWallpaperService deviceWallpaperService; private final DeviceAlarmService deviceAlarmService; + private final FileService fileService; private final Logger logger = LoggerFactory.getLogger(MobileController.class); @@ -114,16 +116,33 @@ public class MobileController { return Result.failed(ResultCode.MOBILE_SIGN_INVALID, "设备签名验证失败"); } - // 2. 注册设备(绑定即注册):SN ↔ 公钥,不存在则新建,已存在则更新公钥 + // 2. 查已有记录:封禁与手机号冲突必须在写公钥之前校验, + // 否则错误手机号的请求会先把公钥换掉,导致真正绑定的设备无法再验签 SnDeviceInfo device = deviceService.lambdaQuery() .eq(SnDeviceInfo::getSerialno, sn) .one(); + if (device != null) { + if (device.getStatus() != null && device.getStatus() == 0) { + return Result.failed(ResultCode.MOBILE_DEVICE_BANNED); + } + if (StringUtils.hasText(device.getSnMobile()) + && !device.getSnMobile().equals(phone.trim())) { + return Result.failed("该设备已绑定其他手机号"); + } + if (StringUtils.hasText(device.getPublicKey()) + && !device.getPublicKey().equals(publicKey)) { + logger.info("设备公钥变更(重新授权), sn: {}, phone: {}", sn, phone.trim()); + } + } + + // 3. 注册设备(绑定即注册):SN ↔ 公钥 + 手机号一次写入 if (device == null) { device = new SnDeviceInfo(); device.setSerialno(sn); device.setStatus(1); device.setSnModel(model); device.setPublicKey(publicKey); + device.setSnMobile(phone.trim()); boolean saved = deviceService.save(device); if (!saved) { return Result.failed("设备注册失败"); @@ -133,21 +152,13 @@ public class MobileController { if (model != null && !model.trim().isEmpty()) { device.setSnModel(model); } + device.setSnMobile(phone.trim()); deviceService.updateById(device); } - // 3. 已绑定给其他手机号则拒绝(同一设备仅允许一个家属手机号) - if (StringUtils.hasText(device.getSnMobile()) - && !device.getSnMobile().equals(phone.trim())) { - return Result.failed("该设备已绑定其他手机号"); - } - - // 4. 写入绑定关系 - device.setSnMobile(phone.trim()); - deviceService.updateById(device); logger.info("设备确认绑定成功(绑定即注册), sn: {}, phone: {}", sn, phone.trim()); - // 5. 回推绑定成功通知到设备端(contentType=21) + // 4. 回推绑定成功通知到设备端(contentType=21) deviceService.pushBindSuccess(sn, phone.trim()); Map result = new HashMap<>(); @@ -479,18 +490,6 @@ public class MobileController { return Result.failed("设备序列号不能为空"); } - String screenshotPath = FilePath.getScreenshotPath(); - logger.info("uploadScreenshot, screenshotPath: {}", screenshotPath); - - File fileDir = new File(screenshotPath); - if (!fileDir.exists()) { - boolean created = fileDir.mkdirs(); - if (!created) { - logger.error("创建目录失败: {}", screenshotPath); - return Result.failed("创建目录失败"); - } - } - String originName = file.getOriginalFilename(); if (originName == null || originName.isEmpty()) { return Result.failed("文件名无效"); @@ -505,10 +504,10 @@ public class MobileController { String md5 = HashUtils.calculateMultipartFileMd5(file); String sha1 = HashUtils.calculateMultipartFileSha1(file); String sha256 = HashUtils.calculateMultipartFileSha256(file); - String fileName = sn + "_" + System.currentTimeMillis() + "_" + md5 + "." + fileExtension; - File destFile = new File(fileDir, fileName); - file.transferTo(destFile); + FileInfo stored = fileService.uploadFile(file, StorageDir.DEVICE_SCREENSHOT); + String url = stored.getUrl(); + int slash = url.lastIndexOf('/'); SnScreenshot screenshotInfo = new SnScreenshot(); screenshotInfo.setSn(sn); @@ -517,10 +516,10 @@ public class MobileController { // 保存MIME类型与扩展名,便于前端筛选/预览 screenshotInfo.setMimeType(file.getContentType()); screenshotInfo.setFileExtension(fileExtension); - // fileName 仅存纯文件名;前端访问 URL = /static/screenshot/{fileName}(由 WebMvcConfig 静态映射) - screenshotInfo.setFileName(fileName); - // filePath 仅存相对子目录,避免耦合绝对路径 - screenshotInfo.setFilePath(FilePath.TABLET_PATH + "/" + FilePath.SCREENSHOT_PATH + "/" + fileName); + // 对象名由存储服务生成(UUID),fileName 仅存末段便于展示 + screenshotInfo.setFileName(slash >= 0 ? url.substring(slash + 1) : url); + // 保存存储返回的完整 URL(s3 / aliyun / local),前端按 URL 直接访问 + screenshotInfo.setFilePath(url); screenshotInfo.setFileSize(file.getSize()); screenshotInfo.setFileMd5(md5); screenshotInfo.setFileSha1(sha1); @@ -530,7 +529,7 @@ public class MobileController { screenshotService.save(screenshotInfo); - logger.info("截图上传成功, sn: {}, fileName: {}", sn, fileName); + logger.info("截图上传成功, sn: {}, url: {}", sn, url); return Result.success(); } catch (Exception e) { diff --git a/src/main/java/com/youlai/boot/device/model/dto/WallpaperFileDTO.java b/src/main/java/com/youlai/boot/device/model/dto/WallpaperFileDTO.java index d018b0f2..35bc10f1 100644 --- a/src/main/java/com/youlai/boot/device/model/dto/WallpaperFileDTO.java +++ b/src/main/java/com/youlai/boot/device/model/dto/WallpaperFileDTO.java @@ -3,7 +3,7 @@ package com.youlai.boot.device.model.dto; import lombok.Data; /** - * 壁纸文件信息(上传保存后带回文件名与哈希值) + * 壁纸文件信息(上传保存后带回访问 URL 与哈希值) * * @author TTSTD * @since 2026/08/30 @@ -12,12 +12,7 @@ import lombok.Data; public class WallpaperFileDTO { /** - * 保存后的纯文件名(历史本地存储时用于拼接 /static/wallpaper/{fileName}) - */ - private String fileName; - - /** - * 文件访问地址(云存储为完整 URL,历史数据为 /static/wallpaper/{fileName}) + * 文件访问地址(对象存储返回的完整 URL) */ private String url; diff --git a/src/main/java/com/youlai/boot/device/model/vo/DevicePictureVO.java b/src/main/java/com/youlai/boot/device/model/vo/DevicePictureVO.java index 25a6af05..ae664173 100644 --- a/src/main/java/com/youlai/boot/device/model/vo/DevicePictureVO.java +++ b/src/main/java/com/youlai/boot/device/model/vo/DevicePictureVO.java @@ -12,7 +12,7 @@ import lombok.Data; public class DevicePictureVO { /** - * 图片完整URL(相对路径,如 /static/wallpaper/xxx) + * 图片访问地址(对象存储返回的完整 URL) */ private String url; diff --git a/src/main/java/com/youlai/boot/device/model/vo/WallpaperUploadVO.java b/src/main/java/com/youlai/boot/device/model/vo/WallpaperUploadVO.java index bde5072a..112fe1cb 100644 --- a/src/main/java/com/youlai/boot/device/model/vo/WallpaperUploadVO.java +++ b/src/main/java/com/youlai/boot/device/model/vo/WallpaperUploadVO.java @@ -12,7 +12,7 @@ import lombok.Data; public class WallpaperUploadVO { /** - * 保存后的图片完整URL(相对路径,如 /static/wallpaper/xxx) + * 图片访问地址(对象存储返回的完整 URL) */ private String url; diff --git a/src/main/java/com/youlai/boot/device/service/impl/DevicePictureServiceImpl.java b/src/main/java/com/youlai/boot/device/service/impl/DevicePictureServiceImpl.java index 2186d4d9..aade922a 100644 --- a/src/main/java/com/youlai/boot/device/service/impl/DevicePictureServiceImpl.java +++ b/src/main/java/com/youlai/boot/device/service/impl/DevicePictureServiceImpl.java @@ -13,8 +13,6 @@ import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Service; -import java.net.MalformedURLException; -import java.net.URL; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; @@ -115,27 +113,14 @@ public class DevicePictureServiceImpl extends ServiceImpl(); } - String prefix = normalizePrefix(accessUrlPrefix); List urls = new ArrayList<>(files.size()); List items = new ArrayList<>(files.size()); for (int i = 0; i < files.size(); i++) { WallpaperFileDTO file = files.get(i); - if (file == null || (StringUtils.isBlank(file.getUrl()) && StringUtils.isBlank(file.getFileName()))) { + if (file == null || StringUtils.isBlank(file.getUrl())) { continue; } - // 云存储直接使用存储服务返回的完整 URL;历史本地存储按静态资源前缀拼接 - String url = StringUtils.isNotBlank(file.getUrl()) - ? file.getUrl() - : prefix + "/wallpaper/" + file.getFileName(); + String url = file.getUrl(); urls.add(url); SnDeviceWallpaperItem item = new SnDeviceWallpaperItem(); item.setSerialno(sn); @@ -229,17 +220,4 @@ public class DeviceWallpaperServiceImpl extends ServiceImpl implements ScreenshotService { - /** 截图静态资源访问前缀(与 WebMvcConfig 中映射保持一致) */ - @Value("${file.access-url-prefix:/static}") - private String accessUrlPrefix; + private final FileService fileService; @Override public List listBySn(String sn) { @@ -35,23 +31,11 @@ public class ScreenshotServiceImpl extends ServiceImpl { ScreenshotVO vo = new ScreenshotVO(); BeanUtils.copyProperties(entity, vo); - // 拼接可访问的静态资源 URL:/static/screenshot/{fileName} - vo.setUrl(base + entity.getFileName()); + // file_path 存的是存储服务返回的完整访问 URL(s3 / aliyun / local) + vo.setUrl(entity.getFilePath()); return vo; }).collect(Collectors.toList()); } @@ -62,9 +46,7 @@ public class ScreenshotServiceImpl extends ServiceImpl deletePhysicalFile(entity.getFileName())); - // 删除数据库记录 + list.forEach(entity -> deleteRemoteQuietly(entity.getFilePath())); this.removeByIds(list.stream().map(SnScreenshot::getId).collect(Collectors.toList())); return list.size(); } /** - * 删除磁盘上的截图文件(基于文件名拼接目录,含安全校验防路径穿越) + * 删除存储服务上的截图对象:失败只记日志,不阻断数据库记录删除 */ - private void deletePhysicalFile(String fileName) { - if (fileName == null || fileName.isEmpty() || fileName.contains("..") || fileName.contains("/") || fileName.contains("\\")) { - log.warn("截图文件名非法,跳过物理删除: {}", fileName); - return; - } - File file = new File(FilePath.getScreenshotPath() + fileName); - if (file.exists() && file.isFile()) { - if (!file.delete()) { - log.warn("截图文件删除失败: {}", file.getAbsolutePath()); - } + private void deleteRemoteQuietly(String url) { + try { + fileService.deleteFile(url); + } catch (Exception e) { + log.warn("删除截图文件失败:{}", url, e); } } }