# 文件存储服务(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 '业务类型 (大写蛇形字符串,业务模块自定)', 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`,**不是枚举**) `bizType` **不维护中央清单**,新业务模块加新字符串即可,框架不强制注册。 理由:上传服务是「统一基础设施」,应当对业务透明。强制枚举会让「加个新模块」变成「改框架代码 + 重新发版」,违背开闭原则。 ```java package com.rui.common.core.enums; /** * 文件业务类型工具类(已不再是枚举)。 *

上传接口接收任意 bizType 字符串,框架只做格式校验,不维护"已注册"清单。

*/ public final class FileBizType { private static final Pattern PATTERN = Pattern.compile("^[A-Z][A-Z0-9_]*$"); private static final int MAX_LENGTH = 50; private FileBizType() {} /** trim / 大写 / - 转 _ / 格式校验;非法时抛 BizException */ public static String normalize(String bizType) { /* ... */ } /** 订阅方按此过滤:{@code "SYS_APP_CERT_UPLOAD"} 等 */ public static String uploadType(String bizType) { return normalize(bizType) + "_UPLOAD"; } public static String deletedType(String bizType) { return normalize(bizType) + "_DELETED"; } } ``` **bizType 格式约束**(normalize 强制): - 字母/数字开头,仅大写字母 + 数字 + 下划线 - 长度 ≤ 50(与 `sys_file.biz_type VARCHAR(50)` 对齐) - 例:`SYS_APP_CERT` / `USER_AVATAR` / `MY_NEW_BIZ` 都可 **具体业务的大小 / 扩展名限制**走 yml 配置 `rui.file.biz-types.{BIZ_TYPE}`,缺失则走默认值;不属于「注册」。 > ⚠️ 这是 2026-06-07 第二次设计调整:原计划是 enum + 预定义 4 个值,后改为工具类 + 任意字符串。 --- ## 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 格式 (normalize) 否则 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(bizType, file, uploader, tenant) // bizType 即上传时传的字符串 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` | 文件业务类型工具类(非枚举;normalize / uploadType / deletedType) | | `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) (必填;大写蛇形字符串,业务模块自定,框架不维护清单) storage : string (form) (可选,aliyun/tencent/local,不传走 active) fileName : string (form) (可选;指定存储名,规则 [A-Za-z0-9][A-Za-z0-9._-]{<=200},详见 7.4) extract : bool (form) (可选,默认 false;true 时若文件是 .zip 自动解压为多文件入库,详见 7.5) Response (Result>): { "error": 0, "message": "success", "data": [ { "id": 1001, "name": "a1b2c3d4.pem", "originalName": "wechat.pem", "path": "sys-app-cert/2026/06/a1b2c3d4.pem", // 存储路径,不含域名 "url": "https://oss.../sys-app-cert/2026/06/a1b2c3d4.pem", "size": 2048, "contentType": "application/x-pem-file", "storageType": "ALIYUN", "bizType": "SYS_APP_CERT" } ] } ``` > **响应统一是数组**:单文件上传长度为 1,zip 解压上传长度为 N。前端按 `data.length` 即可区分。 ### 7.4 fileName 参数 | 行为 | 说明 | |------|------| | 不传 | 默认 `bizType/yyyy/MM/{uuid}{ext}`,分布式不冲突 | | 传 | 存储路径为 `bizType/{fileName}`,**会覆盖同名文件**(适用固定路径场景,如 `avatar-{userId}.jpg`)| | 校验 | `^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$`,不合规 400 | ### 7.5 extract=true(ZIP 自动解压) **适用**:批量上传场景(应用多证书、UI 主题包、字体包、翻译文件等)。 ```http POST /storage/upload Content-Type: multipart/form-data file : certs.zip (必填,.zip) bizType : SYS_APP_CERT fileName : wechat (可选,作为解压路径的根段) extract : true ``` **行为**: - zip 本身**不**存到后端;解压每个 entry 单独存、单独推 `ON_UPLOAD` 事件 - 存储路径:`bizType/{zipBaseName}/{entryName}`,zipBaseName 优先级 `fileName` > 原文件名去 `.zip` > UUID 前 12 位 - 响应:`data` 数组长度 = zip 中文件 entry 数(不含目录) - 非 .zip 文件传 `extract=true` → 400 **安全护栏**(`ZipExtractor` 强制): | 项 | 限制 | 失败行为 | |----|------|---------| | 总 entry 数 | ≤ 100 | 400 防 zip bomb / 百万小文件 | | 单 entry 大小 | ≤ `bizType` 配置的 `maxSize` | 400 | | entry 名 | 须匹配 `^[A-Za-z0-9][A-Za-z0-9._-]*(/[A-Za-z0-9][A-Za-z0-9._-]*)*$` | 400(防 Zip Slip / 绝对路径 / Windows 盘符)| | entry 名长度 | ≤ 200 | 400 | **典型场景**: ```jsonc // 请求 file = wechat.zip // 内部: wechat/apiclient_cert.pem, wechat/apiclient_key.pem bizType = "SYS_APP_CERT" fileName = "wechat" extract = true // 响应 { "error": 0, "data": [ { "id": 1001, "name": "wechat/apiclient_cert.pem", "path": "sys-app-cert/wechat/apiclient_cert.pem", ... }, { "id": 1002, "name": "wechat/apiclient_key.pem", "path": "sys-app-cert/wechat/apiclient_key.pem", ... } ] } // 订阅方收到 2 条 ON_UPLOAD,type 都是 "SYS_APP_CERT_UPLOAD",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.uploadType("SYS_APP_CERT").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`