Files
rui-docs/superpowers/specs/2026-06-07-file-storage-service-design.md
T
vifo f26c67368c docs(spec): 上传 API 增加 fileName + extract=zip 自动解压
- §7.1 补充 fileName (可选)、extract (bool)
- 响应统一为 Result<List<SysFileUploadVO>>:单文件也是长度 1 的数组
- §7.4 fileName 行为说明 (不传/传/校验)
- §7.5 extract=true (ZIP 解压):
  - 行为 + 路径规则 (bizType/zipBaseName/entryName)
  - 安全护栏 (总 entry ≤ 100, 单 entry ≤ maxSize, entry 名正则)
  - 请求/响应/订阅方事件示例
- 对应后端 commit: c4a5d5f
2026-06-07 23:17:14 +08:00

24 KiB
Raw Blame History

文件存储服务(rui-service-storage)设计文档

日期: 2026-06-07 状态: 设计中 作者: AI Assistant 关联: Gitea #4 [API-REQ] 通用文件上传接口


1. 背景与目标

1.1 现状

  • 全局无文件上传相关代码(grep -ri "upload|oss|cos" 业务代码无命中)
  • rui-common-webBaseController 已 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<T> 包装
  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

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 不维护中央清单,新业务模块加新字符串即可,框架不强制注册。

理由:上传服务是「统一基础设施」,应当对业务透明。强制枚举会让「加个新模块」变成「改框架代码 + 重新发版」,违背开闭原则。

package com.rui.common.core.enums;

/**
 * 文件业务类型工具类(已不再是枚举)。
 * <p>上传接口接收任意 bizType 字符串,框架只做格式校验,不维护"已注册"清单。</p>
 */
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 <modules>rui-service-storage
rui-service/rui-service-starter/pom.xml rui-service-storage 依赖
rui-service/rui-service-starter/.../StarterApplication.java @ComponentScancom.rui.service.storage
rui-service/rui-service-starter/src/main/resources/application.yml rui.modules.available 加 storage 入口

7. API 接口设计

7.1 上传文件

POST /storage/upload
Content-Type: multipart/form-data
Authorization: Bearer <JWT>             # 网关已注入,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)    (可选,默认 falsetrue 时若文件是 .zip 自动解压为多文件入库,详见 7.5)

Response (Result<List<SysFileUploadVO>>):
{
  "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=trueZIP 自动解压)

适用:批量上传场景(应用多证书、UI 主题包、字体包、翻译文件等)。

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

典型场景

// 请求
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_UPLOADtype 都是 "SYS_APP_CERT_UPLOAD"bizType=SYS_APP_CERT

7.2 查询文件

GET /storage/file/{id}
GET /storage/file/page?bizType=SYS_APP_CERT&pageNum=1&pageSize=20

Response (Result<SysFileQueryVO>):
{ "id":1001, "name":"...", "url":"...", "size":2048,
  "bizType":"SYS_APP_CERT", "createdAt":"..." }

7.3 删除文件

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

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

{
  "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

{
  "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 示例)

@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 / 本地兜底)

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

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

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.yamlrui.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. 不存敏感信息:日志只记录 fileIdbizType,不打原文件名或 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/uploadbizType=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