Files
rui-docs/superpowers/specs/2026-06-07-file-storage-service-design.md
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

580 lines
24 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 文件存储服务(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 '业务类型 (大写蛇形字符串,业务模块自定)',
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;
/**
* 文件业务类型工具类(已不再是枚举)。
* <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 上传文件
```http
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 主题包、字体包、翻译文件等)。
```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_UPLOADtype 都是 "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<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.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`