f26c67368c
- §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
24 KiB
24 KiB
文件存储服务(rui-service-storage)设计文档
日期: 2026-06-07 状态: 设计中 作者: AI Assistant 关联: Gitea #4 [API-REQ] 通用文件上传接口
1. 背景与目标
1.1 现状
- 全局无文件上传相关代码(
grep -ri "upload|oss|cos"业务代码无命中) rui-common-web的BaseController已 importMultipartFile,框架就绪rui-common-mq+rui-common-mq-redis已有完整发布订阅抽象- 各业务模块开始需要上传能力:
- Gitea #4 紧急:SysApp 第三方应用集成需上传证书(
.pem/.crt/.key/.p12) - 后续:用户头像、订单附件、CMS 轮播图等
- Gitea #4 紧急:SysApp 第三方应用集成需上传证书(
1.2 目标
- 独立微服务
rui-service-storage提供统一上传接口(POST /storage/upload) - 支持三家存储后端:阿里云 OSS / 腾讯云 COS / 本地(Strategy 模式可扩展)
- 统一鉴权:服务内置
@AutoPermission(网关暂不背鉴权) - 统一返回:所有响应走
Result<T>包装 - Redis pub/sub 广播:上传完成后推送
ON_UPLOAD事件,订阅方按type字段过滤处理 - 集中常量:跨服务 topic 字符串统一在
rui-common-core/.../constants/MqTopicConstants.java维护 - 集成聚合启动器
rui-service-starter
2. 核心设计原则
- 统一接口:所有业务模块共用
POST /storage/upload,差异由bizType区分 - 解耦推送:上传完成 → 落
sys_file→ 推 MQ 事件 → 订阅方各自处理,存储服务不感知业务 - 可插拔后端:Strategy 模式,新增存储后端只加一个
@Component即可 - 配置驱动:
bizType的扩展名白名单、文件大小限制、默认存储后端全部 yaml 配置 - 常量集中:topic/channel 等跨服务字符串统一在
rui-common-core常量目录维护 - 事件可重放:所有上传记录落库
sys_file,订阅方失败可基于 DB 重放 - 不破坏向后兼容:旧服务无需改造即可调用新上传接口
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 |
@ComponentScan 加 com.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) (可选,默认 false;true 时若文件是 .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=true(ZIP 自动解压)
适用:批量上传场景(应用多证书、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_UPLOAD,type 都是 "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.yaml管rui.file.biz-types等共享rui-service-storage.yaml管端口 + 三个后端的凭据- 不重复:业务模块不需要 import storage 专属配置
10. 鉴权与安全
- JWT 校验:
@EnableResourceServer+rui-common-security自动校验 - 权限注解:
@AutoPermission("sys:file:upload")类级默认;查询/删除按方法级覆盖 - 大小限制:双重防护:Spring
multipart.max-file-size+ 业务校验maxSize - 扩展名白名单:
bizType配置驱动,未匹配返回 400 - 文件名安全:存储文件名采用
uuid + 原扩展名,避免路径穿越 - 不存敏感信息:日志只记录
fileId和bizType,不打原文件名或 URL - 跨服务调用:上传接口需要
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 文件返回 400POST /storage/upload传 .exe +bizType=SYS_APP_CERT返回 400POST /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