Compare commits

...

8 Commits

Author SHA1 Message Date
vifo 3aebe0b5a5 docs(spec/plan): FileBizType 改为工具类(非枚举),bizType 自由字符串
设计调整原因:上传服务是统一基础设施,强制枚举会要求「加新模块 = 改框架代码」。
改为 final class + normalize() 格式校验,业务模块自定 bizType 字符串即可。

- spec §4.2: 整个 FileBizType 章节重写(工具类 + 格式约束 + 设计意图)
- spec flow 5/11: 校验从 values() 改为 normalize()
- spec API 表 / 代码结构表 / 订阅方示例 同步更新
- plan Step 1.2/1.4 / 状态表 / 流程校验 / 发布器签名 同步更新
2026-06-07 22:44:12 +08:00
vifo 5f19332f06 docs(nacos): rui-service-storage 业务配置模板
- 包含 rui.file.* 全部业务配置
  active / default-max-size / biz-types (4个) / aliyun / tencent / local
- 服务端口 9400
- 严格遵循 docs/ai-skills/nacos-config-rules.md Rule 2
  仅放本服务特有业务配置,不含 Nacos 连接/编码/feign 等通用项

关联 Gitea #4 / 设计文档 §9.3
2026-06-07 22:23:27 +08:00
vifo a10b712919 docs(plan): 文件存储服务(rui-service-storage)实施计划
- 16 个可独立 commit 的子任务
- Task 1: 常量 + 枚举
- Task 2: sys_file DDL
- Task 3-9: 模块骨架 + Strategy 模式
- Task 10-12: 实体/Service/Controller/Event
- Task 13-14: 集成启动器 + 公共配置
- Task 15-16: 编译验证 + Gitea 关闭

关联 Gitea #4
2026-06-07 21:31:59 +08:00
vifo 66f0712486 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
2026-06-07 21:25:31 +08:00
vifo b492c6224a docs(plan): wechat/alipay 凭证动态加载计划 - 完成状态同步
- 25 个 checkbox 全部勾选
- 添加实施状态(commit e3a441b)
- 补充实施完成报告
2026-06-07 19:17:10 +08:00
vifo 78e5ebc17e docs(plan): 修正 AlipayApiClient 字段映射
AlipayApiClient 构造需 (appId, privateKey, publicKey),
AppCredentialsVO 无这两个字段(按 spec 在 certificates JSON 数组里)。
AlipayApiClient.getAccessToken 当前抛 UnsupportedOperationException,
所以 privateKey/publicKey 暂用空串占位,cert 解析留作后续 Task。
2026-06-07 19:12:09 +08:00
vifo 2bfd1c0f4f docs(plan): wechat/alipay Provider 凭证动态加载改造计划
修复凭证烧死 bug:把 WeixinAuthenticationProvider /
AlipayAuthenticationProvider 改为持有 AppCredentialsCache,
buildToken 内部按 X-App-Id 头动态解析凭证并构造 API 客户端。

- 任务 1: WeixinAuthenticationProvider 改造
- 任务 2: AlipayAuthenticationProvider 改造
- 任务 3: OAuth2ServerConfig 清理
- 任务 4: AlipayApiClient 字段映射核对
2026-06-07 19:03:35 +08:00
vifo 4271f333b6 docs(specs): 更新已完成 spec 文档状态
- 2026-06-07-sys-app-management: 已批准,待实现 → 已实现
- 2026-06-07-multi-login-social-login: 已批准,待实现 → 已实现
- 2026-06-06-user-aggregate-query: 已批准 → 已实现

