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

- 独立微服务 rui-service-storage 统一上传接口
- 支持阿里云 OSS / 腾讯云 COS / 本地 Strategy
- 内置 @AutoPermission 鉴权
- Redis pub/sub ON_UPLOAD / ON_FILE_DELETED 事件
- topic 常量集中在 rui-common-core/.../constants/MqTopicConstants
- 关联 Gitea #4
This commit is contained in:
2026-06-07 21:25:31 +08:00
parent b492c6224a
commit 66f0712486
@@ -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<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`
```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;
/**
* 文件业务类型枚举。
* <p>所有 bizType 字符串必须在此枚举中定义,否则上传接口返回 400。</p>
*
* <p>事件 type 字段 = {@code bizType.name() + "_UPLOAD"}(上传) / {@code "_DELETED"}(删除)</p>
*/
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` | `<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 上传文件
```http
POST /storage/upload
Content-Type: multipart/form-data
Authorization: Bearer <JWT> # storage
file : MultipartFile ()
bizType : string (form) (FileBizType )
storage : string (query) (aliyun/tencent/local active)
Response (Result<SysFileUploadVO>):
{
"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<SysFileQueryVO>):
{ "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`