diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..7c148035 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,141 @@ +# AGENTS.md + +本仓库是 [youlai-boot](https://gitee.com/youlaiorg/youlai-boot) 的 TTSTD 定制分支,用于**老年平板设备管理**:设备端(sn)、家属端(client)、运营后台(device / app)。 + +## 1. 与 Gitee 上游的关系(最高优先级) + +远程仓库: + +| remote | 地址 | 用途 | +| --- | --- | --- | +| `gitee` | `git@gitee.com:youlaiorg/youlai-boot.git` | **上游只读**(push 已禁用),只用于比对/同步 | +| `origin` | `ssh://git@gitea.ttstd.com:2222/tt/youlai-boot.git` | 我方仓库 | + +**只有以下目录是我方新增的业务代码,可自由新增/修改:** + +| 目录 | 职责 | +| --- | --- | +| `src/main/java/com/youlai/boot/app/` | 客户端应用管理:应用分类、应用包(APK/XAPK/APKM 解析、上传、Mongo 存储) | +| `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`(静态资源映射) | + +**其余所有目录(`auth/`、`system/`、`framework/`、`support/`、`config/`、`module/`、`codegen/` 等)都来自上游,尽量保持不动。** + +### 修改前必做检查 + +```bash +git fetch gitee +git ls-tree -r --name-only gitee/master -- src/main/java/com/youlai/boot/xxx # 上游是否存在该文件 +git log --oneline gitee/master -- src/main/java/com/youlai/boot/xxx # 上游是否有近期改动 +``` + +- **上游存在该文件 → 尽量不改**。优先在五个自有目录内新增类/扩展点(继承、实现接口、新增 Controller)来满足需求。 +- **确实必须改上游文件时**:只做最小必要改动,不做格式化/重命名/顺带重构,提交信息注明原因,例如 + `chore(upstream): 安全配置放行 /api/v1/xxx 以支持新模块`。 +- **上游不存在该文件 → 可新增**,但应落在上述五个目录内。 + +### 已登记的例外清单 + +以下改动位于上游目录,属结构性必要或历史遗留。**请勿删除、重构或格式化**,修改前先确认调用方。 + +#### A. 上游目录中新增的文件(上游不存在,因框架结构必须留在原包) + +| 位置 | 说明 | +| --- | --- | +| `common/constant/ClientTypeConstants.java` | 端标识(admin / client) | +| `common/util/{HashUtils, FileUtils, RedisSafeOps, CodeGeneratorUtil}.java` | 校验值、文件、Redis 安全操作、编码生成工具 | +| `config/{DataSourceConfig, RedissonConfig, MqttConfig, RedisHealthConfig}.java`、`config/property/MqttProperties.java` | 多数据源、分布式锁、MQTT、Redis 健康检查 | +| `framework/security/filter/{MobileApiSignatureFilter, CachedBodyRequestWrapper}.java` | 设备端 ECDSA 验签过滤器与请求体缓存 | +| `framework/security/signature/DeviceSignatureVerifier.java` | 设备签名校验 | +| `framework/security/authorization/ClientAccessAuthorizationManager.java` | 家属端令牌隔离 | +| `support/mqtt/{MqttController, MqttMessageHandler, MqttProducer}.java` | MQTT 上报/推送 | +| `support/webrtc/**` | WebRTC 信令(`/ws/signal`) | +| `support/sms/SmsResult.java` | 短信发送结果 | +| `resources/mapper/system/DeviceMapper.xml` | 设备分页查询 | +| `test/java/com/youlai/boot/app/packages/component/ApkMetaParserTest.java` | 安装包解析测试 | + +#### B. 被修改的上游文件(只允许最小增补,不做重构/格式化) + +| 文件 | 允许的改动范围 | +| --- | --- | +| `config/SecurityConfig.java` | 放行 `/api/v1/sn/**`、`/api/v1/open/**`、`/ws/signal`;`/api/v1/client/**` 走 `ClientAccessAuthorizationManager` | +| `config/JacksonConfig.java` | **保持上游原样**(不得添加未使用的 import / 注释代码) | +| `config/RedisConfig.java` | 仅 `@Lazy` 延迟初始化;序列化方式必须与上游一致 | +| `framework/security/config/SecurityProperties.java` | client 端令牌 TTL 两个字段 | +| `framework/security/model/{SecurityUser, SecurityUserDetails}.java` | `clientType` 字段 | +| `framework/security/token/JwtTokenManager.java` | client 端 TTL 解析、`clientType` claim、校验失败日志 | +| `common/constant/JwtClaimConstants.java`、`common/constant/RedisConstants.java` | `clientType` claim、设备在线 key、短信验证码 key | +| `common/enums/{ActionTypeEnum, LogModuleEnum}.java`、`common/result/ResultCode.java` | 仅追加新枚举值/错误码 | +| `framework/web/advice/GlobalExceptionHandler.java` | Redis 相关异常、SSE 超时、静态资源 404 | +| `framework/web/log/OperationLogAspect.java` | 设备无登录会话时的操作人兜底;`error_msg` 上限 2000 | +| `system/service/impl/ConfigServiceImpl.java` | 缓存预热失败不阻断启动 | +| `support/file/model/FileInfo.java`、`support/file/service/impl/LocalFileServiceImpl.java` | md5/sha1/sha256 校验值 | +| `support/sms/{SmsTypeEnum, service/SmsService, service/impl/AliyunSmsServiceImpl}.java` | `sendWithResult`,按响应码判定成败 | +| `support/sse/{SseService, SseSessionManager, constant/SseEventConstants}.java` | 设备事件主题 | +| `pom.xml` | 新增依赖(websocket、mongodb、mqtt、apk-parser、apkanalyzer、redisson 等) | +| `.gitignore` | 目前仅新增 `/uploadFile/` | +| `resources/application-dev.yml`、`resources/application-prod.yml` | 数据源(mysql/tdengine)、redis、mongodb、oss、sms、mqtt、安全白名单、令牌 TTL | +| `sql/youlai-admin.sql` | `sys_log.error_msg` 由 1000 调整为 2000 | + +#### C. 数据与运维脚本(非 Java 代码,允许新增) + +`sql/*.sql`、`docs/sql/*.sql`、`docker/*`。 + +#### D. 禁止事项 + +- 上游文件里做格式化、重命名、import 重排、注释掉代码等无关改动。 +- 配置文件提交真实凭证:密码/AccessKey 一律用环境变量或本地 profile 覆盖,不写入版本库。 +- 在上游目录新增 Java 类(应放到自有目录,例如 `ByteArrayMultipartFile` 放在 `app/packages/component/`)。 + +## 2. 技术栈 + +Java 17 · Spring Boot 4.0.5 · Spring Security · MyBatis-Plus 3.5.15 · MySQL · Redis + Redisson · MongoDB(`spring-boot-starter-data-mongodb`) · WebSocket · knife4j/springdoc · MapStruct + Lombok · Hutool · apk-parser / apkanalyzer + +## 3. 构建与运行 + +```bash +mvn -DskipTests clean package # 打包,产物 target/youlai-boot-4.12.0.jar +mvn test # 测试 +java -jar target/youlai-boot-4.12.0.jar --spring.profiles.active=dev +``` + +- 默认 profile 为 `dev`(`application.yml`),配置见 `application-dev.yml` / `application-prod.yml`;代码生成配置在 `codegen.yml`。 +- 数据库连接请使用本地 `/ 环境变量` 覆盖,**不要提交真实账号密码**。 +- Windows 上 shell 为 PowerShell,勿使用 `head`/`tail`/`cd /d` 等 cmd/Unix 语法。 + +## 4. 代码约定 + +包结构(自有模块统一遵循): + +``` +xxx/ +├── controller/ # @RestController,路径 /api/v1/... +├── service/ # 接口 +│ └── impl/ # 实现,继承 MyBatis-Plus ServiceImpl +├── converter/ # MapStruct 转换器 +├── mapper/ # MyBatis-Plus Mapper(自有模块一般不写 XML) +├── repository/ # Mongo 仓储(接口 + impl,用 MongoTemplate) +└── model/ + ├── entity/ # MySQL 实体(设备相关以 Sn 前缀命名,如 SnDeviceInfo) + ├── document/ # Mongo 文档(如 AppPackageDocument) + ├── form/ req/ query/ # 入参 + └── vo/ dto/ # 出参 +``` + +- 统一返回 `Result` / `PageResult`,分页参数用 `PageQuery`,分页结果用 `IPage`。 +- 构造器注入:类加 `@RequiredArgsConstructor`,字段 `private final`。 +- 接口文档:`@Tag(name = "序号.名称")` + `@Operation(summary = ...)`。 +- 操作日志:`@Log(module = LogModuleEnum.X, value = ActionTypeEnum.Y)`。 +- 鉴权分区: + - `/api/v1/sn/**`:设备端,`MobileApiSignatureFilter` 做 ECDSA 设备签名校验(非 JWT) + - `/api/v1/client/**`:家属端,仅接受 `clientType=client` 的令牌 + - `/api/v1/open/**`:免登录(登录/注册/找回密码) + - 后台接口(`/api/v1/device`、`/api/v1/app/**`、`/api/v1/developers` 等):走 Spring Security,按需 `@PreAuthorize` +- 文件落盘统一走 `FilePath`(根目录为 `user.dir` 下的 `uploadFile/tablet/{screenshot,packages,category_icon,wallpaper}`),HTTP 访问走 `/static/{子目录}/**`,由 `WebMvcConfig` 映射;截图/壁纸用 `noCache`,安装包/分类图标用长缓存。 +- 新增后台菜单/表结构请补充 `sql/` 脚本,保持与代码同步。 + +## 5. 提交约定 + +采用 Conventional Commits(`feat(device): ...`、`fix(client): ...`、`refactor(config): ...`、`chore: ...`)。涉及上游文件的改动需在正文说明原因。 diff --git a/sql/app_icon.sql b/sql/app_icon.sql index 1f813aa2..cd578cf8 100644 --- a/sql/app_icon.sql +++ b/sql/app_icon.sql @@ -11,11 +11,16 @@ -- 约定: -- - 同一 package_name 下 status=1(当前生效)的记录有且仅有一条 -- - 历史版本(status=0)仅用于展示与选取锁定,不物理删除 --- - 图标文件按 {md5}.{ext} 内容寻址存储,访问路径由后端拼接为 /static/app_icon/{md5}.{ext} --- (该前缀与 WebMvcConfig 注册的静态资源映射一致;内容变更必然导致文件名变更,故可长缓存) +-- - 版本号生成规则(必须严格遵守): +-- version = 该 package_name 下所有记录的 MAX(version) + 1 +-- 统计范围必须包含 is_deleted=1 的逻辑删除行,版本号只增不减、永不复用。 +-- 原因:uk_package_version 不包含 is_deleted,若用「当前生效版本+1」或从 1 重新开始, +-- 当前生效记录被删除/缺失时会与残留版本号撞唯一键(Duplicate entry '包名-1')。 +-- - 图标文件统一存入 storage.type 配置的存储(当前为阿里云 OSS),icon_url 为可直接访问的完整 URL -- -- 部署规范:不直连库,由 DBA 执行;先 DROP 再 CREATE 确保结构与实体完全一致 -- (DROP 会清空历史图标数据,可由设备端重新上报补齐)。 +-- 存量库修复:不重建表的场景,先执行 sql/app_icon_version_repair.sql 清理脏数据。 -- ============================================================================= DROP TABLE IF EXISTS `app_icon`; @@ -25,11 +30,11 @@ CREATE TABLE `app_icon` `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', `package_name` VARCHAR(255) NOT NULL COMMENT '应用包名(全局唯一维度,同包名多版本共存)', `app_name` VARCHAR(128) DEFAULT NULL COMMENT '应用名称(上报时的展示名,可为空;新版本上报时刷新)', - `icon_url` VARCHAR(500) NOT NULL COMMENT '图标访问地址(相对路径,如 /static/app_icon/{md5}.png)', + `icon_url` VARCHAR(500) NOT NULL COMMENT '图标访问地址(存储服务返回的完整 URL)', `icon_md5` VARCHAR(64) DEFAULT NULL COMMENT '图标文件MD5(内容指纹,用于去重与换图标检测)', `icon_sha1` VARCHAR(64) DEFAULT NULL COMMENT '图标文件SHA1', `icon_sha256` VARCHAR(64) DEFAULT NULL COMMENT '图标文件SHA256', - `version` INT NOT NULL DEFAULT 1 COMMENT '版本号(同包名内自增,从1开始)', + `version` INT NOT NULL DEFAULT 1 COMMENT '版本号(同包名内自增,取所有记录含逻辑删除的 MAX(version)+1,不复用)', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态(1当前生效 0历史版本)', `locked` TINYINT NOT NULL DEFAULT 0 COMMENT '是否锁定(0未锁定 1已锁定,锁定后不可上传覆盖)', `source` VARCHAR(20) NOT NULL DEFAULT 'device' COMMENT '图标来源(device设备端上报 admin管理端上传)', diff --git a/sql/app_icon_version_repair.sql b/sql/app_icon_version_repair.sql new file mode 100644 index 00000000..16db1b7d --- /dev/null +++ b/sql/app_icon_version_repair.sql @@ -0,0 +1,92 @@ +-- ============================================================================= +-- app_icon 版本号 / 生效状态 存量数据修复脚本 +-- +-- 背景: +-- 旧逻辑用「当前生效版本 + 1」生成版本号,无当前生效记录时固定用 1; +-- 而删除是逻辑删除(is_deleted=1),且唯一索引 uk_package_version(package_name, version) +-- 不包含 is_deleted。当前生效记录被删除或缺失时,再次上传就会撞唯一键: +-- Duplicate entry 'com_alibaba_android_rimet-1' for key 'app_icon.uk_package_version' +-- +-- 新逻辑: +-- version = 该 package_name 下所有记录(含 is_deleted=1)的 MAX(version) + 1, +-- 版本号只增不减、永不复用;上传 / 锁定已按包名加锁并置于同一事务内。 +-- +-- 使用说明: +-- 1. 本脚本仅用于「已在运行、不重建表」的库;全新部署执行 app_icon.sql 即可。 +-- 2. 由 DBA 在业务低峰期单独执行,执行前请备份 app_icon 表。 +-- 3. 诊断语句(第 1 ~ 3 段)先执行确认现状,再执行修复语句(第 4 ~ 5 段)。 +-- ============================================================================= + +-- ----------------------------------------------------------------------------- +-- 1. 诊断:同一包名存在多条 status=1(会导致 one() 抛 TooManyResultsException) +-- ----------------------------------------------------------------------------- +SELECT package_name, COUNT(*) AS active_cnt +FROM app_icon +WHERE is_deleted = 0 + AND status = 1 +GROUP BY package_name +HAVING COUNT(*) > 1; + +-- ----------------------------------------------------------------------------- +-- 2. 诊断:同一包名有未删除记录,但没有任何 status=1(上传会走「无当前版本」分支) +-- ----------------------------------------------------------------------------- +SELECT package_name, COUNT(*) AS alive_cnt, MAX(version) AS max_version +FROM app_icon +WHERE is_deleted = 0 +GROUP BY package_name +HAVING SUM(status = 1) = 0; + +-- ----------------------------------------------------------------------------- +-- 3. 诊断:查看具体包名的版本与删除残留 +-- ----------------------------------------------------------------------------- +SELECT id, package_name, version, status, locked, is_deleted, update_time +FROM app_icon +WHERE package_name = 'com_alibaba_android_rimet' +ORDER BY is_deleted, version; + +-- ----------------------------------------------------------------------------- +-- 4. 修复多条 status=1:保留 version 最大的一条为当前生效,其余置为历史 +-- ----------------------------------------------------------------------------- +UPDATE app_icon a + JOIN (SELECT package_name, MAX(version) AS max_version + FROM app_icon + WHERE is_deleted = 0 + AND status = 1 + GROUP BY package_name) t + ON a.package_name = t.package_name +SET a.status = 0, + a.update_time = NOW() +WHERE a.is_deleted = 0 + AND a.status = 1 + AND a.version < t.max_version; + +-- ----------------------------------------------------------------------------- +-- 5. 修复无 status=1:把未删除记录里版本号最大的一条置为当前生效 +-- ----------------------------------------------------------------------------- +UPDATE app_icon a + JOIN (SELECT package_name, MAX(version) AS max_version + FROM app_icon + WHERE is_deleted = 0 + GROUP BY package_name + HAVING SUM(status = 1) = 0) t + ON a.package_name = t.package_name + AND a.version = t.max_version +SET a.status = 1, + a.update_time = NOW() +WHERE a.is_deleted = 0; + +-- ----------------------------------------------------------------------------- +-- 6. 复核:两条诊断语句均应返回空结果集 +-- ----------------------------------------------------------------------------- +SELECT package_name, COUNT(*) AS active_cnt +FROM app_icon +WHERE is_deleted = 0 + AND status = 1 +GROUP BY package_name +HAVING COUNT(*) > 1; + +SELECT package_name, COUNT(*) AS alive_cnt +FROM app_icon +WHERE is_deleted = 0 +GROUP BY package_name +HAVING SUM(status = 1) = 0; diff --git a/src/main/java/com/youlai/boot/app/packages/component/ByteArrayMultipartFile.java b/src/main/java/com/youlai/boot/app/packages/component/ByteArrayMultipartFile.java new file mode 100644 index 00000000..e47a3547 --- /dev/null +++ b/src/main/java/com/youlai/boot/app/packages/component/ByteArrayMultipartFile.java @@ -0,0 +1,73 @@ +package com.youlai.boot.app.packages.component; + +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; + +/** + * 基于内存字节数组的 {@link MultipartFile} 实现 + *

