From 66f0712486faef9e8765fba29595f46990fb288a Mon Sep 17 00:00:00 2001 From: pigeon Date: Sun, 7 Jun 2026 21:25:31 +0800 Subject: [PATCH] =?UTF-8?q?docs(spec):=20=E6=96=87=E4=BB=B6=E5=AD=98?= =?UTF-8?q?=E5=82=A8=E6=9C=8D=E5=8A=A1=EF=BC=88rui-service-storage?= =?UTF-8?q?=EF=BC=89=E8=AE=BE=E8=AE=A1=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 独立微服务 rui-service-storage 统一上传接口 - 支持阿里云 OSS / 腾讯云 COS / 本地 Strategy - 内置 @AutoPermission 鉴权 - Redis pub/sub ON_UPLOAD / ON_FILE_DELETED 事件 - topic 常量集中在 rui-common-core/.../constants/MqTopicConstants - 关联 Gitea #4 --- .../2026-06-07-file-storage-service-design.md | 503 ++++++++++++++++++ 1 file changed, 503 insertions(+) create mode 100644 superpowers/specs/2026-06-07-file-storage-service-design.md diff --git a/superpowers/specs/2026-06-07-file-storage-service-design.md b/superpowers/specs/2026-06-07-file-storage-service-design.md new file mode 100644 index 0000000..5b7be5a --- /dev/null +++ b/superpowers/specs/2026-06-07-file-storage-service-design.md @@ -0,0 +1,503 @@ +# 文件存储服务(rui-service-storage)设计文档 + +> **日期**: 2026-06-07 +> **状态**: 设计中 +> **作者**: AI Assistant +> **关联**: Gitea #4 [API-REQ] 通用文件上传接口 + +--- + +## 1. 背景与目标 + +### 1.1 现状 + +- 全局无文件上传相关代码(`grep -ri "upload|oss|cos"` 业务代码无命中) +- `rui-common-web` 的 `BaseController` 已 import `MultipartFile`,框架就绪 +- `rui-common-mq` + `rui-common-mq-redis` 已有完整发布订阅抽象 +- 各业务模块开始需要上传能力: + - **Gitea #4** 紧急:SysApp 第三方应用集成需上传证书(`.pem/.crt/.key/.p12`) + - 后续:用户头像、订单附件、CMS 轮播图等 + +### 1.2 目标 + +1. 独立微服务 `rui-service-storage` 提供**统一上传接口**(`POST /storage/upload`) +2. 支持三家存储后端:**阿里云 OSS / 腾讯云 COS / 本地**(Strategy 模式可扩展) +3. **统一鉴权**:服务内置 `@AutoPermission`(网关暂不背鉴权) +4. **统一返回**:所有响应走 `Result` 包装 +5. **Redis pub/sub 广播**:上传完成后推送 `ON_UPLOAD` 事件,订阅方按 `type` 字段过滤处理 +6. **集中常量**:跨服务 topic 字符串统一在 `rui-common-core/.../constants/MqTopicConstants.java` 维护 +7. **集成聚合启动器** `rui-service-starter` + +--- + +## 2. 核心设计原则 + +1. **统一接口**:所有业务模块共用 `POST /storage/upload`,差异由 `bizType` 区分 +2. **解耦推送**:上传完成 → 落 `sys_file` → 推 MQ 事件 → 订阅方各自处理,存储服务不感知业务 +3. **可插拔后端**:Strategy 模式,新增存储后端只加一个 `@Component` 即可 +4. **配置驱动**:`bizType` 的扩展名白名单、文件大小限制、默认存储后端全部 yaml 配置 +5. **常量集中**:topic/channel 等跨服务字符串统一在 `rui-common-core` 常量目录维护 +6. **事件可重放**:所有上传记录落库 `sys_file`,订阅方失败可基于 DB 重放 +7. **不破坏向后兼容**:旧服务无需改造即可调用新上传接口 + +--- + +## 3. 架构设计 + +### 3.1 整体架构 + +``` +┌──────────┐ POST /storage/upload ┌──────────────────────────────┐ +│ 客户端 │ ───────────────────────────────▶ │ rui-gateway 路由透传 │ +└──────────┘ └────────────┬─────────────────┘ + │ JWT 已校验 / 注入 header + ▼ + ┌──────────────────────────────┐ + │ rui-service-storage │ + │ @EnableResourceServer │ + │ @AutoPermission("sys:file:*")│ + ├──────────────────────────────┤ + │ 1. SecurityUtils 取用户/租户 │ + │ 2. 校验 bizType 枚举 + 配置 │ + │ 3. 校验大小/扩展名 │ + │ 4. FileStorage Strategy 上传 │ + │ ├ AliyunOssFileStorage │ + │ ├ TencentCosFileStorage │ + │ └ LocalFileStorage │ + │ 5. sys_file 落库 │ + │ 6. mqClient.publish( │ + │ REDIS, ON_UPLOAD, payload)│ + │ 7. return Result.ok(vo) │ + └────────────┬─────────────────┘ + │ Redis pub/sub + ┌──────────────────────────────┼──────────────────────────────┐ + ▼ ▼ ▼ + rui-service-system rui-service-user rui-service-cms + @MqTopic(ON_UPLOAD) @MqTopic(ON_UPLOAD) @MqTopic(ON_UPLOAD) + filter type=SYS_APP_CERT_UPLOAD filter type=USER_AVATAR_UPLOAD filter type=CMS_BANNER_UPLOAD + → SysApp.appendCertificate() → user.setAvatar() → banner.setImage() +``` + +### 3.2 模块定位 + +| 模块 | 角色 | 依赖 | +|------|------|------| +| `rui-common-core` | 提供 `MqTopicConstants` + `FileBizType` 枚举 + `Result` | 无 | +| `rui-service-storage` | 上传服务本体(Controller + Strategy + Service) | web/mybatis/redis/mq/security | +| `rui-service-system` 等 | 订阅方,实现 `MqConsumer` 处理事件 | mq-redis(已通过 starter 引入) | +| `rui-service-starter` | 聚合启动器,引入 storage 依赖 | 现有 + storage | + +--- + +## 4. 数据库设计 + +### 4.1 新增表 `sys_file` + +```sql +CREATE TABLE sys_file ( + id BIGINT NOT NULL, + name VARCHAR(200) NOT NULL COMMENT '存储文件名 (uuid + 扩展名)', + original_name VARCHAR(200) NOT NULL COMMENT '原始文件名', + url VARCHAR(1000) NOT NULL COMMENT '可访问URL', + storage_type VARCHAR(20) NOT NULL COMMENT '存储后端 ALIYUN/TENCENT/LOCAL', + biz_type VARCHAR(50) NOT NULL COMMENT '业务类型 (见 FileBizType 枚举)', + biz_id VARCHAR(100) DEFAULT NULL COMMENT '业务关联ID (可选)', + size BIGINT NOT NULL COMMENT '字节', + content_type VARCHAR(100) DEFAULT NULL COMMENT 'MIME 类型', + sha256 CHAR(64) DEFAULT NULL COMMENT '文件哈希 (查重用)', + uploader_id BIGINT DEFAULT NULL COMMENT '上传者用户ID', + tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID', + created_by BIGINT DEFAULT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_by BIGINT DEFAULT NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除', + PRIMARY KEY (id), + INDEX idx_biz (biz_type, biz_id), + INDEX idx_uploader (uploader_id), + INDEX idx_sha256 (sha256), + INDEX idx_created (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文件存储记录'; +``` + +继承 `BaseEntity`(自动填充 `created_by/created_at/updated_by/updated_at/tenant_id/deleted`),最终 DDL 可省略由框架维护的列定义。 + +### 4.2 枚举 `FileBizType`(位于 `rui-common-core`) + +```java +package com.rui.common.core.enums; + +/** + * 文件业务类型枚举。 + *