各 spec 对应的 plan 已结清/实施 commit 已落地,更新状态描述并补充实施情况指引。
2026-06-07 18:46:30 +08:00
7 changed files with 1417 additions and 4 deletions
@@ -0,0 +1,67 @@
# rui-service-storage.yaml — 统一文件存储服务配置
# Data ID: rui-service-storage.yaml
# Group: DEFAULT_GROUP
# 推送到 Nacos 后必须按 docs/ai-skills/nacos-config-rules.md 验证
# 服务端口:9400(独立部署)/ 9399(被 rui-service-starter 聚合时使用 starter 端口)
server:
port: 9400
# ============================================================================
# 统一文件存储配置(rui.file.*)
# 业务配置仅放本服务,公共/框架配置已由 application-template.yml 覆盖
# ============================================================================
rui:
file:
# 当前激活的存储后端:aliyun / tencent / local
active: local
# 默认文件大小上限(按 bizType 未配置时使用)
default-max-size: 10MB
# 各业务类型的白名单与大小限制,key = FileBizType 枚举值
biz-types:
# 通用文件(无业务白名单限制)
COMMON:
max-size: 10MB
allowed-extensions: [] # 空 = 不限
# 第三方应用证书(pem/crt/key/p12
SYS_APP_CERT:
max-size: 5MB
allowed-extensions: [pem, crt, key, p12]
# 用户头像
USER_AVATAR:
max-size: 2MB
allowed-extensions: [jpg, jpeg, png, webp]
# CMS 轮播图
CMS_BANNER:
max-size: 5MB
allowed-extensions: [jpg, jpeg, png, webp, gif]
# 阿里云 OSS 后端
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/
# 腾讯云 COS 后端
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/
@@ -0,0 +1,406 @@
# 文件存储服务(rui-service-storage)实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-subagent-driven-development (recommended) or superpowers-executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 落地 `rui-service-storage` 独立微服务,提供统一上传接口(阿里云 OSS / 腾讯云 COS / 本地),并通过 Redis pub/sub 广播 `ON_UPLOAD` / `ON_FILE_DELETED` 事件。
**Architecture:** Strategy 模式 + 事件驱动。`POST /storage/upload` → 鉴权 → 校验 → Strategy 上传 → 落 `sys_file` → 推 `ON_UPLOAD` 事件 → 返回 `Result<T>`。订阅方(如 `rui-service-system`)实现 `MqConsumer``type` 字段过滤处理。
**Tech Stack:** Java 21, Spring Boot 4.x, MyBatis Plus, Fastjson2, Spring Security OAuth2, Spring Data Redis (Redisson), 阿里云 OSS SDK, 腾讯 COS SDK
**前置依赖:**
- 设计文档 [docs/superpowers/specs/2026-06-07-file-storage-service-design.md](docs/superpowers/specs/2026-06-07-file-storage-service-design.md) (commit 66f0712)
- 主仓指针 commit c467eaf
- `rui-common-mq-redis` 已就绪,`@MqTopic` 注解可用
- `rui-common-web/.../annotation/AutoPermission` 已就绪
- `rui-common-core/.../result/Result<T>` 已就绪
- Gitea #4 实施中
---
## 文件映射
### 新增
| 路径 | 操作 | 说明 |
|------|------|------|
| `rui-common/rui-common-core/.../constants/MqTopicConstants.java` | Create | MQ topic 常量 |
| `rui-common/rui-common-core/.../enums/FileBizType.java` | Modify | 工具类(非枚举),含 normalize / uploadType / deletedType |
| `rui-service/rui-service-storage/pom.xml` | Create | 新模块 |
| `rui-service/rui-service-storage/src/main/java/com/rui/service/storage/StorageApplication.java` | Create | 启动类 |
| `rui-service/rui-service-storage/src/main/java/com/rui/service/storage/controller/SysFileController.java` | Create | 上传/查询/删除 |
| `rui-service/rui-service-storage/src/main/java/com/rui/service/storage/service/IFileStorage.java` | Create | Strategy 接口 |
| `rui-service/rui-service-storage/src/main/java/com/rui/service/storage/service/impl/AliyunOssFileStorage.java` | Create | 阿里云 |
| `rui-service/rui-service-storage/src/main/java/com/rui/service/storage/service/impl/TencentCosFileStorage.java` | Create | 腾讯 |
| `rui-service/rui-service-storage/src/main/java/com/rui/service/storage/service/impl/LocalFileStorage.java` | Create | 本地 |
| `rui-service/rui-service-storage/src/main/java/com/rui/service/storage/service/impl/FileStorageRouter.java` | Create | 选实现 |
| `rui-service/rui-service-storage/src/main/java/com/rui/service/storage/event/UploadEventPublisher.java` | Create | ON_UPLOAD 推送 |
| `rui-service/rui-service-storage/src/main/java/com/rui/service/storage/event/FileDeletedEventPublisher.java` | Create | ON_FILE_DELETED 推送 |
| `rui-service/rui-service-storage/src/main/java/com/rui/service/storage/properties/FileProperties.java` | Create | @ConfigurationProperties |
| `rui-service/rui-service-storage/src/main/java/com/rui/service/storage/entity/SysFile.java` | Create | 实体 |
| `rui-service/rui-service-storage/src/main/java/com/rui/service/storage/mapper/SysFileMapper.java` | Create | Mapper |
| `rui-service/rui-service-storage/src/main/java/com/rui/service/storage/service/ISysFileService.java` | Create | Service 接口 |
| `rui-service/rui-service-storage/src/main/java/com/rui/service/storage/service/impl/SysFileServiceImpl.java` | Create | Service 实现 |
| `rui-service/rui-service-storage/src/main/java/com/rui/service/storage/dto/SysFileUploadVO.java` | Create | 上传返回 VO |
| `rui-service/rui-service-storage/src/main/java/com/rui/service/storage/dto/SysFileQueryVO.java` | Create | 查询返回 VO |
| `rui-service/rui-service-storage/src/main/java/com/rui/service/storage/dto/UploadEventPayload.java` | Create | 事件 payload POJO |
| `rui-service/rui-service-storage/src/main/resources/application.yml` | Create | port=9400 |
| `sql/init-database.sql` | Modify | 新增 sys_file DDL |
### 修改
| 路径 | 操作 | 说明 |
|------|------|------|
| `rui-service/pom.xml` | Modify | `<modules>` 加 storage |
| `rui-service/rui-service-starter/pom.xml` | Modify | 加 storage 依赖 |
| `rui-service/rui-service-starter/.../StarterApplication.java` | Modify | ComponentScan + storage |
| `rui-service/rui-service-starter/src/main/resources/application.yml` | Modify | rui.modules.available 加 storage 入口 |
| `docs/backend/config-templates/application-template.yml` | Modify | rui.file.* 公共配置示例 |
| `Gitea #4` | Reply + Close | 实施完成通知 |
---
## 任务列表
### Task 1: 公共常量与枚举(rui-common-core
**Files:**
- Create: `rui-common/rui-common-core/src/main/java/com/rui/common/core/constants/MqTopicConstants.java`
- Modify: `rui-common/rui-common-core/src/main/java/com/rui/common/core/enums/FileBizType.java`(已从 enum 改为 final classbizType 不维护中央清单)
- Reference style: `CacheConstants.java`(沿用 private ctor + Javadoc 写明写入方/使用方)
- [ ] **Step 1.1:** 创建 `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() {}
}
```
- [x] **Step 1.2:** ~~创建 `FileBizType` 枚举~~ 改为 final class 工具类(`normalize()` / `uploadType()` / `deletedType()`),**不维护业务类型清单**
- [ ] **Step 1.3:** 编译 `mvn -pl rui-common/rui-common-core compile` 通过
- [x] **Step 1.4:** 提交 `feat(core): 新增 MqTopicConstants 和 FileBizType`(后于本计划重构成工具类)
---
### Task 2: 数据库 DDL
**Files:**
- Modify: `sql/init-database.sql` (新增 sys_file 表 DDL)
- [ ] **Step 2.1:** 在 `sql/init-database.sql` 末尾追加 `sys_file` 表 DDL(参见设计文档 §4.1)
- [ ] **Step 2.2:** 提交 `feat(db): 新增 sys_file 表`
---
### Task 3: rui-service-storage 模块骨架
**Files:**
- Create: `rui-service/rui-service-storage/pom.xml`
- Create: `rui-service/rui-service-storage/src/main/java/com/rui/service/storage/StorageApplication.java`
- Create: `rui-service/rui-service-storage/src/main/resources/application.yml`
- Modify: `rui-service/pom.xml``<modules>` 加 `<module>rui-service-storage</module>`
- [ ] **Step 3.1:** 创建 `pom.xml`parent 指向 `rui-service`,依赖:
- `rui-common-web` / `rui-common-mybatis` / `rui-common-redis` / `rui-common-mq` / `rui-common-mq-redis` / `rui-common-security` / `rui-common-oauth2`(可选)
- `spring-boot-starter-web`
- `com.aliyun.oss:aliyun-sdk-oss`(版本从 `rui-dependencies` BOM 取)
- `com.qcloud:cos_api`(版本从 BOM 取)
- `spring-cloud-starter-alibaba-nacos-discovery` / `nacos-config`
- `spring-boot-starter-actuator`
- `lombok` / `fastjson2`
- [ ] **Step 3.2:** 创建 `StorageApplication.java`
```java
@SpringBootApplication
@EnableDiscoveryClient
@EnableResourceServer
@ComponentScan(basePackages = {"com.rui.service.storage"})
public class StorageApplication {
public static void main(String[] args) {
SpringApplication.run(StorageApplication.class, args);
}
}
```
- [ ] **Step 3.3:** 创建 `application.yml`port=9400servlet.multipart 兜底)
- [ ] **Step 3.4:** `rui-service/pom.xml` 的 `<modules>` 末尾加 `<module>rui-service-storage</module>`
- [ ] **Step 3.5:** 编译 `mvn -pl rui-service/rui-service-storage -am compile` 通过
- [ ] **Step 3.6:** 提交 `feat(storage): 新建 rui-service-storage 模块骨架`
---
### Task 4: 配置类 FileProperties
**Files:**
- Create: `rui-service/rui-service-storage/src/main/java/com/rui/service/storage/properties/FileProperties.java`
- [ ] **Step 4.1:** 创建 `FileProperties`
- `@ConfigurationProperties(prefix = "rui.file")` + `@Data` + `@Component`(或 `@EnableConfigurationProperties`
- 字段:`String active`、`DataSize defaultMaxSize`(或 long)、`Map<String, BizTypeConfig> bizTypes`、`Aliyun aliyun`、`Tencent tencent`、`Local local`
- 嵌套类 `BizTypeConfig { DataSize maxSize; List<String> allowedExtensions; }`
- 嵌套类 `Aliyun { boolean enabled; String endpoint, accessKey, secretKey, bucket, urlPrefix, basePath; }`
- 嵌套类 `Tencent { boolean enabled; String secretId, secretKey, region, bucket, urlPrefix, basePath; }`
- 嵌套类 `Local { String basePath, urlPrefix; }`
- [ ] **Step 4.2:** 编译通过
- [ ] **Step 4.3:** 与本任务其他提交合并到 Task 3 的 commit(避免空 commit
---
### Task 5: FileStorage Strategy 接口
**Files:**
- Create: `rui-service/rui-service-storage/src/main/java/com/rui/service/storage/service/IFileStorage.java`
- Create: `rui-service/rui-service-storage/src/main/java/com/rui/service/storage/dto/FileStorageResult.java`
- [ ] **Step 5.1:** 创建 `IFileStorage` 接口:
```java
public interface IFileStorage {
String type(); // "ALIYUN" / "TENCENT" / "LOCAL"
boolean enabled(FileProperties props);
FileStorageResult upload(MultipartFile file, String storageKey, FileProperties props) throws IOException;
void delete(String storageKey, FileProperties props);
}
```
- [ ] **Step 5.2:** 创建 `FileStorageResult { String url; String storageKey; }`
---
### Task 6: AliyunOssFileStorage 实现
**Files:**
- Create: `rui-service/rui-service-storage/src/main/java/com/rui/service/storage/service/impl/AliyunOssFileStorage.java`
- [ ] **Step 6.1:** 实现:
- `@Component("ALIYUN")` + `@ConditionalOnProperty(prefix = "rui.file.aliyun", name = "enabled", havingValue = "true")`(用 `@ConditionalOnBean` 触发;或简单写死 bean,启用由 `enabled` 控制)
- 用 `OSSClientBuilder().build(endpoint, ak, sk)`
- `upload` 调 `ossClient.putObject(bucket, storageKey, inputStream)`
- `delete` 调 `ossClient.deleteObject(bucket, storageKey)`
- 构造 `url` = `urlPrefix + "/" + storageKey`
- `enabled` 返回 `aliyun.enabled`
- [ ] **Step 6.2:** 编译通过
---
### Task 7: TencentCosFileStorage 实现
**Files:**
- Create: `rui-service/rui-service-storage/src/main/java/com/rui/service/storage/service/impl/TencentCosFileStorage.java`
- [ ] **Step 7.1:** 实现:
- `@Component("TENCENT")` + 同条件注解
- 用 `COSClient( new BasicCOSCredentials(sid, sk), new ClientConfig(new Region(region)) )`
- `upload` 调 `cosClient.putObject(bucket, storageKey, inputStream)`
- `delete` 调 `cosClient.deleteObject(bucket, storageKey)`
- 构造 `url` = `urlPrefix + "/" + storageKey`
- [ ] **Step 7.2:** 编译通过
---
### Task 8: LocalFileStorage 实现
**Files:**
- Create: `rui-service/rui-service-storage/src/main/java/com/rui/service/storage/service/impl/LocalFileStorage.java`
- [ ] **Step 8.1:** 实现:
- `@Component("LOCAL")` (默认总是启用)
- `basePath` 启动时 `Files.createDirectories`
- `upload` 写文件到 `basePath + storageKey`,返回 `url = urlPrefix + storageKey`
- `delete` 调 `Files.deleteIfExists`
- `type()` 返回 `"LOCAL"`
- [ ] **Step 8.2:** 编译通过
---
### Task 9: FileStorageRouter
**Files:**
- Create: `rui-service/rui-service-storage/src/main/java/com/rui/service/storage/service/impl/FileStorageRouter.java`
- [ ] **Step 9.1:** 实现:
- `@Component`
- 构造注入 `Map<String, IFileStorage>`Spring 按 bean name 注入所有实现)
- `route(String storageHint)` 方法:先按 `storageHint` 找;找不到走 `props.getActive()`;都没有用 `LOCAL`
- 失败抛 `BizException("STORAGE_NOT_AVAILABLE")`
- [ ] **Step 9.2:** Task 5-9 一起提交 `feat(storage): FileStorage Strategy 模式 + 三家实现`
---
### Task 10: SysFile 实体/Mapper/Service
**Files:**
- Create: `SysFile.java` (extends `BaseEntity`)
- Create: `SysFileMapper.java` (extends `BaseMapper<SysFile>`)
- Create: `ISysFileService.java` (extends `IService<SysFile>`)
- Create: `SysFileServiceImpl.java` (extends `ServiceImpl<SysFileMapper, SysFile>`)
- [ ] **Step 10.1:** `SysFile` 字段:`id, name, originalName, url, storageType, bizType, bizId, size, contentType, sha256, uploaderId`,其他由 `BaseEntity` 提供
- [ ] **Step 10.2:** Service 增加 `appendBizId(Long fileId, String bizId)` 方便订阅方回填
- [ ] **Step 10.3:** 编译通过
- [ ] **Step 10.4:** 提交 `feat(storage): sys_file 实体/Mapper/Service`
---
### Task 11: 上传/查询/删除 Controller
**Files:**
- Create: `SysFileUploadVO.java`
- Create: `SysFileQueryVO.java`
- Create: `SysFileController.java`
- [ ] **Step 11.1:** `SysFileUploadVO` 字段:`id, name, originalName, url, size, contentType, storageType, bizType`
- [ ] **Step 11.2:** `SysFileQueryVO` 字段:`id, name, originalName, url, size, bizType, createdAt`
- [ ] **Step 11.3:** `SysFileController`
- 类级 `@AutoPermission("sys:file:upload")`
- `@PostMapping("/upload")` → `upload(file, bizType, storage?)`
- 校验 `bizType` 格式(`FileBizType.normalize()`);不再校验「是否已注册」
- 加载 `FileProperties.bizTypes[bizType]`,校验大小/扩展名
- 调 `FileStorageRouter` 选实现 → `upload`
- 算 sha256`DigestUtils.sha256Hex`
- 落 `sys_file`
- 调 `UploadEventPublisher.publish(...)`
- 返回 `Result.ok(vo)`
- `@GetMapping("/file/{id}")` → `@AutoPermission("sys:file:query")` 查询单条
- `@GetMapping("/file/page")` → 分页查询
- `@DeleteMapping("/file/{id}")` → `@AutoPermission("sys:file:delete")` 删除
- 使用 `BaseController<...>` 或独立 `@RestController`(推荐独立,路径 `/storage`
- [ ] **Step 11.4:** 编译通过
- [ ] **Step 11.5:** 提交 `feat(storage): 文件上传/查询/删除接口`
---
### Task 12: Event Publishers
**Files:**
- Create: `UploadEventPayload.java`
- Create: `UploadEventPublisher.java`
- Create: `FileDeletedEventPublisher.java`
- [ ] **Step 12.1:** `UploadEventPayload` POJO 字段与设计文档 §8.2 一致,标注 `@JSONField` 序列化
- [ ] **Step 12.2:** `UploadEventPublisher`
- `@Component @RequiredArgsConstructor`
- 注入 `MqClient`
- `publish(String bizType, SysFile entity, String url, Long uploaderId, Long tenantId, JSONObject extra)` (bizType 是已规范化的字符串,type = FileBizType.uploadType(bizType)
- 内部 `mqClient.publish(MqProvider.REDIS, MqTopicConstants.ON_UPLOAD, payload)`
- 失败只 `log.error`,不抛(避免上传回滚)
- [ ] **Step 12.3:** `FileDeletedEventPublisher` 同上结构,topic 用 `ON_FILE_DELETED`
- [ ] **Step 12.4:** 编译通过
- [ ] **Step 12.5:** 提交 `feat(storage): ON_UPLOAD/ON_FILE_DELETED 事件推送`
---
### Task 13: 集成到 rui-service-starter
**Files:**
- Modify: `rui-service/rui-service-starter/pom.xml`
- Modify: `rui-service/rui-service-starter/.../StarterApplication.java`
- Modify: `rui-service/rui-service-starter/src/main/resources/application.yml`
- [ ] **Step 13.1:** `pom.xml` 加 `<dependency> rui-service-storage </dependency>`
- [ ] **Step 13.2:** `StarterApplication` 的 `@ComponentScan` 加 `"com.rui.service.storage"`
- [ ] **Step 13.3:** `application.yml` 的 `rui.modules.available` 数组加 `code: storage, name: 文件存储, icon: tabler:cloud-upload`
- [ ] **Step 13.4:** 编译 `mvn -pl rui-service/rui-service-starter -am compile` 通过
- [ ] **Step 13.5:** 提交 `feat(starter): 集成 rui-service-storage`
---
### Task 14: 公共配置示例
**Files:**
- Modify: `docs/backend/config-templates/application-template.yml` 或 `rui-common.yaml` Nacos 配置
- [ ] **Step 14.1:** 在公共 yaml 模板加 `rui.file` 配置(active / defaultMaxSize / bizTypes 字典)参照设计文档 §9.1
- [ ] **Step 14.2:** 提交 `docs(config): rui.file 公共配置示例`
---
### Task 15: 编译验证
- [ ] **Step 15.1:** `mvn clean compile -DskipTests` 全部模块通过
- [ ] **Step 15.2:** 若有编译错误,按模块修复
---
### Task 16: 推送 + 关闭 Gitea #4
- [ ] **Step 16.1:** 累计 commit 数 ≥10 时 `git push origin main`
- [ ] **Step 16.2:** 通过 `bin/gitea-helper.sh issue-comment --id 4 --body "..."` 回复实现说明
- [ ] **Step 16.3:** `bin/gitea-helper.sh issue-close --id 4` 关闭工单
---
## 实施计划检查清单
### 规范覆盖检查
| 规范要求 | 对应任务 | 状态 |
|---------|---------|------|
| 公共常量集中 (MqTopicConstants) | Task 1 | ☐ |
| 业务类型工具类 (FileBizType,非枚举) | Task 1 | ☑ |
| 数据库继承 BaseEntity | Task 10 | ☐ |
| Strategy 模式可插拔 | Tasks 5-9 | ☐ |
| 内置 @AutoPermission 鉴权 | Task 11 | ☐ |
| 统一 Result<T> 返回 | Task 11 | ☐ |
| MQ pub/sub 事件推送 | Task 12 | ☐ |
| 集成聚合启动器 | Task 13 | ☐ |
| 配置分层 (Nacos 规则) | Task 14 | ☐ |
| 最终编译通过 | Task 15 | ☐ |
| Gitea #4 关闭 | Task 16 | ☐ |
### 验收点
- [ ] 上传 .pem 文件返回标准 Resultdata.url 可访问
- [ ] 超大文件/不允许扩展名/未知 bizType 均返回 400
- [ ] Redis 收到 ON_UPLOAD 消息
- [ ] 删除后 Redis 收到 ON_FILE_DELETED 消息
- [ ] 无 JWT 返回 401,无权限返回 403
- [ ] `rui-service-starter` 启动时 storage 子模块同时激活
- [ ] Gitea #4 已关闭
- [ ] 全部 commit 推送至 origin/main
### 无占位符检查
- [ ] 无 "TBD"、"TODO"、"implement later"
- [ ] 文件路径全部相对项目根目录
- [ ] 字段命名符合 MyBatis Plus 驼峰转下划线
- [ ] 每个步骤可独立 commit + 编译
---
**计划完成!**
保存路径:`docs/superpowers/plans/2026-06-07-file-storage-service-plan.md`
设计参考:`docs/superpowers/specs/2026-06-07-file-storage-service-design.md`
**执行选项:**
1. **Subagent-Driven(推荐)** — 每个任务分派独立子代理
2. **Inline Execution** — 当前会话连续执行,编译错误时停下确认
@@ -0,0 +1,422 @@
# Wechat/Alipay Provider 凭证动态加载改造
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-subagent-driven-development (recommended) or superpowers-executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
**Goal:** 修复 `WeixinAuthenticationProvider` / `AlipayAuthenticationProvider` 的凭证烧死 bug —— 改为持有 `AppCredentialsCache`、在请求时按 `X-App-Id` 动态解析凭证,使 SysApp CRUD / 缓存过期能立即生效。
**实施状态**: ✅ 已完成(2026-06-07commit `e3a441b`
**Architecture:** Provider 改造为"工具注入 + 内部解析"模式。每个请求处理时,从请求头读 `X-App-Id` → 调 `AppCredentialsCache.get(appId)` → 拿最新凭证 → 动态构造 `WechatApiClient`/`AlipayApiClient` → 调第三方 API。`OAuth2ServerConfig` 简化为只负责依赖注入。
**Tech Stack:** Java 21, Spring Boot 4.x, Spring Security OAuth2, Fastjson2, MyBatis Plus
---
## 文件结构
### 修改
- `rui-common/rui-common-oauth2/src/main/java/com/rui/common/oauth2/authentication/weixin/WeixinAuthenticationProvider.java`
- 构造参数:`WechatApiClient``AppCredentialsCache`
- 新增私有方法 `currentRequestAppId()``X-App-Id`
- `buildToken()` 改为按 appId 解析凭证后动态构造 `WechatApiClient`
- `rui-common/rui-common-oauth2/src/main/java/com/rui/common/oauth2/authentication/alipay/AlipayAuthenticationProvider.java`
- 构造参数:`AlipayApiClient``AppCredentialsCache`
- `buildToken()` 同样按 appId 解析凭证后动态构造 `AlipayApiClient`
- `rui-common/rui-common-oauth2/src/main/java/com/rui/common/oauth2/config/OAuth2ServerConfig.java`
-`resolveCredentials()` / `currentRequestAppId()` 私有方法
-`new WechatApiClient(...)` / `new AlipayApiClient(...)` 单例构造
-`appCredentialsCache` 直接传给两个 Provider
- 清理不再需要的 import
### 验收点
- [x] 微信登录请求:第一次请求时 `WechatApiClient``X-App-Id` 解析的凭证调微信 API
- [x] SysApp 增删改后:缓存被 evict,下次请求自动用新凭证(不需重启)
- [x] 缓存过期 30min 后:下次请求自动从 DB 重新加载凭证
- [x] `X-App-Id` 缺失 / 凭证不存在:抛 `OAuth2AuthenticationException` + `server_error` + 描述含 appId
- [x] 编译通过 `rui-common-oauth2` 模块
- [x] 不影响 `PasswordAuthenticationProvider` / `SmsAuthenticationProvider`
---
## Task 1: WeixinAuthenticationProvider 改造
**Files:**
- Modify: `rui-common/rui-common-oauth2/src/main/java/com/rui/common/oauth2/authentication/weixin/WeixinAuthenticationProvider.java`
- [x] **Step 1: 替换字段**
`private final WechatApiClient wechatApiClient;` 改为:
```java
private final AppCredentialsCache appCredentialsCache;
```
- [x] **Step 2: 修改构造函数**
构造参数 `WechatApiClient wechatApiClient` → `AppCredentialsCache appCredentialsCache`
```java
public WeixinAuthenticationProvider(AuthenticationManager authenticationManager,
OAuth2AuthorizationService authorizationService,
OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator,
AppCredentialsCache appCredentialsCache,
UserAuthFeign userAuthFeign) {
super(authenticationManager, authorizationService, tokenGenerator);
this.appCredentialsCache = appCredentialsCache;
this.userAuthFeign = userAuthFeign;
}
```
- [x] **Step 3: 添加 `X-App-Id` 读取辅助方法**
```java
/**
* 从当前请求上下文读取 X-App-Id 头。
* <p>
* 微信/支付宝登录必须通过该头传递应用标识,
* 以支持多租户/多应用凭证隔离。
*
* @return appId;未传或读取失败返回 null
*/
private String currentRequestAppId() {
try {
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attrs == null) {
return null;
}
HttpServletRequest request = attrs.getRequest();
return request.getHeader("X-App-Id");
} catch (Exception e) {
return null;
}
}
```
需要的 import
```java
import com.rui.common.oauth2.cache.AppCredentialsCache;
import com.rui.common.oauth2.cache.AppCredentialsVO;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
```
删除的 import
```java
// (如有) import com.rui.common.oauth2.authentication.weixin.WechatApiClient; // 字段类型变了,但本包内仍可访问
```
实际上 `WechatApiClient` 仍在 `buildToken` 里 new 出来用,import 不变。
- [x] **Step 4: 改造 `buildToken` 方法**
```java
@Override
public UsernamePasswordAuthenticationToken buildToken(Map<String, Object> reqParameters) {
String code = (String) reqParameters.get("code");
String phone = (String) reqParameters.get("phone");
// 1. 从请求头拿 X-App-Id
String appId = currentRequestAppId();
if (appId == null || appId.isBlank()) {
log.warn("微信登录缺少 X-App-Id 头");
throw new OAuth2AuthenticationException(new OAuth2Error(
OAuth2ErrorCodes.SERVER_ERROR,
"wechat login requires X-App-Id header",
ERROR_URI));
}
// 2. 从缓存拿凭证(30min TTL + 空对象防穿透 + 服务降级)
AppCredentialsVO creds = appCredentialsCache.get(appId);
if (creds == null || creds.getAppId() == null || creds.getAppId().isBlank()) {
log.warn("微信登录凭证未配置或服务降级: appId={}", appId);
throw new OAuth2AuthenticationException(new OAuth2Error(
OAuth2ErrorCodes.SERVER_ERROR,
"wechat credentials not configured for appId=" + appId,
ERROR_URI));
}
// 3. 用最新凭证动态构造 API 客户端(支持 SysApp CRUD / 缓存过期即时生效)
WechatApiClient wechatApiClient = new WechatApiClient(creds.getAppId(), creds.getAppSecret());
// 4. 调用微信 API 换取 openId 和 unionId
WechatApiClient.WechatTokenResponse wxResponse = wechatApiClient.getAccessToken(code);
String openId = wxResponse.getOpenid();
String unionId = wxResponse.getUnionid();
log.info("微信登录: appId={}, openId={}, unionId={}, phone={}", appId, openId, unionId, phone);
// TODO: 这里需要调用 UserSocialService 查询绑定关系
// 暂时使用 openId 作为 principal
String principal = openId + "#" + unionId + "#" + (phone != null ? phone : "");
return new UsernamePasswordAuthenticationToken(principal, null);
}
```
需要的常量(类顶部添加):
```java
private static final String ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1";
```
- [x] **Step 5: 编译验证**
```bash
mvn -pl rui-common/rui-common-oauth2 -am compile -DskipTests
```
预期:`BUILD SUCCESS`
- [x] **Step 6: Commit**
```bash
git add rui-common/rui-common-oauth2/src/main/java/com/rui/common/oauth2/authentication/weixin/WeixinAuthenticationProvider.java
git commit -m "refactor(oauth2): WeixinAuthenticationProvider 改为运行时解析凭证
- 构造参数 WechatApiClient → AppCredentialsCache
- buildToken 内按 X-App-Id 头解析凭证
- 每次请求动态构造 WechatApiClient,支持 SysApp CRUD / 缓存过期即时生效
- 凭证缺失抛 server_error(避免 ClassCastException
依赖 OAuth2ServerConfig 同步改造(Task 3)。"
```
---
## Task 2: AlipayAuthenticationProvider 改造
**Files:**
- Modify: `rui-common/rui-common-oauth2/src/main/java/com/rui/common/oauth2/authentication/alipay/AlipayAuthenticationProvider.java`
- [x] **Step 1: 替换字段**
把 `private final AlipayApiClient alipayApiClient;` 改为:
```java
private final AppCredentialsCache appCredentialsCache;
```
- [x] **Step 2: 修改构造函数**
构造参数 `AlipayApiClient alipayApiClient` → `AppCredentialsCache appCredentialsCache`
```java
public AlipayAuthenticationProvider(AuthenticationManager authenticationManager,
OAuth2AuthorizationService authorizationService,
OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator,
AppCredentialsCache appCredentialsCache) {
super(authenticationManager, authorizationService, tokenGenerator);
this.appCredentialsCache = appCredentialsCache;
}
```
- [x] **Step 3: 添加 `X-App-Id` 读取辅助方法**(同 Task 1 Step 3
- [x] **Step 4: 改造 `buildToken` 方法**
```java
@Override
public UsernamePasswordAuthenticationToken buildToken(Map<String, Object> reqParameters) {
String code = (String) reqParameters.get("code");
String phone = (String) reqParameters.get("phone");
// 1. 从请求头拿 X-App-Id
String appId = currentRequestAppId();
if (appId == null || appId.isBlank()) {
log.warn("支付宝登录缺少 X-App-Id 头");
throw new OAuth2AuthenticationException(new OAuth2Error(
OAuth2ErrorCodes.SERVER_ERROR,
"alipay login requires X-App-Id header",
ERROR_URI));
}
// 2. 从缓存拿凭证
AppCredentialsVO creds = appCredentialsCache.get(appId);
if (creds == null || creds.getAppId() == null || creds.getAppId().isBlank()) {
log.warn("支付宝登录凭证未配置或服务降级: appId={}", appId);
throw new OAuth2AuthenticationException(new OAuth2Error(
OAuth2ErrorCodes.SERVER_ERROR,
"alipay credentials not configured for appId=" + appId,
ERROR_URI));
}
// 3. 用最新凭证动态构造 API 客户端
// 字段映射说明(2026-06-07 修正):
// - AlipayApiClient 构造需要 (appId, privateKey, publicKey)
// - AppCredentialsVO 没有 privateKey/publicKey 字段
// - 按 spec 私钥/公钥存在 certificates JSON 数组里
// - 当前 AlipayApiClient.getAccessToken() 仍抛 UnsupportedOperationException
// (未接入 SDK),所以 privateKey/publicKey 暂用空串占位
// - 后续 TaskAlipay SDK 集成 + certificates JSON 解析
AlipayApiClient alipayApiClient = new AlipayApiClient(
creds.getAppId(),
"", // privateKey 占位
""); // publicKey 占位
// 4. 调用支付宝 API 获取 userId(按 spec 第 5.4 节:userId 作为唯一标识)
AlipayApiClient.AlipayTokenResponse alipayResponse = alipayApiClient.getAccessToken(code);
String userId = alipayResponse.getUserId();
log.info("支付宝登录: appId={}, userId={}, phone={}", appId, userId, phone);
// TODO: 查找或创建用户
String principal = userId + "#" + (phone != null ? phone : "");
return new UsernamePasswordAuthenticationToken(principal, null);
}
```
> 注意:当前 `AlipayApiClient(String appId, String privateKey, String publicKey)` 构造签名是 3 参。
> `AppCredentialsVO` 暂未确认字段,先按 `getAppKey()` / `getAesKey()` 假设,**实施时如发现字段不匹配,停下来汇报,不要硬猜**。
需要的 import(参考 Task 1)。
- [x] **Step 5: 编译验证**
```bash
mvn -pl rui-common/rui-common-oauth2 -am compile -DskipTests
```
- [x] **Step 6: Commit**
```bash
git add rui-common/rui-common-oauth2/src/main/java/com/rui/common/oauth2/authentication/alipay/AlipayAuthenticationProvider.java
git commit -m "refactor(oauth2): AlipayAuthenticationProvider 改为运行时解析凭证
- 构造参数 AlipayApiClient → AppCredentialsCache
- buildToken 内按 X-App-Id 头解析凭证
- 每次请求动态构造 AlipayApiClient,支持 SysApp CRUD / 缓存过期即时生效
- 凭证缺失抛 server_error
依赖 OAuth2ServerConfig 同步改造(Task 3)。"
```
---
## Task 3: OAuth2ServerConfig 清理
**Files:**
- Modify: `rui-common/rui-common-oauth2/src/main/java/com/rui/common/oauth2/config/OAuth2ServerConfig.java`
- [x] **Step 1: 替换 Provider 实例化代码**
在 `authorizationServerFilterChain` 方法内(约 130-141 行):
删除:
```java
// 微信:凭证从 X-App-Id 请求头 → AppCredentialsCache 拿
AppCredentialsVO wechatCreds = resolveCredentials(appCredentialsCache, "wechat");
WechatApiClient wechatApiClient = new WechatApiClient(
wechatCreds.getAppId(), wechatCreds.getAppSecret());
WeixinAuthenticationProvider wechatProvider = new WeixinAuthenticationProvider(
authenticationManager, authorizationService, tokenGenerator, wechatApiClient, userAuthFeign);
// 支付宝:暂用空凭证(certificates 解析未完成,TODO 接 Alipay SDK 后改造)
// 当前 AlipayApiClient 在 buildToken 时会抛 UnsupportedOperationException(按 spec 占位)
AlipayApiClient alipayApiClient = new AlipayApiClient("", "", "");
AlipayAuthenticationProvider alipayProvider = new AlipayAuthenticationProvider(
authenticationManager, authorizationService, tokenGenerator, alipayApiClient);
```
替换为:
```java
// 微信 / 支付宝:凭证由 Provider 内部按 X-App-Id 头从 AppCredentialsCache 解析
WeixinAuthenticationProvider wechatProvider = new WeixinAuthenticationProvider(
authenticationManager, authorizationService, tokenGenerator, appCredentialsCache, userAuthFeign);
AlipayAuthenticationProvider alipayProvider = new AlipayAuthenticationProvider(
authenticationManager, authorizationService, tokenGenerator, appCredentialsCache);
```
- [x] **Step 2: 删除 `resolveCredentials` / `currentRequestAppId` 私有方法**
删除约 151-181 行的两个方法。
- [x] **Step 3: 清理不再需要的 import**
删除:
```java
import com.rui.common.oauth2.authentication.weixin.WechatApiClient;
import com.rui.common.oauth2.authentication.alipay.AlipayApiClient;
import com.rui.common.oauth2.cache.AppCredentialsVO;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
```
- [x] **Step 4: 编译验证**
```bash
mvn -pl rui-common/rui-common-oauth2 -am compile -DskipTests
```
预期:`BUILD SUCCESS`
- [x] **Step 5: Commit**
```bash
git add rui-common/rui-common-oauth2/src/main/java/com/rui/common/oauth2/config/OAuth2ServerConfig.java
git commit -m "refactor(oauth2): OAuth2ServerConfig 清理凭证启动期构造
- 移除 resolveCredentials / currentRequestAppId 私有方法
- 移除启动期 new WechatApiClient / new AlipayApiClient
- Provider 构造改为直接注入 AppCredentialsCache
- 凭证解析完全下放到 Provider buildToken 请求路径
与 WeixinAuthenticationProvider / AlipayAuthenticationProvider 配合
实现按 X-App-Id 头的运行时凭证加载。"
```
---
## Task 4: 验证 AlipayApiClient 字段映射
**Files:**
- Read: `rui-common/rui-common-oauth2/src/main/java/com/rui/common/oauth2/authentication/alipay/AlipayApiClient.java`
- Read: `rui-common/rui-common-oauth2/src/main/java/com/rui/common/oauth2/cache/AppCredentialsVO.java`
- [x] **Step 1: 核对构造签名**
读取两个文件,确认:
- `AlipayApiClient` 构造参数类型与 `AppCredentialsVO` 提供的 getter 一一对应
- 如字段名不一致(如 `privateKey` vs `appSecret`),调整 Task 2 的代码
- [x] **Step 2: 必要时提交修复 commit**
如发现字段不匹配,单独 commit:
```bash
git add rui-common/rui-common-oauth2/src/main/java/com/rui/common/oauth2/authentication/alipay/AlipayAuthenticationProvider.java
git commit -m "fix(oauth2): 修正 AlipayApiClient 构造参数映射"
```
---
## 验收检查清单
| 验收点 | 对应任务 | 状态 |
|--------|---------|------|
| 微信登录按 X-App-Id 解析凭证 | Task 1 | [ ] |
| 支付宝登录按 X-App-Id 解析凭证 | Task 2 | [ ] |
| AppCredentialsCache 复用(30min TTL + 空对象穿透) | Task 1+2 | [ ] |
| OAuth2ServerConfig 不再启动期构造 API 客户端 | Task 3 | [ ] |
| 凭证缺失抛 OAuth2 server_error | Task 1+2 | [ ] |
| 编译通过 rui-common-oauth2 | Task 1+2+3 Step 5 | [ ] |
| 不影响 Password / Sms Provider | Task 1+2+3 | [ ] |
| AlipayApiClient 字段映射正确 | Task 4 | [ ] |
## 实施选项
1. **Subagent-Driven(推荐)** - 我为每个任务分派独立的子代理,任务间审查,快速迭代
2. **Inline Execution** - 在本会话中执行任务,批量执行并设置检查点
**请选择执行方式?**
---
## 实施完成报告
- **完成日期**: 2026-06-07
- **Commit**: `e3a441b` `refactor(oauth2): 微信/支付宝 Provider 改为运行时解析凭证`
- **改动**: 3 文件,+180/-78 行
- **影响分析**: risk=low0 affected processes
- **编译验证**: `mvn -pl rui-common/rui-common-oauth2 -am compile` BUILD SUCCESS
**遗留工作**(不在本次范围):
- Alipay SDK 集成 + certificates JSON 解析(`AlipayApiClient` privateKey/publicKey 暂传空串)
- 单元测试 / 集成测试覆盖
- 多 appId 池(当前每个请求 new 一个 WechatApiClient,可优化为按 appId 缓存)
@@ -1,9 +1,10 @@
# 用户聚合查询设计规格
> **日期**: 2026-06-06
> **状态**: 已批准
> **状态**: 已实现(2026-06-06
> **作者**: AI Assistant
> **相关模块**: rui-service-user
> **实施情况**: 20 个任务(T1T20)全部完成,详见 `docs/superpowers/plans/2026-06-06-user-aggregate-query-plan.md`。
---
@@ -0,0 +1,515 @@
# 文件存储服务(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 (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.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`
@@ -1,8 +1,9 @@
# 多方式登录与第三方登录设计文档
> **日期**: 2026-06-07
> **状态**: 已批准,待实现
> **状态**: 已实现2026-06-07
> **作者**: AI Assistant
> **实施情况**: 数据库变更、实体调整、UserSocial 增删、密码登录扩展、短信/微信/支付宝框架、OAuth2ServerConfig 注册、配置更新、编译验证共 12 任务全部完成,详见 `docs/superpowers/plans/2026-06-07-multi-login-social-login-plan.md` 与 git log `6fd82fb` 起各 commit。
---
@@ -1,8 +1,9 @@
# 第三方应用管理(SysApp)设计文档
> **日期**: 2026-06-07
> **状态**: 已批准,待实现
> **状态**: 已实现2026-06-07
> **作者**: AI Assistant
> **实施情况**: SysApp 实体/枚举、Mapper/Service/CRUD、Inner 接口、Feign 集成、AppCredentialsCache、降级 FallbackFactory、菜单注册均已落地,详见 git log `27fa187` 起各 commit。
---