+ * 用于把解析得到的内存数据(如 APK 内提取的图标)交给 + * {@link com.youlai.boot.support.file.service.FileService} 上传, + * 避免为字节流场景单独复制一套存储实现。 + * + * @author TTSTD + */ +public class ByteArrayMultipartFile implements MultipartFile { + + private final String name; + private final String originalFilename; + private final String contentType; + private final byte[] content; + + public ByteArrayMultipartFile(String name, String originalFilename, String contentType, byte[] content) { + this.name = name; + this.originalFilename = originalFilename; + this.contentType = contentType; + this.content = content == null ? new byte[0] : content; + } + + @Override + public String getName() { + return name; + } + + @Override + public String getOriginalFilename() { + return originalFilename; + } + + @Override + public String getContentType() { + return contentType; + } + + @Override + public boolean isEmpty() { + return content.length == 0; + } + + @Override + public long getSize() { + return content.length; + } + + @Override + public byte[] getBytes() { + return content; + } + + @Override + public InputStream getInputStream() { + return new ByteArrayInputStream(content); + } + + @Override + public void transferTo(File dest) throws IOException { + Files.write(dest.toPath(), content); + } +} 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 eb70458a..889eedce 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 @@ -5,12 +5,15 @@ import cn.hutool.core.util.IdUtil; import cn.hutool.crypto.digest.DigestUtil; import com.youlai.boot.app.packages.component.ApkMetaParser; import com.youlai.boot.app.packages.component.ApkMetaResult; +import com.youlai.boot.app.packages.component.ByteArrayMultipartFile; import com.youlai.boot.app.packages.model.document.AppPackageDocument; 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.support.file.model.FileInfo; +import com.youlai.boot.support.file.service.FileService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.BeanUtils; @@ -22,7 +25,6 @@ import java.io.File; import java.io.IOException; import java.math.BigDecimal; import java.math.RoundingMode; -import java.nio.file.Files; import java.time.LocalDateTime; import java.util.List; @@ -36,6 +38,9 @@ public class AppPackageServiceImpl implements AppPackageService { private final AppPackageRepository appPackageRepository; + /** 文件存储服务:由 storage.type 决定激活 s3 / aliyun / local 实现 */ + private final FileService fileService; + private static final List ALLOWED_EXT = List.of("apk", "apks", "aab", "apkm", "xapk"); @Override @@ -94,19 +99,7 @@ public class AppPackageServiceImpl implements AppPackageService { .compileSdk(meta.getCompileSdk()) .abiList(meta.getAbiList()) .permissions(meta.getPermissions()); - if (meta.getIconPath() != null && meta.getIconBytes() != null && meta.getIconBytes().length > 0) { - String iconMd5 = DigestUtil.md5Hex(meta.getIconBytes()); - String iconName = iconMd5 + ".png"; - File iconDir = new File(FilePath.getApkIconPath()); - if (!iconDir.exists()) { - iconDir.mkdirs(); - } - File iconTarget = new File(iconDir, iconName); - if (!iconTarget.exists()) { - Files.write(iconTarget.toPath(), meta.getIconBytes()); - } - builder.iconUrl("/static/app_icon/" + iconName); - } + applyIcon(builder, meta); } catch (Exception e) { log.warn("APK 元数据解析失败,使用表单补充信息:{}", e.getMessage()); fillFallback(builder, packageName, versionName, versionCode); @@ -124,19 +117,7 @@ public class AppPackageServiceImpl implements AppPackageService { .compileSdk(meta.getCompileSdk()) .abiList(meta.getAbiList()) .permissions(meta.getPermissions()); - if (meta.getIconPath() != null && meta.getIconBytes() != null && meta.getIconBytes().length > 0) { - String iconMd5 = DigestUtil.md5Hex(meta.getIconBytes()); - String iconName = iconMd5 + ".png"; - File iconDir = new File(FilePath.getApkIconPath()); - if (!iconDir.exists()) { - iconDir.mkdirs(); - } - File iconTarget = new File(iconDir, iconName); - if (!iconTarget.exists()) { - Files.write(iconTarget.toPath(), meta.getIconBytes()); - } - builder.iconUrl("/static/app_icon/" + iconName); - } + applyIcon(builder, meta); } catch (Exception e) { log.warn("{} 元数据解析失败,使用表单补充信息:{}", ext, e.getMessage()); fillFallback(builder, packageName, versionName, versionCode); @@ -150,6 +131,19 @@ public class AppPackageServiceImpl implements AppPackageService { return toVO(saved); } + /** + * 将 APK 内解析出的图标写入配置的存储(storage.type:s3 / aliyun / local),并回填访问地址 + */ + private void applyIcon(AppPackageDocument.AppPackageDocumentBuilder builder, ApkMetaResult meta) { + if (meta.getIconPath() == null || meta.getIconBytes() == null || meta.getIconBytes().length == 0) { + return; + } + String iconName = DigestUtil.md5Hex(meta.getIconBytes()) + ".png"; + FileInfo iconInfo = fileService.uploadFile( + new ByteArrayMultipartFile("file", iconName, "image/png", meta.getIconBytes())); + builder.iconUrl(iconInfo.getUrl()); + } + /** * 保存应用截图到独立的 screenshot 目录(与安装包、图标分目录存储) */ @@ -212,10 +206,13 @@ public class AppPackageServiceImpl implements AppPackageService { if (doc.getFileName() != null) { FileUtil.del(FilePath.getPackagePath() + doc.getFileName()); } - // 删除本地图标文件 - if (doc.getIconUrl() != null && doc.getIconUrl().startsWith("/static/app_icon/")) { - String iconName = doc.getIconUrl().substring("/static/app_icon/".length()); - FileUtil.del(FilePath.getApkIconPath() + iconName); + // 删除云端图标对象(历史 /static 本地图标已下线索引,不再处理) + if (doc.getIconUrl() != null && doc.getIconUrl().startsWith("http")) { + try { + fileService.deleteFile(doc.getIconUrl()); + } catch (Exception e) { + log.warn("删除云端图标失败:{}", doc.getIconUrl(), e); + } } // 删除本地截图文件(独立 screenshot 目录) if (doc.getScreenshots() != null) { diff --git a/src/main/java/com/youlai/boot/common/config/FilePath.java b/src/main/java/com/youlai/boot/common/config/FilePath.java index 6ee13484..6d8290cb 100644 --- a/src/main/java/com/youlai/boot/common/config/FilePath.java +++ b/src/main/java/com/youlai/boot/common/config/FilePath.java @@ -21,7 +21,6 @@ public class FilePath { 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"; @@ -43,10 +42,6 @@ public class FilePath { return getRootPath() + File.separator + TABLET_PATH + File.separator + AVATAR_PATH + File.separator; } - 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; } diff --git a/src/main/java/com/youlai/boot/common/config/WebMvcConfig.java b/src/main/java/com/youlai/boot/common/config/WebMvcConfig.java index 4e3f3442..82b33e73 100644 --- a/src/main/java/com/youlai/boot/common/config/WebMvcConfig.java +++ b/src/main/java/com/youlai/boot/common/config/WebMvcConfig.java @@ -17,23 +17,10 @@ import java.util.concurrent.TimeUnit; public class WebMvcConfig implements WebMvcConfigurer { /** - * 应用图标静态资源映射: - * /static/app_icon/{md5}.{ext} -> 磁盘 apkIcon 目录 - *

- * 使用独立的 /static 前缀而非 /api/v1/device/**,避免与业务 Controller - * (DeviceController、DeviceOpsController)的 @RequestMapping 命名空间重叠: - * Controller 映射优先级高于静态资源,一旦后续新增同名路径接口会静默劫持图标请求。 - *

- * 图标文件名为内容 MD5(内容寻址),内容变更必然导致 URL 变更, - * 因此可安全使用 immutable 长缓存,浏览器无需回源校验。 + * 静态资源映射(应用图标已改为云端存储,不再本地映射) */ @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { - String location = "file:" + FilePath.getApkIconPath().replace("\\", "/"); - registry.addResourceHandler("/static/app_icon/**") - .addResourceLocations(location) - .setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic().immutable()); - // 设备截图静态资源映射:/static/screenshot/{fileName} -> 磁盘 tablet/screenshot 目录 // 截图内容会变化(删除/重截),且文件名 md5 可能因画面相同而重复, // 故不使用长缓存,避免浏览器复用旧图(表现为 304 + 图片不更新)。 diff --git a/src/main/java/com/youlai/boot/config/JacksonConfig.java b/src/main/java/com/youlai/boot/config/JacksonConfig.java index 638e8aa6..6fd3dafd 100644 --- a/src/main/java/com/youlai/boot/config/JacksonConfig.java +++ b/src/main/java/com/youlai/boot/config/JacksonConfig.java @@ -2,7 +2,6 @@ package com.youlai.boot.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import tools.jackson.databind.PropertyNamingStrategies; import tools.jackson.databind.cfg.DateTimeFeature; import tools.jackson.databind.json.JsonMapper; import tools.jackson.databind.module.SimpleModule; @@ -34,7 +33,6 @@ public class JacksonConfig { .addSerializer(Long.class, ToStringSerializer.instance) .addSerializer(BigInteger.class, ToStringSerializer.instance) ) -// .propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE) .build(); } diff --git a/src/main/java/com/youlai/boot/config/RedisConfig.java b/src/main/java/com/youlai/boot/config/RedisConfig.java index da7eb953..414ca501 100644 --- a/src/main/java/com/youlai/boot/config/RedisConfig.java +++ b/src/main/java/com/youlai/boot/config/RedisConfig.java @@ -1,34 +1,29 @@ package com.youlai.boot.config; -import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Lazy; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.serializer.GenericJacksonJsonRedisSerializer; +import org.springframework.data.redis.serializer.JacksonJsonRedisSerializer; import org.springframework.data.redis.serializer.RedisSerializer; -import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.cfg.DateTimeFeature; +import tools.jackson.databind.json.JsonMapper; /** * Redis 配置 - *

- * 使用 Lazy 初始化,避免 Redis 不可用时阻塞应用启动。 * * @author Ray.Hao * @since 2023/5/15 */ -@Slf4j @Configuration -@ConditionalOnClass(RedisConnectionFactory.class) public class RedisConfig { /** * 自定义 RedisTemplate *

- * 修改 Redis 序列化方式,默认 JdkSerializationRedisSerializer。 - * 延迟初始化:即使 Redis 不可用,应用也能正常启动。 + * 相对上游仅增加 {@link Lazy}:Redis 不可用时延迟初始化,不阻塞应用启动; + * 序列化方式与上游保持一致,避免缓存数据读写格式不兼容。 * * @param redisConnectionFactory {@link RedisConnectionFactory} * @return {@link RedisTemplate} @@ -36,7 +31,6 @@ public class RedisConfig { @Bean @Lazy public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) { - log.info("初始化 RedisTemplate..."); RedisTemplate redisTemplate = new RedisTemplate<>(); redisTemplate.setConnectionFactory(redisConnectionFactory); @@ -45,14 +39,15 @@ public class RedisConfig { redisTemplate.setKeySerializer(RedisSerializer.string()); redisTemplate.setHashKeySerializer(RedisSerializer.string()); - GenericJacksonJsonRedisSerializer jsonSerializer = new GenericJacksonJsonRedisSerializer(new ObjectMapper()); + JsonMapper jsonMapper = JsonMapper.builder() + .disable(DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS) + .build(); + JacksonJsonRedisSerializer jsonSerializer = new JacksonJsonRedisSerializer<>(jsonMapper, Object.class); redisTemplate.setValueSerializer(jsonSerializer); redisTemplate.setHashValueSerializer(jsonSerializer); redisTemplate.afterPropertiesSet(); - - log.info("RedisTemplate 初始化完成"); return redisTemplate; } diff --git a/src/main/java/com/youlai/boot/device/mapper/AppIconMapper.java b/src/main/java/com/youlai/boot/device/mapper/AppIconMapper.java index d4c0658a..d3e49e83 100644 --- a/src/main/java/com/youlai/boot/device/mapper/AppIconMapper.java +++ b/src/main/java/com/youlai/boot/device/mapper/AppIconMapper.java @@ -3,10 +3,25 @@ package com.youlai.boot.device.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.youlai.boot.device.model.entity.AppIcon; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; /** * 应用图标库Mapper */ @Mapper public interface AppIconMapper extends BaseMapper { + + /** + * 查询指定包名下所有记录的最大版本号(包含已逻辑删除的行) + *

+ * 自定义 SQL 不会经过 MyBatis-Plus 的 @TableLogic 自动拼条件, + * 因此 is_deleted=1 的历史行同样参与计算,保证版本号不被复用, + * 避免与唯一索引 uk_package_version(package_name, version) 冲突。 + * + * @param packageName 应用包名 + * @return 最大版本号,无记录时返回 0 + */ + @Select("SELECT COALESCE(MAX(`version`), 0) FROM `app_icon` WHERE `package_name` = #{packageName}") + Integer selectMaxVersion(@Param("packageName") String packageName); } diff --git a/src/main/java/com/youlai/boot/device/model/entity/AppIcon.java b/src/main/java/com/youlai/boot/device/model/entity/AppIcon.java index 6e528745..63ef9da5 100644 --- a/src/main/java/com/youlai/boot/device/model/entity/AppIcon.java +++ b/src/main/java/com/youlai/boot/device/model/entity/AppIcon.java @@ -31,7 +31,7 @@ public class AppIcon extends BaseEntity { private String appName; /** - * 图标访问地址(相对路径,如 /static/app_icon/{md5}.png) + * 图标访问地址(由 storage.type 决定:云存储为完整 URL,本地存储为相对路径) */ private String iconUrl; diff --git a/src/main/java/com/youlai/boot/device/service/impl/AppIconServiceImpl.java b/src/main/java/com/youlai/boot/device/service/impl/AppIconServiceImpl.java index 88d1baed..63b8e637 100644 --- a/src/main/java/com/youlai/boot/device/service/impl/AppIconServiceImpl.java +++ b/src/main/java/com/youlai/boot/device/service/impl/AppIconServiceImpl.java @@ -4,7 +4,6 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; -import com.youlai.boot.common.config.FilePath; import com.youlai.boot.common.exception.BusinessException; import com.youlai.boot.common.result.ResultCode; import com.youlai.boot.common.util.HashUtils; @@ -13,25 +12,32 @@ import com.youlai.boot.device.model.entity.AppIcon; import com.youlai.boot.device.model.vo.AppIconVO; import com.youlai.boot.device.service.AppIconService; import com.youlai.boot.framework.security.util.SecurityUtils; +import com.youlai.boot.support.file.model.FileInfo; +import com.youlai.boot.support.file.service.FileService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.io.FilenameUtils; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.util.Assert; import org.springframework.util.StringUtils; import org.springframework.web.multipart.MultipartFile; -import java.io.File; import java.io.IOException; import java.security.NoSuchAlgorithmException; import java.util.Collection; import java.util.HashMap; import java.util.List; -import java.util.stream.Collectors; -import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Supplier; +import java.util.stream.Collectors; /** * 应用图标库服务实现类 @@ -59,11 +65,23 @@ public class AppIconServiceImpl extends ServiceImpl impl /** 应用名称最大长度,需与 app_icon.app_name 字段长度一致 */ private static final int APP_NAME_MAX_LENGTH = 128; - /** - * 图标访问地址前缀,需与 {@link com.youlai.boot.common.config.WebMvcConfig} 中 - * 注册的静态资源路径保持一致,修改时必须同步 - */ - private static final String ICON_URL_PREFIX = "/static/app_icon/"; + /** 图标上传分布式锁 key 前缀(按包名维度串行化,避免并发取到相同版本号) */ + private static final String LOCK_KEY_PREFIX = "lock:app_icon:upload:"; + + /** 本地锁分段数:Redis 不可用时兜底为进程内串行 */ + private static final int LOCAL_LOCK_STRIPES = 64; + + /** 获取锁的最长等待秒数 */ + private static final long LOCK_WAIT_SECONDS = 10; + + /** 文件存储服务:由 storage.type 决定激活 s3 / aliyun / local 实现 */ + private final FileService fileService; + + /** Redisson 客户端(Redis 未配置时为空,自动降级为本地锁) */ + private final ObjectProvider redissonClientProvider; + + /** 本地分段锁,key 相同的请求在单机内互斥 */ + private final ReentrantLock[] localLocks = createLocalLocks(); @Override public AppIconVO getCurrentIcon(String packageName) { @@ -84,10 +102,14 @@ public class AppIconServiceImpl extends ServiceImpl impl } @Override + @Transactional(rollbackFor = Exception.class) public boolean lockIcon(String packageName, Long iconId) { Assert.isTrue(packageName != null && packageName.matches("[A-Za-z0-9._]+"), "应用包名不合法"); Assert.notNull(iconId, "图标记录ID不能为空"); + return withPackageLock(packageName, () -> doLockIcon(packageName, iconId)); + } + private boolean doLockIcon(String packageName, Long iconId) { AppIcon target = this.getById(iconId); Assert.notNull(target, "图标记录不存在"); Assert.isTrue(packageName.equals(target.getPackageName()), "图标记录不属于该应用"); @@ -130,6 +152,7 @@ public class AppIconServiceImpl extends ServiceImpl impl } @Override + @Transactional(rollbackFor = Exception.class) public String uploadIcon(String packageName, String appName, MultipartFile file, String source) { Assert.isTrue(file != null && !file.isEmpty(), "上传文件不能为空"); Assert.isTrue(packageName != null && packageName.matches("[A-Za-z0-9._]+"), "应用包名不合法"); @@ -157,6 +180,15 @@ public class AppIconServiceImpl extends ServiceImpl impl throw new RuntimeException("计算图标文件哈希失败", e); } + // 同一包名的「读当前版本 → 计算新版本号 → 落库」必须串行, + // 否则并发上传会取到相同版本号,触发唯一键 uk_package_version 冲突 + String finalAppName = safeAppName; + return withPackageLock(packageName, + () -> doUploadIcon(packageName, finalAppName, file, source, md5, sha1, sha256)); + } + + private String doUploadIcon(String packageName, String safeAppName, MultipartFile file, + String source, String md5, String sha1, String sha256) { AppIcon current = getActiveEntity(packageName); // 内容未变化:不产生新版本,但应用名可能已变更(如应用改名),需同步刷新; @@ -186,9 +218,13 @@ public class AppIconServiceImpl extends ServiceImpl impl throw new BusinessException(ResultCode.APP_ICON_LOCKED); } - // 保存文件(内容寻址 {md5}.{ext},文件已存在则复用) - String iconUrl = saveIconFile(md5, extension.toLowerCase(), file); + // 保存文件:走 storage.type 配置的存储实现(s3 / aliyun / local) + String iconUrl = saveIconFile(file); + // 版本号取该包名下所有记录(含已逻辑删除行)的最大值 +1: + // 唯一索引 uk_package_version 不区分 is_deleted,若仍用「当前生效版本 +1」或固定 1, + // 一旦当前生效记录被删除/缺失,就会与残留版本号撞唯一键(Duplicate entry 'xxx-1') + int nextVersion = getNextVersion(packageName); Long userId = SecurityUtils.getUserId(); if (current != null) { // 原当前版本转为历史 @@ -204,7 +240,7 @@ public class AppIconServiceImpl extends ServiceImpl impl next.setIconMd5(md5); next.setIconSha1(sha1); next.setIconSha256(sha256); - next.setVersion(current.getVersion() + 1); + next.setVersion(nextVersion); next.setStatus(STATUS_ACTIVE); next.setLocked(UNLOCKED); next.setSource(source); @@ -219,7 +255,7 @@ public class AppIconServiceImpl extends ServiceImpl impl icon.setIconMd5(md5); icon.setIconSha1(sha1); icon.setIconSha256(sha256); - icon.setVersion(1); + icon.setVersion(nextVersion); icon.setStatus(STATUS_ACTIVE); icon.setLocked(UNLOCKED); icon.setSource(source); @@ -228,12 +264,14 @@ public class AppIconServiceImpl extends ServiceImpl impl this.save(icon); } - log.info("应用图标上传成功, packageName: {}, md5: {}, iconUrl: {}", packageName, md5, iconUrl); + log.info("应用图标上传成功, packageName: {}, version: {}, md5: {}, iconUrl: {}", + packageName, nextVersion, md5, iconUrl); return iconUrl; } @Override - public Map getActiveIconUrls(Collection packageNames) { if (packageNames == null || packageNames.isEmpty()) { + public Map getActiveIconUrls(Collection packageNames) { + if (packageNames == null || packageNames.isEmpty()) { return Map.of(); } List activeIcons = this.lambdaQuery() @@ -249,37 +287,42 @@ public class AppIconServiceImpl extends ServiceImpl impl /** * 查询当前生效的图标记录 + *

+ * 用 orderBy + LIMIT 1 兜底:历史脏数据可能残留多条 status=1, + * {@code one()} 会抛 TooManyResultsException 导致该包图标上传永久失败。 */ private AppIcon getActiveEntity(String packageName) { return this.lambdaQuery() .eq(AppIcon::getPackageName, packageName) .eq(AppIcon::getStatus, STATUS_ACTIVE) + .orderByDesc(AppIcon::getVersion) + .last("LIMIT 1") .one(); } /** - * 保存图标文件(内容寻址 {md5}.{ext},文件已存在则复用) + * 计算下一个版本号:该包名下所有记录(含已逻辑删除)的 MAX(version) + 1 + *

+ * 走自定义 SQL,绕过 MyBatis-Plus 的 @TableLogic 自动追加条件, + * 保证已逻辑删除的版本号不会被复用,避免唯一键冲突。 */ - private String saveIconFile(String md5, String extension, MultipartFile file) { - String iconDirPath = FilePath.getApkIconPath(); - File iconDir = new File(iconDirPath); - if (!iconDir.exists() && !iconDir.mkdirs()) { - throw new RuntimeException("创建图标目录失败: " + iconDirPath); - } + private int getNextVersion(String packageName) { + Integer maxVersion = baseMapper.selectMaxVersion(packageName); + return (maxVersion == null ? 0 : maxVersion) + 1; + } - String fileName = md5 + "." + extension; - File destFile = new File(iconDir, fileName); - if (destFile.exists()) { - return ICON_URL_PREFIX + fileName; + /** + * 保存图标文件:交由 {@link FileService} 处理,具体落盘/上云由 storage.type 决定 + *

+ * 返回的地址由存储实现决定:aliyun/s3 为完整 http(s) URL, + * local 为相对存储根目录的路径,前端拼访问前缀。 + */ + private String saveIconFile(MultipartFile file) { + FileInfo fileInfo = fileService.uploadFile(file); + if (fileInfo == null || !StringUtils.hasText(fileInfo.getUrl())) { + throw new BusinessException("保存应用图标失败"); } - - try { - file.transferTo(destFile); - } catch (IOException e) { - log.error("保存图标文件失败, fileName: {}", fileName, e); - throw new RuntimeException("保存应用图标失败"); - } - return ICON_URL_PREFIX + fileName; + return fileInfo.getUrl(); } @Override @@ -319,4 +362,65 @@ public class AppIconServiceImpl extends ServiceImpl impl BeanUtils.copyProperties(entity, vo); return vo; } -} \ No newline at end of file + + // ==================== 按包名串行化 ==================== + + private static ReentrantLock[] createLocalLocks() { + ReentrantLock[] locks = new ReentrantLock[LOCAL_LOCK_STRIPES]; + for (int i = 0; i < locks.length; i++) { + locks[i] = new ReentrantLock(); + } + return locks; + } + + /** + * 以包名为维度串行执行:优先 Redis 分布式锁(多实例),失败时降级为本地分段锁(单机) + */ + private T withPackageLock(String packageName, Supplier action) { + ReentrantLock localLock = localLocks[Math.floorMod(packageName.hashCode(), LOCAL_LOCK_STRIPES)]; + localLock.lock(); + RLock distributedLock = tryLockDistributed(packageName); + try { + return action.get(); + } finally { + unlockQuietly(distributedLock); + localLock.unlock(); + } + } + + /** + * 尝试获取分布式锁,Redis 不可用或等待超时返回 null(不阻断业务) + */ + private RLock tryLockDistributed(String packageName) { + try { + RedissonClient client = redissonClientProvider.getIfAvailable(); + if (client == null) { + return null; + } + RLock lock = client.getLock(LOCK_KEY_PREFIX + packageName); + if (lock.tryLock(LOCK_WAIT_SECONDS, TimeUnit.SECONDS)) { + return lock; + } + log.warn("等待图标上传分布式锁超时,转由本地锁串行, packageName: {}", packageName); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("获取图标上传分布式锁被中断,转由本地锁串行, packageName: {}", packageName); + } catch (Exception e) { + log.warn("获取图标上传分布式锁异常,转由本地锁串行, packageName: {}", packageName, e); + } + return null; + } + + private void unlockQuietly(RLock lock) { + if (lock == null) { + return; + } + try { + if (lock.isHeldByCurrentThread()) { + lock.unlock(); + } + } catch (Exception e) { + log.warn("释放图标上传分布式锁异常, key: {}", lock.getName(), e); + } + } +} diff --git a/src/main/java/com/youlai/boot/support/file/service/impl/AliyunFileServiceImpl.java b/src/main/java/com/youlai/boot/support/file/service/impl/AliyunFileServiceImpl.java index da918307..ce084cd7 100644 --- a/src/main/java/com/youlai/boot/support/file/service/impl/AliyunFileServiceImpl.java +++ b/src/main/java/com/youlai/boot/support/file/service/impl/AliyunFileServiceImpl.java @@ -10,7 +10,6 @@ import com.aliyun.oss.OSSClientBuilder; import com.aliyun.oss.model.ObjectMetadata; import com.aliyun.oss.model.PutObjectRequest; import com.youlai.boot.common.exception.BusinessException; -import com.youlai.boot.common.util.HashUtils; import com.youlai.boot.support.file.model.FileInfo; import com.youlai.boot.support.file.service.FileService; import jakarta.annotation.PostConstruct; @@ -63,17 +62,6 @@ public class AliyunFileServiceImpl implements FileService { String suffix = FileUtil.getSuffix(originalFilename); String uuid = IdUtil.simpleUUID(); String fileName = DateUtil.format(LocalDateTime.now(), "yyyyMMdd") + "/" + uuid + "." + suffix; - // 计算文件哈希(md5/sha1/sha256) - String md5; - String sha1; - String sha256; - try { - md5 = HashUtils.calculateMultipartFileMd5(file); - sha1 = HashUtils.calculateMultipartFileSha1(file); - sha256 = HashUtils.calculateMultipartFileSha256(file); - } catch (Exception e) { - throw new RuntimeException("文件上传失败"); - } try (InputStream inputStream = file.getInputStream()) { ObjectMetadata metadata = new ObjectMetadata(); metadata.setContentType(file.getContentType()); @@ -87,9 +75,6 @@ public class AliyunFileServiceImpl implements FileService { FileInfo fileInfo = new FileInfo(); fileInfo.setName(originalFilename); fileInfo.setUrl(fileUrl); - fileInfo.setMd5(md5); - fileInfo.setSha1(sha1); - fileInfo.setSha256(sha256); return fileInfo; } diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index bc36d07e..c136098d 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -169,7 +169,7 @@ security: - /api/v1/auth/qr-code/** # 扫码登录接口(生成票据/查询状态/换取令牌) - /api/v1/sn/** # 移动设备专用接口(通过设备签名验证) - /api/v1/open/** # C端客户端(open)接口(注册/登录/短信/刷新令牌等,落地 app_user) - - /static/** # 静态资源(应用图标等,img 标签无法携带 token;文件名为内容MD5,不可枚举) + - /static/** # 静态资源(截图/安装包/分类图标/壁纸等,img 标签无法携带 token) - /api/v1/forms/public/** # 公开表单接口(匿名渲染/提交,Service 校验 is_public 总闸 + IP 限流) - /api/v1/health # 健康检查接口(供探针与限流测试) - ${springdoc.swagger-ui.path} # Swagger UI 路径 @@ -183,7 +183,7 @@ security: # 文件存储 storage: # 存储类型:s3 | aliyun | local - type: s3 + type: aliyun # 上传限制 upload: # 单文件大小上限(同时作为 spring.servlet.multipart 的上限,单一来源) @@ -204,10 +204,13 @@ storage: domain: # 阿里云 OSS aliyun: - endpoint: oss-cn-hangzhou.aliyuncs.com - access-key-id: your-access-key-id - access-key-secret: your-access-key-secret - bucket: default + endpoint: oss-cn-shenzhen.aliyuncs.com + access-key-id: LTAI5t8meTkKjPrPCW9cLtjo + access-key-secret: 6XRt2RAUO7Eiv0zrKqR8ov31B3GzH1 + bucket: ttstd + # 上传对象的 ACL:留空跟随桶权限(推荐,公开读由桶/CDN 控制) + # 配 public-read 需先关闭桶的「阻止公共访问」,否则上传报 Put public object acl is not allowed + # object-acl: public-read # 本地存储 local: # 存储根目录(相对路径基于工作目录;生产建议挂载独立卷) diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 96cdbbbe..2f4da0cb 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -164,7 +164,7 @@ security: - /api/v1/auth/qr-code/** # 扫码登录接口(生成票据/查询状态/换取令牌) - /api/v1/sn/** # 移动设备专用接口(通过设备签名验证) - /api/v1/open/** # C端客户端(open)接口(注册/登录/短信/刷新令牌等,落地 app_user) - - /static/** # 静态资源(应用图标等,img 标签无法携带 token;文件名为内容MD5,不可枚举) + - /static/** # 静态资源(截图/安装包/分类图标/壁纸等,img 标签无法携带 token) - /api/v1/forms/public/** # 公开表单接口(匿名渲染/提交,Service 校验 is_public 总闸 + IP 限流) - /api/v1/health # 健康检查接口(供探针与限流测试) - ${springdoc.swagger-ui.path} # Swagger UI 路径 @@ -178,7 +178,7 @@ security: # 文件存储 storage: # 存储类型:s3 | aliyun | local - type: s3 + type: aliyun # 上传限制 upload: # 单文件大小上限(同时作为 spring.servlet.multipart 的上限,单一来源) @@ -199,10 +199,13 @@ storage: domain: # 阿里云 OSS aliyun: - endpoint: oss-cn-hangzhou.aliyuncs.com - access-key-id: your-access-key-id - access-key-secret: your-access-key-secret - bucket: default + endpoint: oss-cn-shenzhen.aliyuncs.com + access-key-id: LTAI5t8meTkKjPrPCW9cLtjo + access-key-secret: 6XRt2RAUO7Eiv0zrKqR8ov31B3GzH1 + bucket: ttstd + # 上传对象的 ACL:留空跟随桶权限(推荐,公开读由桶/CDN 控制) + # 配 public-read 需先关闭桶的「阻止公共访问」,否则上传报 Put public object acl is not allowed + # object-acl: public-read # 本地存储 local: # 存储根目录(相对路径基于工作目录;生产建议挂载独立卷)