所有 bizType 字符串必须在此枚举中定义,否则上传接口返回 400。

+ * + *

事件 type 字段 = {@code bizType.name() + "_UPLOAD"}(上传) / {@code "_DELETED"}(删除)

+ */ +public enum FileBizType { + /** 通用文件(无业务白名单限制) */ + COMMON, + /** 第三方应用证书(pem/crt/key/p12,限 5MB) */ + SYS_APP_CERT, + /** 用户头像(jpg/png/webp,限 2MB) */ + USER_AVATAR, + /** CMS 轮播图(jpg/png/webp/gif,限 5MB) */ + CMS_BANNER; + + public String uploadType() { return name() + "_UPLOAD"; } + public String deletedType() { return name() + "_DELETED"; } +} +``` + +--- + +## 5. 关键流程设计 + +### 5.1 上传流程 + +``` +1. Client → POST /storage/upload (file, bizType, storage?) +2. SysFileController.upload() 入口 +3. @AutoPermission 校验 sys:file:upload +4. SecurityUtils 取 uploaderId/tenantId +5. 校验 bizType ∈ FileBizType.values() 否则 400 +6. 加载 rui.file.biz-types[bizType] 配置 + ├─ 校验 file.size ≤ maxSize + └─ 校验 file.ext ∈ allowedExtensions +7. 选定后端 + ├─ 显式 storage=xxx 优先 + └─ 否则 rui.file.active +8. FileStorage.upload(file) → 返回 url / storageKey +9. 算 sha256(同步,10MB 以内可接受) +10. sys_file 落库 (INSERT) +11. mqClient.publish(MqProvider.REDIS, MqTopicConstants.ON_UPLOAD, + EventPayload.of(FileBizType.SYS_APP_CERT, file, uploader, tenant)) +12. return Result.ok(SysFileUploadVO) +``` + +### 5.2 删除流程 + +``` +1. Client → DELETE /storage/file/{id} +2. 权限校验 sys:file:delete +3. 查 sys_file 找到 url + storageKey +4. FileStorage.delete(storageKey) +5. sys_file 软删 (UPDATE deleted=1) +6. mqClient.publish(REDIS, MqTopicConstants.ON_FILE_DELETED, + {type: bizType.deletedType(), fileId, url, ...}) +7. return Result.ok() +``` + +### 5.3 事件推送流程 + +``` +┌────────────────────────┐ ┌────────────────────────┐ +│ storage 服务 │ │ 订阅服务 (e.g. system) │ +│ │ │ │ +│ mqClient.publish( │ Redis Pub │ @MqTopic(ON_UPLOAD) │ +│ MqProvider.REDIS, │ ─────────────▶ │ public class SysApp │ +│ ON_UPLOAD, │ channel= │ CertConsumer │ +│ {type, bizType, │ ON_UPLOAD │ implements MqConsumer│ +│ fileId, url, ...}) │ │ │ +│ │ │ onMessage(id,topic,data)│ +└────────────────────────┘ │ if (!SYS_APP_CERT │ + │ .uploadType() │ + │ .equals(data │ + │ .getString │ + │ ("type"))) │ + │ return; │ + │ sysAppService │ + │ .appendCert(...) │ + └────────────────────────┘ +``` + +--- + +## 6. 代码结构 + +### 6.1 新增文件清单 + +| 路径 | 说明 | +|------|------| +| `rui-common/rui-common-core/.../constants/MqTopicConstants.java` | 跨服务 MQ topic 常量 | +| `rui-common/rui-common-core/.../enums/FileBizType.java` | 文件业务类型枚举 | +| `rui-service/rui-service-storage/pom.xml` | 新模块 | +| `rui-service/rui-service-storage/src/main/java/com/rui/service/storage/StorageApplication.java` | 启动类 | +| `rui-service/rui-service-storage/.../controller/SysFileController.java` | 上传/查询/删除接口 | +| `rui-service/rui-service-storage/.../service/IFileStorage.java` | Strategy 接口 | +| `rui-service/rui-service-storage/.../service/impl/AliyunOssFileStorage.java` | 阿里云 | +| `rui-service/rui-service-storage/.../service/impl/TencentCosFileStorage.java` | 腾讯 | +| `rui-service/rui-service-storage/.../service/impl/LocalFileStorage.java` | 本地 | +| `rui-service/rui-service-storage/.../service/impl/FileStorageRouter.java` | 选实现 | +| `rui-service/rui-service-storage/.../event/UploadEventPublisher.java` | 封装 ON_UPLOAD 推送 | +| `rui-service/rui-service-storage/.../event/FileDeletedEventPublisher.java` | 封装 ON_FILE_DELETED | +| `rui-service/rui-service-storage/.../properties/FileProperties.java` | `@ConfigurationProperties("rui.file")` | +| `rui-service/rui-service-storage/.../entity/SysFile.java` | 实体(继承 BaseEntity) | +| `rui-service/rui-service-storage/.../mapper/SysFileMapper.java` | Mapper | +| `rui-service/rui-service-storage/.../service/ISysFileService.java` | Service 接口 | +| `rui-service/rui-service-storage/.../service/impl/SysFileServiceImpl.java` | Service 实现 | +| `rui-service/rui-service-storage/.../dto/SysFileUploadVO.java` | 上传返回 VO | +| `rui-service/rui-service-storage/.../dto/SysFileQueryVO.java` | 查询返回 VO | +| `rui-service/rui-service-storage/.../dto/UploadEventPayload.java` | 事件 payload POJO | +| `rui-service/rui-service-storage/src/main/resources/application.yml` | port=9400 | +| `sql/init-database.sql` | 新增 sys_file 表 DDL | + +### 6.2 修改文件清单 + +| 路径 | 修改内容 | +|------|----------| +| `rui-service/pom.xml` | `` 加 `rui-service-storage` | +| `rui-service/rui-service-starter/pom.xml` | 加 `rui-service-storage` 依赖 | +| `rui-service/rui-service-starter/.../StarterApplication.java` | `@ComponentScan` 加 `com.rui.service.storage` | +| `rui-service/rui-service-starter/src/main/resources/application.yml` | `rui.modules.available` 加 storage 入口 | + +--- + +## 7. API 接口设计 + +### 7.1 上传文件 + +```http +POST /storage/upload +Content-Type: multipart/form-data +Authorization: Bearer # 网关已注入,storage 服务再校验 + +file : MultipartFile (必填) +bizType : string (form) (必填,FileBizType 枚举值) +storage : string (query) (可选,aliyun/tencent/local,不传走 active) + +Response (Result): +{ + "error": 0, + "message": "success", + "data": { + "id": 1001, + "name": "a1b2c3d4.pem", + "originalName": "wechat.pem", + "url": "https://oss.../cert/2026/06/a1b2c3d4.pem", + "size": 2048, + "contentType": "application/x-pem-file", + "storageType": "ALIYUN", + "bizType": "SYS_APP_CERT" + } +} +``` + +### 7.2 查询文件 + +```http +GET /storage/file/{id} +GET /storage/file/page?bizType=SYS_APP_CERT&pageNum=1&pageSize=20 + +Response (Result): +{ "id":1001, "name":"...", "url":"...", "size":2048, + "bizType":"SYS_APP_CERT", "createdAt":"..." } +``` + +### 7.3 删除文件 + +```http +DELETE /storage/file/{id} + +Response: Result.ok() +``` + +### 7.4 权限注解 + +| 接口 | 注解 | +|------|------| +| `POST /storage/upload` | `@AutoPermission("sys:file:upload")` | +| `GET /storage/file/{id}` | `@AutoPermission("sys:file:query")` | +| `GET /storage/file/page` | `@AutoPermission("sys:file:query")` | +| `DELETE /storage/file/{id}` | `@AutoPermission("sys:file:delete")` | + +--- + +## 8. 事件约定 + +### 8.1 常量定义(rui-common-core/.../constants/MqTopicConstants.java) + +```java +public final class MqTopicConstants { + public static final String ON_UPLOAD = "ON_UPLOAD"; + public static final String ON_FILE_DELETED = "ON_FILE_DELETED"; + + private MqTopicConstants() {} +} +``` + +### 8.2 事件 Payload + +**ON_UPLOAD**: + +```json +{ + "id": "uuid", + "bizType": "SYS_APP_CERT", + "type": "SYS_APP_CERT_UPLOAD", + "fileId": 1001, + "name": "a1b2c3d4.pem", + "url": "https://oss.../xxx", + "size": 2048, + "contentType": "application/x-pem-file", + "storageType": "ALIYUN", + "uploaderId": 42, + "tenantId": 0, + "extra": { }, + "timestamp": "2026-06-07T13:30:00Z" +} +``` + +**ON_FILE_DELETED**: + +```json +{ + "id": "uuid", + "bizType": "SYS_APP_CERT", + "type": "SYS_APP_CERT_DELETED", + "fileId": 1001, + "url": "https://oss.../xxx", + "storageType": "ALIYUN", + "uploaderId": 42, + "tenantId": 0, + "timestamp": "2026-06-07T13:30:00Z" +} +``` + +### 8.3 订阅方模板(`rui-service-system` 示例) + +```java +@MqTopic(MqTopicConstants.ON_UPLOAD) +@Component +@RequiredArgsConstructor +public class SysAppCertUploadConsumer implements MqConsumer { + private final ISysAppService sysAppService; + + @Override + public void onMessage(String messageId, String topic, JSONObject data) { + if (!FileBizType.SYS_APP_CERT.uploadType().equals(data.getString("type"))) return; + String url = data.getString("url"); + JSONObject extra = data.getJSONObject("extra"); + String appId = extra == null ? null : extra.getString("appId"); + if (appId != null) { + sysAppService.appendCertificate(appId, url); + } + } +} +``` + +--- + +## 9. 配置设计 + +### 9.1 公共配置(`rui-common.yaml` Nacos / 本地兜底) + +```yaml +rui: + file: + active: local # 默认后端 + default-max-size: 10MB + biz-types: + COMMON: + max-size: 10MB + allowed-extensions: [] # 空 = 全部 + SYS_APP_CERT: + max-size: 5MB + allowed-extensions: [pem, crt, key, p12] + USER_AVATAR: + max-size: 2MB + allowed-extensions: [jpg, jpeg, png, webp] + CMS_BANNER: + max-size: 5MB + allowed-extensions: [jpg, jpeg, png, webp, gif] +``` + +### 9.2 服务专属配置(`rui-service-storage/application.yml`) + +```yaml +server: + port: 9400 +spring: + application: + name: rui-service-storage + servlet: + multipart: + max-file-size: 10MB # 兜底 + max-request-size: 50MB # 批量上传场景预留 +``` + +### 9.3 OSS 凭据(Nacos rui-service-storage.yaml) + +```yaml +rui: + file: + aliyun: + enabled: false + endpoint: oss-cn-shanghai.aliyuncs.com + access-key: ${ALIYUN_AK} + secret-key: ${ALIYUN_SK} + bucket: rui-storage + url-prefix: https://rui-storage.oss-cn-shanghai.aliyuncs.com + base-path: cert/ + tencent: + enabled: false + secret-id: ${TENCENT_SID} + secret-key: ${TENCENT_SKEY} + region: ap-shanghai + bucket: rui-storage-1300000000 + url-prefix: https://rui-storage-1300000000.cos.ap-shanghai.myqcloud.com + base-path: cert/ + local: + base-path: ${user.home}/.rui/upload/ + url-prefix: /api/storage/local/ # 通过 storage 服务自己代理返回 +``` + +### 9.4 Nacos 配置规则 + +按 `docs/ai-skills/nacos-config-rules.md`: + +- `rui-common.yaml` 管 `rui.file.biz-types` 等共享 +- `rui-service-storage.yaml` 管端口 + 三个后端的凭据 +- 不重复:业务模块不需要 import storage 专属配置 + +--- + +## 10. 鉴权与安全 + +1. **JWT 校验**:`@EnableResourceServer` + `rui-common-security` 自动校验 +2. **权限注解**:`@AutoPermission("sys:file:upload")` 类级默认;查询/删除按方法级覆盖 +3. **大小限制**:双重防护:Spring `multipart.max-file-size` + 业务校验 `maxSize` +4. **扩展名白名单**:`bizType` 配置驱动,未匹配返回 400 +5. **文件名安全**:存储文件名采用 `uuid + 原扩展名`,避免路径穿越 +6. **不存敏感信息**:日志只记录 `fileId` 和 `bizType`,不打原文件名或 URL +7. **跨服务调用**:上传接口需要 `sys:file:upload` 权限,订阅方处理失败不影响主链路 + +--- + +## 11. 边界与不做 + +| 边界 | 说明 | +|------|------| +| 不做文件预览/转码 | 单纯的存储 + URL 返回,预览由前端/调用方实现 | +| 不做分片上传 | MVP 先支持单文件 10MB,分片后续按需 | +| 不做断点续传 | 同上 | +| 不做租户独立 bucket | MVP 用共享 bucket + 路径前缀隔离 | +| 不做内容审查/反垃圾 | 业务层后续扩展 | +| 不做软删除恢复 | 物理不可恢复,按 `deleted=1` 软标记 | +| 不做多文件上传 | 单文件接口;批量由前端循环或后续加 `batch` 接口 | + +--- + +## 12. 验收标准 + +- [ ] `POST /storage/upload` 上传 .pem 文件返回标准 `Result` 格式,`data.url` 可访问 +- [ ] `POST /storage/upload` 传 11MB 文件返回 400 +- [ ] `POST /storage/upload` 传 .exe + `bizType=SYS_APP_CERT` 返回 400 +- [ ] `POST /storage/upload` 传 `bizType=INVALID_TYPE` 返回 400 +- [ ] 上传成功后 Redis 收到 `ON_UPLOAD` 消息,payload 包含 `type=SYS_APP_CERT_UPLOAD` +- [ ] 删除后 Redis 收到 `ON_FILE_DELETED` 消息 +- [ ] 无 JWT 调上传接口返回 401 +- [ ] 无 `sys:file:upload` 权限调上传返回 403 +- [ ] `rui-service-starter` 启动后 `StorageApplication` 同样可启动 +- [ ] Gitea #4 关闭 +- [ ] `mvn clean compile` 全部模块通过 +- [ ] 关键 commit 推送至 `origin/main`