Compare commits

...

27 Commits

Author SHA1 Message Date
vifo 4540af71ae docs(ai-skills): README 索引加 sql-deploy.md 入口 2026-06-07 18:25:34 +08:00
vifo eba4f07832 docs(standards): 增加 Feign 客户端注册规范章节
按用户提醒 + 本次实际踩坑记录:
- 项目用 rui-common-feign 自定义注册机制
- spring.factories 是 Feign 唯一注册渠道(@EnableFeignClients 包扫描不生效)
- 添加新 FeignClient 必须同步更新 spring.factories
- 漏写会导致运行时 NPE
2026-06-07 17:54:04 +08:00
vifo 3395f69b42 docs(standards): 增加 Result 返回规范章节
按用户反馈(i18n key 用 code 字符串,key 放 data 字段):
- 字段语义:error/Http、code/i18n key、message/默认中文、data/业务数据
- 调用规范表:成功/校验失败/未授权/无权限/数据不存在/降级/通用失败
- 重点:数据不存在用 failNotFound(ResultCode.DATA_NOT_FOUND, key)
- 禁止写法:Result.ok(null) 表示'未找到'(反直觉)
- i18n 配合示例:前端用 code 字段路由 i18n,data 字段做模板替换
2026-06-07 17:49:19 +08:00
vifo 22889afedc docs(spec): 用 appId 作为唯一标识(来自 X-App-Id 请求头)
原方案用 platform 作为缓存 key 错误:
- 多个租户都叫 platform=wechat,缓存 key 冲突
- platform 不是真正唯一标识

按用户反馈调整:
- 凭证查询/缓存全部用 appId
- appId 来源:请求头 X-App-Id
- §3.1 加 UNIQUE KEY uk_app_id (app_id) 约束
- §5.1 流程重写:从 X-App-Id 取 appId → 缓存 key = app:creds:{appId}
- §5.2/5.3 删缓存也改用 appId
- §7.1 OAuth2 改造:从 request.getHeader("X-App-Id") 取
- 删除 §1.2 中 'client_id 映射' 的旧说法
2026-06-07 17:08:32 +08:00
vifo c576053ab6 docs(spec): SysApp 简化为单密钥列 + 多证书 JSON 数组
按用户反馈调整:
- 删除:app_secret_text / app_secret_file_path / private/public_key 的 text+file_path
- 新增:certificates JSON(多 p12 证书,每项含 name/path/password)
- 简单凭证(app_id/app_secret/app_key/aes_key)保留单值 VARCHAR 列
- §2 原则 4 改为'简单文本用列 / 多证书用 JSON'
2026-06-07 17:02:06 +08:00
vifo 33690fe80b docs(spec): 第三方应用管理(SysApp)设计规格
- 新增 SysApp 实体:多租户第三方应用凭证管理
- 平台元数据 + 支付扩展 + AES key + 证书/文本分离
- 消费方:OAuth2ServerConfig Feign + Redis 30min 缓存
- 租户隔离:owner_type=PLATFORM/TENANT + client_id→tenant_id 映射
- 范围:不涉及 rui-service-user(user 模块)
2026-06-07 16:53:36 +08:00
vifo ae78e0f673 docs(nacos): 前缀 social → thirdparty,匹配 OAuth2ServerConfig 改动 2026-06-07 16:15:08 +08:00
vifo 856e16beff docs(nacos): 用 app.wechat/app.alipay 替代 social.* 配置结构
配合 OAuth2ServerConfig 重构:
- social.wechat.app-id/secret → app.wechat.app-id/secret
- social.alipay.app-id/private-key/public-key → app.alipay.*
- 新增 app.alipay.app-key (AlipayApiClient 预留扩展)
2026-06-07 16:13:09 +08:00
vifo 6df6e7ad0c docs(plan): 标记 Task 12 (编译验证) 已完成 (commits 74960af + baf0283)
附:实际执行包含修复 plan 的 supports 签名 bug (3 个 Provider)
2026-06-07 15:56:28 +08:00
vifo 365aa49cbd docs(plan): 标记 Task 11 (sys_oauth_client grant_types) 已完成 (commit 74960af) 2026-06-07 15:53:52 +08:00
vifo 792b10bd34 docs(plan): 标记 Task 10 (Nacos 社交登录配置) 已完成 (commit 2249b36) 2026-06-07 15:52:45 +08:00
vifo 2249b3649a docs(nacos): 添加社交登录配置 (rui-auth.yaml)
- 微信登录配置 (app-id, app-secret)
- 支付宝登录配置 (app-id, private-key, public-key)
- 用 env var 占位符支持容器化部署
2026-06-07 15:52:34 +08:00
vifo b3c66245e9 docs(plan): 标记 Task 9 (OAuth2 配置注册) 已完成 (commit 22c64bd)
附:实际还包含让原 no-arg 构造编译失败的修复
2026-06-07 15:51:25 +08:00
vifo eb99ae43cb docs(plan): 标记 Task 8 (支付宝登录框架) 已完成 (commit a4fcb95) 2026-06-07 15:49:17 +08:00
vifo c69c34ff25 docs(plan): 标记 Task 7 (微信登录框架) 已完成 (commit 337d189) 2026-06-07 15:47:31 +08:00
vifo 938302c164 docs(plan): 标记 Task 6 (短信登录框架) 已完成 (commit 89645d5) 2026-06-07 15:45:16 +08:00
vifo f1f4440be2 docs(plan): 标记 Task 5 (OAuth2 密码登录扩展) 已完成
- 3 个文件修改完成 (commit 2488bcf)
- 标注:Step 3 跳过 (loadUserByAccount 已支持 EMAIL)
- 关键修补:loadUserByUsername 增加 # 解码 (plan 漏掉的实现漏洞)
2026-06-07 15:42:36 +08:00
vifo 47ef4c6938 docs(plan): 标记 Task 4 (内部接口 EMAIL 支持) 已完成 (commit 27f4a00) 2026-06-07 15:38:35 +08:00
vifo 00c77529c5 docs(plan): 标记 Task 3 (数据访问层) 已完成
- UserSocialMapper/Service/Impl 创建完成 (commit c147e56)
- 标注 5 处实际执行的偏差(#prefix#、@EnableRedisCache、@Transactional、baseMapper、tenantId)
2026-06-07 15:37:38 +08:00
vifo 23823074f6 docs(plan): 标记 Task 2 (实体类调整) 已完成
- User 实体加 email、UserDetail 删 email、UserSocial 新建 (commit 1de6937)
- 添加实际执行说明:UserSocial 屏蔽 BaseEntity 不存在的审计字段
2026-06-07 15:34:26 +08:00
vifo 84b3bb601e docs(spec): 添加多方式登录与第三方登录设计规格 (已批准) 2026-06-07 15:32:26 +08:00
vifo 3c618b57bb docs(plan): 标记 Task 1 (数据库变更) 已完成
- Step 1-4 全部完成 (commit 6fd82fb)
- 添加实际执行说明:email 字段位置改为 AFTER username
2026-06-07 15:31:52 +08:00
vifo 9d0cffa86e docs(plan): 添加多方式登录与第三方登录实施计划
- 12个任务,覆盖数据库、实体、数据访问、认证逻辑、配置
- 详细的步骤和代码示例
- 包含编译验证和检查清单
2026-06-07 15:13:02 +08:00
vifo a4767ee3d0 docs(plan): 更新实施计划状态为已完成
- 所有20个任务已完成
- 编译验证通过
- 代码已提交
2026-06-06 17:10:40 +08:00
vifo 3c2fa877a6 docs(plan): 用户聚合查询实施计划
- 20个详细任务分解
- 包含依赖关系图
- 数据库变更、代码修改、缓存、测试全覆盖
- 风险评估和回滚计划
2026-06-06 13:32:44 +08:00
vifo de78c21799 docs(spec): 更新用户聚合查询设计规格
- 添加 phone 字段迁移到 uc_user 表的设计
- 新增统一认证接口 /user/inner/auth/load(POST)
- 支持 AccountType 枚举:USERNAME/PHONE/EMAIL
- 废弃旧的 loadByUsername 接口
- 添加数据库变更 SQL 脚本
2026-06-06 13:30:23 +08:00
vifo a8c164459a docs(spec): 用户聚合查询设计规格
- 方案B:后端聚合查询 + Redis缓存
- 新增聚合接口 /user/admin/user/{id}/aggregate
- 批量列表查询优化,避免N+1
- 缓存策略与失效机制设计
- 保持向后兼容
2026-06-06 13:14:13 +08:00
8 changed files with 4132 additions and 2 deletions
+2 -1
View File
@@ -12,7 +12,8 @@ ai-skills/
├── issue-workflow.md # 工单处理流程 ├── issue-workflow.md # 工单处理流程
├── gitea-api.md # Gitea API 使用指南 ├── gitea-api.md # Gitea API 使用指南
├── menu-config.md # 菜单配置规范 ├── menu-config.md # 菜单配置规范
── commit-standards.md # 提交规范 ── commit-standards.md # 提交规范
└── sql-deploy.md # SQL 变更后置流程(推本地库 + 菜单 + 前端工单)
``` ```
## 使用方式 ## 使用方式
@@ -5,3 +5,23 @@
# 服务端口:9301(认证中心,所有服务依赖) # 服务端口:9301(认证中心,所有服务依赖)
server: server:
port: 9301 port: 9301
# 第三方应用通用配置
# 字段定义见 com.rui.common.core.properties.AppProperties
# 每个第三方应用一份独立配置(prefix = thirdparty.<平台名>
# 留空表示对应登录方式未启用(Provider 仍会注册,但调用时会失败)
thirdparty:
wechat:
# 微信开放平台 AppID
app-id: ${WECHAT_APP_ID:}
# 微信开放平台 AppSecret
app-secret: ${WECHAT_APP_SECRET:}
alipay:
# 支付宝开放平台 AppID
app-id: ${ALIPAY_APP_ID:}
# 应用 Key(部分平台如支付宝使用)
app-key: ${ALIPAY_APP_KEY:}
# 应用私钥(用于请求签名,RSA2
private-key: ${ALIPAY_PRIVATE_KEY:}
# 支付宝公钥(用于响应验签)
public-key: ${ALIPAY_PUBLIC_KEY:}
+119 -1
View File
@@ -298,4 +298,122 @@ Closes #123
--- ---
> **最后提醒**:编码规范是为了团队协作,请务必遵守! > **最后提醒**:编码规范是为了团队协作,请务必遵守!
---
## 🌐 Feign 客户端注册规范
`rui-common-feign` 提供自定义 Feign 注册机制(`CloudFeignAutoConfiguration` +
`CustomFeignClientsRegistrar`),**与 Spring Cloud 默认的包扫描机制不同**。
### 注册渠道
所有 `@FeignClient` 接口**必须**列在 `META-INF/spring.factories` 中:
```properties
# rui-common-{module}/src/main/resources/META-INF/spring.factories
com.rui.common.feign.CloudFeignAutoConfiguration=\
com.rui.{module}.feign.YourFeignClient,\
com.rui.{module}.feign.AnotherFeignClient
```
### 为什么不能只靠包扫描
- 项目使用自定义的 `CustomFeignClientsRegistrar`**只有当 `@CloudEnableFeignClients`
注解存在时才会触发包扫描**
- 项目**零处**使用 `@CloudEnableFeignClients` 注解
- 因此 `spring.factories` 是项目 Feign 客户端的**唯一**注册渠道
### 添加新 FeignClient 步骤
1. 定义 `@FeignClient` 接口(带 `contextId` / `path` / `fallbackFactory`
2. **必须**在 `META-INF/spring.factories` 中追加类名
3. 漏写第 2 步 → Bean 未注册 → 运行时 NPE"no qualifying bean of type ..."
### ❌ 禁止
- 只定义 `@FeignClient` 接口但忘了列 `spring.factories`(最常见的坑)
- 期待 Spring Cloud 默认的包扫描会帮你发现(项目里不会)
---
## 📦 Result 返回规范
`com.rui.common.core.result.Result` 是统一的 API 响应封装。所有 controller 必须遵守:
### 字段语义
| 字段 | 类型 | 用途 |
|---|---|---|
| `error` | int | HTTP 风格状态码(200/400/401/403/404/500/503 等) |
| `code` | String | **业务编码,前端 i18n key**(如 `DATA_NOT_FOUND` |
| `message` | String | 默认中文提示,可由前端 i18n 覆盖 |
| `data` | T | 业务数据 |
### 调用规范
| 场景 | 调用方式 | 备注 |
|---|---|---|
| 成功 | `Result.ok(data)` | data 可为 null,但**列表场景应返回 `emptyList`** |
| 业务校验失败 | `Result.fail(400, "msg")``Result.fail(ResultCode.X, "msg")` | 优先用枚举 |
| 未授权 | `Result.fail(401, "msg")` | 框架层 `GlobalExceptionHandler` 统一处理 |
| 无权限 | `Result.fail(403, "msg")` | 同上 |
| **数据不存在** | **`Result.failNotFound(ResultCode.DATA_NOT_FOUND, key)`** | **推荐写法**:key 放 data 字段便于前端模板替换 |
| 资源不存在(泛指) | `Result.fail(ResultCode.NOT_FOUND)` | 不带 key 的场景 |
| 服务降级 | `Result.fail(503, "服务降级: msg")` | Feign fallback 等场景 |
| 通用失败 | `Result.fail("msg")` | 兜底 |
### ❌ 禁止写法
- **`Result.ok(null)` 表示"未找到"** —— 反直觉(HTTP 200 + null),且与 `fail(404)` 语义冲突
- **message 中拼接 key** —— 如 `"字典不存在: " + dictCode`,应该用 `failNotFound(DATA_NOT_FOUND, dictCode)` 让前端用 i18n 模板 `"字典[${data}]不存在"`
- **数字 code 字符串比较** —— 应该用 `ResultCode` 枚举的 `getCode()` 字符串
### ✅ 正确示例
```java
// 查询接口 - 数据不存在
@GetMapping("/dict/getByCode/{dictCode}")
public Result<Map<String, Object>> getDictByCode(@PathVariable String dictCode) {
SysDictType dict = dictTypeService.findByCode(dictCode);
if (dict == null) {
return Result.failNotFound(ResultCode.DATA_NOT_FOUND, dictCode);
}
return Result.ok(buildDictResult(dict));
}
// 业务校验失败
@PostMapping("/save")
public Result<Void> save(@RequestBody @Valid SysDictDTO dto) {
if (dictService.isCodeExists(dto.getCode())) {
return Result.fail(400, "字典编码已存在: " + dto.getCode());
}
return Result.ok();
}
// 列表查询(即使是空也要返回空集合)
@GetMapping("/list")
public Result<List<SysDict>> list() {
return Result.ok(dictService.list()); // 不要 Result.ok(null)
}
```
### i18n 配合示例
前端拿到 `Result` 后:
```javascript
const i18nMap = {
'DATA_NOT_FOUND': '数据[{0}]不存在', // 占位符 {0} 用 data 字段填充
'AUTH_UNAUTHORIZED': '请先登录',
};
if (result.code === 'DATA_NOT_FOUND') {
showError(i18nMap['DATA_NOT_FOUND'].replace('{0}', result.data));
}
```
### 相关枚举
- `com.rui.common.core.result.ResultCode` —— 业务 code 枚举(404 用 `DATA_NOT_FOUND`401 用 `UNAUTHORIZED` 等)
- 新增业务 code 时在 `ResultCode` 加枚举值,**不要直接 `Result.fail(int, String, String)` 硬编码字符串**
@@ -0,0 +1,725 @@
# 用户聚合查询实施计划
> **日期**: 2026-06-06
> **状态**: 已完成
> **关联 Spec**: `docs/superpowers/specs/2026-06-06-user-aggregate-query-design.md`
---
## 1. 任务总览
### 1.1 任务清单
| 编号 | 任务名称 | 优先级 | 预估时间 | 依赖 |
|------|---------|--------|---------|------|
| T1 | 数据库变更:uc_user 添加 phone 字段 | 高 | 20分钟 | 无 | ✅ |
| T2 | 数据库变更:uc_user_detail 移除 phone 字段 | 高 | 15分钟 | T1 | ✅ |
| T3 | 修改 User 实体:添加 phone 字段 | 高 | 15分钟 | T1 | ✅ |
| T4 | 修改 UserDetail 实体:移除 phone 字段 | 高 | 10分钟 | T2 | ✅ |
| T5 | 新增 VO 对象:UserAggregateVO, UserDeptVO, UserPostVO | 高 | 20分钟 | 无 | ✅ |
| T6 | 新增枚举:AccountType | 高 | 10分钟 | 无 | ✅ |
| T7 | 新增 DTOLoginAccountDTO | 高 | 10分钟 | T6 | ✅ |
| T8 | 修改 UserDeptMapper:添加批量查询方法 | 高 | 20分钟 | 无 | ✅ |
| T9 | 修改 UserPostMapper:添加批量查询方法 | 高 | 20分钟 | 无 | ✅ |
| T10 | 修改 UserService:添加聚合查询方法 | 高 | 30分钟 | T3, T5, T8, T9 | ✅ |
| T11 | 修改 UserController:添加聚合接口 | 高 | 20分钟 | T10 | ✅ |
| T12 | 修改 UserInnerController:添加统一认证接口 | 高 | 25分钟 | T3, T7 | ✅ |
| T13 | 修改 UserAuthFeign:添加统一认证方法 | 高 | 15分钟 | T12 | ✅ |
| T14 | 修改 RemoteUserDetailsService:支持新接口 | 高 | 20分钟 | T13 | ✅ |
| T15 | 添加缓存:Redis 缓存用户聚合数据 | 中 | 25分钟 | T10 | ✅ |
| T16 | 缓存失效:数据变更时清除缓存 | 中 | 20分钟 | T15 | ✅ |
| T17 | 编写 SQL 升级脚本 | 高 | 15分钟 | T1, T2 | ✅ |
| T18 | 单元测试和编译验证 | 中 | 40分钟 | T10, T12 | ✅ |
| T19 | 集成测试(编译通过) | 中 | 30分钟 | T18 | ✅ |
| T20 | 文档更新 | 低 | 15分钟 | 全部 | ✅ |
### 1.2 依赖关系图
```
T1 (数据库添加phone)
├── T3 (User实体添加phone)
│ ├── T10 (UserService聚合查询)
│ │ ├── T11 (UserController聚合接口)
│ │ ├── T15 (Redis缓存)
│ │ │ └── T16 (缓存失效)
│ │ └── T18 (单元测试)
│ └── T12 (统一认证接口)
│ ├── T13 (UserAuthFeign)
│ │ └── T14 (RemoteUserDetailsService)
│ └── T18 (单元测试)
├── T7 (LoginAccountDTO)
│ └── T12 (统一认证接口)
└── T17 (SQL脚本)
T2 (数据库移除phone)
└── T4 (UserDetail实体移除phone)
T5 (VO对象)
└── T10 (UserService聚合查询)
T6 (AccountType枚举)
├── T7 (LoginAccountDTO)
└── T12 (统一认证接口)
T8 (UserDeptMapper批量查询)
└── T10 (UserService聚合查询)
T9 (UserPostMapper批量查询)
└── T10 (UserService聚合查询)
T18 (单元测试)
└── T19 (集成测试)
T20 (文档更新)
```
---
## 2. 详细任务
### T1: 数据库变更 - uc_user 添加 phone 字段
**目标**: 在 `uc_user` 表添加 `phone` 字段并创建索引
**步骤**:
1. [ ] 编写 SQL
```sql
ALTER TABLE rui_uc_user
ADD COLUMN phone VARCHAR(20) DEFAULT NULL COMMENT '手机号' AFTER username;
ALTER TABLE rui_uc_user
ADD UNIQUE KEY uk_phone (tenant_id, phone);
ALTER TABLE rui_uc_user
ADD INDEX idx_phone (phone);
```
2. [ ] 在开发环境执行 SQL
3. [ ] 验证表结构:`DESCRIBE rui_uc_user;`
4. [ ] 验证索引:`SHOW INDEX FROM rui_uc_user;`
**验证标准**:
- `phone` 字段存在
- `uk_phone` 唯一索引存在
- `idx_phone` 普通索引存在
**风险**: 生产环境需要谨慎,建议在低峰期执行
---
### T2: 数据库变更 - uc_user_detail 移除 phone 字段
**目标**: 从 `uc_user_detail` 表移除 `phone` 字段
**步骤**:
1. [ ] 备份数据(可选)
2. [ ] 编写 SQL
```sql
-- 先迁移数据(如果有)
-- UPDATE rui_uc_user u
-- JOIN rui_uc_user_detail d ON u.id = d.user_id
-- SET u.phone = d.phone
-- WHERE u.phone IS NULL AND d.phone IS NOT NULL;
ALTER TABLE rui_uc_user_detail
DROP COLUMN phone;
```
3. [ ] 在开发环境执行 SQL
4. [ ] 验证表结构
**验证标准**:
- `phone` 字段已移除
- 其他字段不受影响
**风险**: 确保数据已迁移或不再使用
---
### T3: 修改 User 实体 - 添加 phone 字段
**目标**: 在 `User.java` 实体中添加 `phone` 字段
**文件**: `rui-service/rui-service-user/src/main/java/com/rui/service/user/entity/User.java`
**步骤**:
1. [ ] 添加字段:
```java
@Schema(description = "手机号")
@SearchField(alias = "phone")
private String phone;
```
2. [ ] 确保字段位置在 `username` 之后
3. [ ] 编译验证
**验证标准**:
- `User` 实体可以正常编译
- `phone` 字段有 getter/setter@Data 自动生成)
---
### T4: 修改 UserDetail 实体 - 移除 phone 字段
**目标**: 从 `UserDetail.java` 实体中移除 `phone` 字段
**文件**: `rui-service/rui-service-user/src/main/java/com/rui/service/user/entity/UserDetail.java`
**步骤**:
1. [ ] 移除字段:
```java
// 移除以下代码
@Schema(description = "手机号")
@SearchField(alias = "phone")
private String phone;
```
2. [ ] 编译验证
**验证标准**:
- `UserDetail` 实体可以正常编译
- `phone` 字段已移除
---
### T5: 新增 VO 对象
**目标**: 创建用户聚合查询的 VO 对象
**文件**:
- `rui-service/rui-service-user/src/main/java/com/rui/service/user/vo/UserAggregateVO.java`
- `rui-service/rui-service-user/src/main/java/com/rui/service/user/vo/UserDeptVO.java`
- `rui-service/rui-service-user/src/main/java/com/rui/service/user/vo/UserPostVO.java`
**步骤**:
1. [ ] 创建 `vo` 包
2. [ ] 创建 `UserAggregateVO.java`
```java
package com.rui.service.user.vo;
import com.rui.service.user.entity.User;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.List;
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(description = "用户聚合信息")
public class UserAggregateVO extends User {
@Schema(description = "部门列表")
private List<UserDeptVO> depts;
@Schema(description = "岗位列表")
private List<UserPostVO> posts;
@Schema(description = "主部门ID")
private Long mainDeptId;
@Schema(description = "主部门名称")
private String mainDeptName;
@Schema(description = "部门编码")
private String deptCode;
@Schema(description = "岗位编码")
private String postCode;
}
```
3. [ ] 创建 `UserDeptVO.java`
4. [ ] 创建 `UserPostVO.java`
5. [ ] 编译验证
**验证标准**:
- 所有 VO 类可以正常编译
- 字段和类型正确
---
### T6: 新增枚举 - AccountType
**目标**: 创建账号类型枚举
**文件**: `rui-service/rui-service-user/src/main/java/com/rui/service/user/enums/AccountType.java`
**步骤**:
1. [ ] 创建 `enums` 包
2. [ ] 创建 `AccountType.java`
```java
package com.rui.service.user.enums;
import lombok.Getter;
@Getter
public enum AccountType {
USERNAME("用户名"),
PHONE("手机号"),
EMAIL("邮箱");
private final String description;
AccountType(String description) {
this.description = description;
}
}
```
3. [ ] 编译验证
**验证标准**:
- 枚举可以正常编译
- 包含 USERNAME, PHONE, EMAIL 三个值
---
### T7: 新增 DTO - LoginAccountDTO
**目标**: 创建登录账号 DTO
**文件**: `rui-service/rui-service-user/src/main/java/com/rui/service/user/dto/LoginAccountDTO.java`
**步骤**:
1. [ ] 创建 `dto` 包
2. [ ] 创建 `LoginAccountDTO.java`
```java
package com.rui.service.user.dto;
import com.rui.service.user.enums.AccountType;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "登录账号信息")
public class LoginAccountDTO {
@Schema(description = "账号")
private String account;
@Schema(description = "账号类型")
private AccountType accountType;
}
```
3. [ ] 编译验证
**验证标准**:
- DTO 可以正常编译
- 包含 account 和 accountType 字段
---
### T8: 修改 UserDeptMapper - 添加批量查询方法
**目标**: 添加根据用户ID列表批量查询部门的方法
**文件**:
- `rui-service/rui-service-user/src/main/java/com/rui/service/user/mapper/UserDeptMapper.java`
- `rui-service/rui-service-user/src/main/resources/mapper/UserDeptMapper.xml`
**步骤**:
1. [ ] 在 `UserDeptMapper.java` 添加方法:
```java
List<UserDeptVO> selectDeptListByUserIds(@Param("userIds") List<Long> userIds);
```
2. [ ] 在 `UserDeptMapper.xml` 添加 SQL
```xml
<select id="selectDeptListByUserIds" resultType="com.rui.service.user.vo.UserDeptVO">
SELECT
ud.user_id as userId,
ud.dept_id as deptId,
d.dept_code as deptCode,
d.name as deptName,
ud.is_main as main
FROM uc_user_dept ud
INNER JOIN uc_dept d ON ud.dept_id = d.id
WHERE ud.user_id IN
<foreach collection="userIds" item="id" open="(" separator="," close=")">
#{id}
</foreach>
AND ud.deleted = 0
AND d.deleted = 0
</select>
```
3. [ ] 编译验证
**验证标准**:
- Mapper 接口可以正常编译
- XML 语法正确
---
### T9: 修改 UserPostMapper - 添加批量查询方法
**目标**: 添加根据用户ID列表批量查询岗位的方法
**文件**:
- `rui-service/rui-service-user/src/main/java/com/rui/service/user/mapper/UserPostMapper.java`
- `rui-service/rui-service-user/src/main/resources/mapper/UserPostMapper.xml`
**步骤**:
1. [ ] 在 `UserPostMapper.java` 添加方法:
```java
List<UserPostVO> selectPostListByUserIds(@Param("userIds") List<Long> userIds);
```
2. [ ] 在 `UserPostMapper.xml` 添加 SQL
3. [ ] 编译验证
**验证标准**:
- Mapper 接口可以正常编译
- XML 语法正确
---
### T10: 修改 UserService - 添加聚合查询方法
**目标**: 在 UserService 中添加聚合查询逻辑
**文件**:
- `rui-service/rui-service-user/src/main/java/com/rui/service/user/service/IUserService.java`
- `rui-service/rui-service-user/src/main/java/com/rui/service/user/service/impl/UserServiceImpl.java`
**步骤**:
1. [ ] 在 `IUserService.java` 添加方法:
```java
UserAggregateVO getUserAggregate(Long userId);
Page<UserAggregateVO> listUserAggregate(Page<User> page, QueryWrapper<User> query);
```
2. [ ] 在 `UserServiceImpl.java` 实现方法
3. [ ] 注入 `UserDeptMapper` 和 `UserPostMapper`
4. [ ] 实现单用户聚合查询
5. [ ] 实现批量列表查询(使用 IN 批量查询)
6. [ ] 编译验证
**验证标准**:
- Service 接口可以正常编译
- 实现类可以正常编译
- 聚合查询逻辑正确
---
### T11: 修改 UserController - 添加聚合接口
**目标**: 添加用户聚合查询接口
**文件**: `rui-service/rui-service-user/src/main/java/com/rui/service/user/controller/UserController.java`
**步骤**:
1. [ ] 添加方法:
```java
@Operation(summary = "获取用户聚合信息")
@GetMapping("/{id}/aggregate")
public Result<UserAggregateVO> getUserAggregate(@PathVariable Long id) {
return Result.ok(service.getUserAggregate(id));
}
```
2. [ ] 编译验证
**验证标准**:
- Controller 可以正常编译
- 接口路径正确
---
### T12: 修改 UserInnerController - 添加统一认证接口
**目标**: 添加统一认证查询接口
**文件**: `rui-service/rui-service-user/src/main/java/com/rui/service/user/controller/inner/UserInnerController.java`
**步骤**:
1. [ ] 添加新方法:
```java
@PostMapping("/auth/load")
public Result<JSONObject> loadUser(@RequestBody LoginAccountDTO loginAccount) {
User user;
switch (loginAccount.getAccountType()) {
case PHONE:
user = userService.lambdaQuery()
.eq(User::getPhone, loginAccount.getAccount())
.one();
break;
case EMAIL:
// 如果 User 实体有 email 字段
user = userService.lambdaQuery()
.eq(User::getEmail, loginAccount.getAccount())
.one();
break;
case USERNAME:
default:
user = userService.lambdaQuery()
.eq(User::getUsername, loginAccount.getAccount())
.one();
break;
}
if (user == null) {
return Result.ok(null);
}
// 组装认证信息(复用现有逻辑)
JSONObject info = buildAuthInfo(user);
return Result.ok(info);
}
```
2. [ ] 将原有 `loadByUsername` 标记为 `@Deprecated`
3. [ ] 编译验证
**验证标准**:
- Controller 可以正常编译
- 新接口可以处理不同账号类型
- 旧接口仍然可用但标记为弃用
---
### T13: 修改 UserAuthFeign - 添加统一认证方法
**目标**: 在 Feign 客户端添加新方法
**文件**: `rui-common/rui-common-oauth2/src/main/java/com/rui/common/oauth2/feign/UserAuthFeign.java`
**步骤**:
1. [ ] 添加新方法:
```java
@PostMapping("/auth/load")
Result<JSONObject> loadUser(@RequestBody LoginAccountDTO loginAccount);
```
2. [ ] 将原有 `loadUser` 方法(基于 username)标记为 `@Deprecated`
3. [ ] 编译验证
**验证标准**:
- Feign 接口可以正常编译
- 新方法参数正确
---
### T14: 修改 RemoteUserDetailsService - 支持新接口
**目标**: 修改认证服务以支持新的统一认证接口
**文件**: `rui-common/rui-common-oauth2/src/main/java/com/rui/common/oauth2/service/RemoteUserDetailsService.java`
**步骤**:
1. [ ] 修改 `loadUserByUsername` 方法,改为调用新的 `loadUser` 方法
2. [ ] 或者新增方法 `loadUserByAccount`
3. [ ] 确保兼容性
4. [ ] 编译验证
**验证标准**:
- 认证服务可以正常编译
- 支持用户名和手机号登录
---
### T15: 添加 Redis 缓存
**目标**: 为用户聚合数据添加 Redis 缓存
**文件**: `rui-service/rui-service-user/src/main/java/com/rui/service/user/service/impl/UserServiceImpl.java`
**步骤**:
1. [ ] 注入 `RedisUtil`
2. [ ] 在 `getUserAggregate` 方法中添加缓存逻辑:
```java
public UserAggregateVO getUserAggregate(Long userId) {
String cacheKey = String.format("user:agg:%s:%s", tenantId, userId);
// 尝试从缓存获取
UserAggregateVO cached = redisUtil.getObj(cacheKey, UserAggregateVO.class);
if (cached != null) {
return cached;
}
// 查询数据库...
UserAggregateVO vo = // ... 查询逻辑
// 写入缓存(10分钟)
redisUtil.set(cacheKey, vo, Duration.ofMinutes(10));
return vo;
}
```
3. [ ] 编译验证
**验证标准**:
- 缓存可以正常读写
- TTL 设置正确
---
### T16: 缓存失效
**目标**: 在用户数据变更时清除缓存
**文件**:
- `rui-service/rui-service-user/src/main/java/com/rui/service/user/service/impl/UserDeptServiceImpl.java`
- `rui-service/rui-service-user/src/main/java/com/rui/service/user/service/impl/UserPostServiceImpl.java`
- `rui-service/rui-service-user/src/main/java/com/rui/service/user/service/impl/UserServiceImpl.java`
**步骤**:
1. [ ] 在 `UserDeptServiceImpl` 的 `assignDepts` 和 `setMainDept` 方法中添加缓存清除
2. [ ] 在 `UserPostServiceImpl` 的 `assignPosts` 方法中添加缓存清除
3. [ ] 在 `UserServiceImpl` 的 `update` 方法中添加缓存清除
4. [ ] 编写通用的缓存清除方法
**验证标准**:
- 数据变更后缓存被清除
- 下次查询会重新加载数据
---
### T17: 编写 SQL 升级脚本
**目标**: 创建数据库升级脚本
**文件**: `sql/upgrade-v2.x-add-phone-to-user.sql`
**步骤**:
1. [ ] 创建 SQL 文件
2. [ ] 编写升级脚本:
```sql
-- 升级脚本:将 phone 从 uc_user_detail 迁移到 uc_user
-- 1. 在 uc_user 表添加 phone 字段
ALTER TABLE rui_uc_user
ADD COLUMN phone VARCHAR(20) DEFAULT NULL COMMENT '手机号' AFTER username;
-- 2. 添加唯一索引
ALTER TABLE rui_uc_user
ADD UNIQUE KEY uk_phone (tenant_id, phone);
-- 3. 添加普通索引
ALTER TABLE rui_uc_user
ADD INDEX idx_phone (phone);
-- 4. 迁移数据(如果有)
-- UPDATE rui_uc_user u
-- JOIN rui_uc_user_detail d ON u.id = d.user_id
-- SET u.phone = d.phone
-- WHERE u.phone IS NULL AND d.phone IS NOT NULL;
-- 5. 从 uc_user_detail 移除 phone 字段
ALTER TABLE rui_uc_user_detail
DROP COLUMN phone;
```
3. [ ] 验证 SQL 语法
**验证标准**:
- SQL 语法正确
- 可以在开发环境正常执行
---
### T18: 单元测试
**目标**: 编写单元测试
**文件**:
- `rui-service/rui-service-user/src/test/java/com/rui/service/user/service/UserServiceTest.java`
- `rui-service/rui-service-user/src/test/java/com/rui/service/user/controller/UserControllerTest.java`
**步骤**:
1. [ ] 测试 `getUserAggregate` 方法
2. [ ] 测试 `listUserAggregate` 方法
3. [ ] 测试缓存命中和失效
4. [ ] 测试统一认证接口
5. [ ] 运行测试
**验证标准**:
- 所有测试通过
- 覆盖主要业务场景
---
### T19: 集成测试
**目标**: 进行集成测试
**步骤**:
1. [ ] 启动服务
2. [ ] 测试聚合接口
3. [ ] 测试统一认证接口
4. [ ] 测试缓存
5. [ ] 验证旧接口仍然可用
**验证标准**:
- 所有接口正常响应
- 数据正确
- 缓存有效
---
### T20: 文档更新
**目标**: 更新相关文档
**步骤**:
1. [ ] 更新 API 文档(Swagger
2. [ ] 更新数据库文档
3. [ ] 更新接口文档
**验证标准**:
- 文档与实际代码一致
---
## 3. 实施顺序建议
### 阶段 1:数据库变更(T1, T2, T17
先执行数据库变更,为后续代码修改做准备。
### 阶段 2:基础代码(T3, T4, T5, T6, T7, T8, T9
修改实体、新增 VO/DTO/枚举、修改 Mapper。
### 阶段 3:核心业务(T10, T11, T12, T13, T14
实现聚合查询和统一认证接口。
### 阶段 4:缓存优化(T15, T16
添加缓存和失效机制。
### 阶段 5:测试(T18, T19
编写和运行测试。
### 阶段 6:文档(T20
更新文档。
---
## 4. 风险评估
| 风险 | 概率 | 影响 | 缓解措施 |
|------|------|------|----------|
| 数据库变更失败 | 低 | 高 | 备份数据,先在开发环境测试 |
| 缓存数据不一致 | 中 | 中 | 完善缓存失效机制 |
| 旧接口不兼容 | 低 | 高 | 保留旧接口,标记为弃用 |
| 手机号唯一性冲突 | 中 | 中 | 数据迁移时处理重复数据 |
| 性能问题 | 低 | 中 | 批量查询优化,添加索引 |
---
## 5. 回滚计划
如果实施过程中出现问题,可以按以下顺序回滚:
1. **代码回滚**:使用 git 回滚到上一个版本
2. **数据库回滚**
```sql
-- 移除 uc_user 的 phone 字段
ALTER TABLE rui_uc_user DROP COLUMN phone;
ALTER TABLE rui_uc_user DROP INDEX uk_phone;
ALTER TABLE rui_uc_user DROP INDEX idx_phone;
-- 在 uc_user_detail 添加 phone 字段
ALTER TABLE rui_uc_user_detail
ADD COLUMN phone VARCHAR(20) DEFAULT NULL COMMENT '手机号' AFTER email;
```
---
## 6. 验收标准
- [ ] 所有任务完成
- [ ] 单元测试通过率 100%
- [ ] 集成测试通过
- [ ] 代码审查通过
- [ ] 文档更新完成
- [ ] 数据库变更成功
- [ ] 缓存正常工作
- [ ] 旧接口仍然可用
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,639 @@
# 用户聚合查询设计规格
> **日期**: 2026-06-06
> **状态**: 已批准
> **作者**: AI Assistant
> **相关模块**: rui-service-user
---
## 1. 背景与问题
### 1.1 现状
当前用户数据分散在4张表中:
| 表名 | 说明 |
|------|------|
| `uc_user` | 用户基础信息(用户名、密码、状态等) |
| `uc_user_detail` | 用户详情(昵称、邮箱、手机号等) |
| `uc_user_dept` | 用户部门关联(支持多部门,有主部门标记) |
| `uc_user_post` | 用户岗位关联(支持多岗位) |
### 1.2 问题
- **前端请求过多**:获取完整用户信息需要3个独立请求
- **列表页性能差**:100条用户数据需要 1 + 100 + 100 = 201 个请求
- **数据一致性难保证**:多个请求可能部分失败
- **手机号位置不合理**:手机号在 `uc_user_detail` 表,但短信登录需要频繁查询,应该提升到 `uc_user`
- **认证接口不灵活**:当前 `loadByUsername` 只支持用户名,无法扩展支持手机号、邮箱等多种登录方式
### 1.3 约束条件
- 一个用户通常关联 **1个主部门 + 少量岗位**
- 需要支持 **多租户**tenantId
- 必须 **保持向后兼容**
- 需要 **缓存优化**
- 手机号需要 **唯一约束**(按租户)
---
## 2. 设计目标
1. **减少前端请求**:从3个减少到1个
2. **优化列表页性能**:批量查询,避免 N+1
3. **引入缓存**Redis 缓存用户聚合数据
4. **手机号迁移**:将 `phone``uc_user_detail` 迁移到 `uc_user`
5. **统一认证接口**:支持多种登录方式(用户名、手机号、邮箱等)
6. **保持兼容性**:现有接口不受影响
---
## 3. 技术方案
### 3.1 总体架构
```
┌─────────────┐ ┌──────────────────┐ ┌──────────────┐
│ 前端 │────▶│ UserController │────▶│ UserService │
└─────────────┘ └──────────────────┘ └──────┬───────┘
┌────────────────────────────┼────────────────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Redis Cache │ │ uc_user_dept │ │ uc_user_post │
│ user:agg:{id} │ │ (主部门+关联) │ │ (岗位关联) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
```
### 3.2 核心策略
- **保留关联表**:不改表结构,保持多对多关系的灵活性
- **聚合查询**:后端做 JOIN 或批量 IN 查询,一次性返回
- **两级缓存**
- L1:单用户聚合数据缓存(Redis)
- L2:批量查询结果不缓存(避免缓存过大)
- **缓存失效**:数据变更时主动失效
---
## 4. 数据模型
### 4.1 新增 VO 对象
```java
package com.rui.service.user.vo;
import com.rui.service.user.entity.User;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.List;
/**
* 用户聚合信息(包含部门、岗位)
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(description = "用户聚合信息")
public class UserAggregateVO extends User {
@Schema(description = "部门列表")
private List<UserDeptVO> depts;
@Schema(description = "岗位列表")
private List<UserPostVO> posts;
@Schema(description = "主部门ID")
private Long mainDeptId;
@Schema(description = "主部门名称")
private String mainDeptName;
@Schema(description = "部门编码")
private String deptCode;
@Schema(description = "岗位编码")
private String postCode;
}
```
### 4.2 部门/岗位 VO
```java
@Data
@Schema(description = "用户部门信息")
public class UserDeptVO {
@Schema(description = "用户ID")
private Long userId;
@Schema(description = "部门ID")
private Long deptId;
@Schema(description = "部门编码")
private String deptCode;
@Schema(description = "部门名称")
private String deptName;
@Schema(description = "是否主部门")
private Boolean main;
}
@Data
@Schema(description = "用户岗位信息")
public class UserPostVO {
@Schema(description = "用户ID")
private Long userId;
@Schema(description = "岗位ID")
private Long postId;
@Schema(description = "岗位编码")
private String postCode;
@Schema(description = "岗位名称")
private String postName;
}
```
---
## 5. 接口设计
### 5.1 新增接口
#### 5.1.1 获取用户聚合信息(单用户)
```
GET /user/admin/user/{id}/aggregate
```
**响应示例:**
```json
{
"code": 200,
"msg": "success",
"data": {
"id": 1,
"username": "admin",
"phone": "13800138000",
"userNo": "U001",
"userType": 2,
"status": 1,
"depts": [
{
"deptId": 1,
"deptCode": "TECH",
"deptName": "技术部",
"main": true
},
{
"deptId": 2,
"deptCode": "PROD",
"deptName": "产品部",
"main": false
}
],
"posts": [
{
"postId": 1,
"postCode": "JAVA_DEV",
"postName": "Java开发工程师"
}
],
"mainDeptId": 1,
"mainDeptName": "技术部",
"deptCode": "TECH",
"postCode": "JAVA_DEV"
}
}
```
#### 5.1.2 用户列表(增强版)
复用现有 `/user/admin/user` 列表接口,在响应中增加 `depts``posts` 字段。
**实现方式:**
- 列表查询时,先查询用户基础数据
- 批量查询所有用户的部门和岗位
- 组装到响应中
#### 5.1.3 统一用户认证查询(内部接口)
```
POST /user/inner/auth/load
```
**请求体:**
```json
{
"account": "13800138000",
"loginType": "PHONE"
}
```
**AccountType 枚举:**
```java
public enum AccountType {
USERNAME("用户名"),
PHONE("手机号"),
EMAIL("邮箱");
private final String description;
AccountType(String description) {
this.description = description;
}
}
```
**响应:** 与现有 `loadByUsername` 保持一致,返回用户认证信息
**实现逻辑:**
```java
@PostMapping("/auth/load")
public Result<JSONObject> loadUser(@RequestBody LoginAccountDTO loginAccount) {
User user;
switch (loginAccount.getLoginType()) {
case PHONE:
user = userService.lambdaQuery()
.eq(User::getPhone, loginAccount.getAccount())
.one();
break;
case EMAIL:
user = userService.lambdaQuery()
.eq(User::getEmail, loginAccount.getAccount())
.one();
break;
case USERNAME:
default:
user = userService.lambdaQuery()
.eq(User::getUsername, loginAccount.getAccount())
.one();
break;
}
if (user == null) {
return Result.ok(null);
}
// 组装认证信息(与现有逻辑一致)
JSONObject info = buildAuthInfo(user);
return Result.ok(info);
}
```
### 5.2 现有接口处理
**保留但标记为弃用:**
- `GET /user/inner/auth/loadByUsername/{username}`**@Deprecated**,建议迁移到新的 `/auth/load`
**继续保留的接口:**
- `GET /user/admin/user/{id}` — 用户基础信息
- `GET /user/admin/user-dept/user/{userId}` — 用户部门列表
- `GET /user/admin/user-post/user/{userId}` — 用户岗位列表
- `GET /user/admin/detail` — 用户详情(uc_user_detailphone 字段将移除)
---
## 6. 缓存设计
### 6.1 缓存策略
| 缓存项 | Key 格式 | TTL | 说明 |
|--------|---------|-----|------|
| 用户聚合信息 | `user:agg:{tenantId}:{userId}` | 10分钟 | 单用户完整数据 |
| 用户部门列表 | `user:dept:{tenantId}:{userId}` | 10分钟 | 部门ID列表 |
| 用户岗位列表 | `user:post:{tenantId}:{userId}` | 10分钟 | 岗位ID列表 |
### 6.2 缓存失效
**触发时机:**
- 用户部门变更(assignDepts、setMainDept
- 用户岗位变更(assignPosts
- 用户信息变更(update
- 用户删除
**失效逻辑:**
```java
private void evictUserCache(Long userId) {
Long tenantId = AuthUtil.getTenantId();
redisUtil.del(String.format("user:agg:%s:%s", tenantId, userId));
redisUtil.del(String.format("user:dept:%s:%s", tenantId, userId));
redisUtil.del(String.format("user:post:%s:%s", tenantId, userId));
}
```
### 6.3 缓存穿透防护
- 查询不到数据时,缓存空值(TTL 1分钟)
- 使用布隆过滤器(可选,初期可不用)
---
## 7. 查询逻辑
### 7.1 单用户聚合查询
```java
@Cacheable(value = "user:agg", key = "#tenantId + ':' + #userId")
public UserAggregateVO getUserAggregate(Long userId, Long tenantId) {
// 1. 查询用户基础信息
User user = userMapper.selectById(userId);
if (user == null) {
return null;
}
// 2. 查询部门(带名称)
List<UserDeptVO> depts = userDeptMapper.selectDeptListByUserId(userId);
// 3. 查询岗位(带名称)
List<UserPostVO> posts = userPostMapper.selectPostListByUserId(userId);
// 4. 组装
UserAggregateVO vo = new UserAggregateVO();
BeanUtils.copyProperties(user, vo);
vo.setDepts(depts);
vo.setPosts(posts);
// 6. 提取主部门
depts.stream()
.filter(UserDeptVO::getMain)
.findFirst()
.ifPresent(main -> {
vo.setMainDeptId(main.getDeptId());
vo.setMainDeptName(main.getDeptName());
});
return vo;
}
```
### 7.2 批量列表查询优化
**问题:** 列表页有100条数据,不能每条都查一次数据库
**方案:** 批量 IN 查询
```java
public Page<UserAggregateVO> listUserAggregate(Page<User> page, QueryWrapper<User> query) {
// 1. 查询用户基础数据
Page<User> userPage = userMapper.selectPage(page, query);
List<User> users = userPage.getRecords();
if (users.isEmpty()) {
return new Page<>();
}
// 2. 提取所有用户ID
List<Long> userIds = users.stream()
.map(User::getId)
.collect(Collectors.toList());
// 3. 批量查询部门(一次查询)
Map<Long, List<UserDeptVO>> deptMap = userDeptMapper
.selectDeptListByUserIds(userIds)
.stream()
.collect(Collectors.groupingBy(UserDeptVO::getUserId));
// 4. 批量查询岗位(一次查询)
Map<Long, List<UserPostVO>> postMap = userPostMapper
.selectPostListByUserIds(userIds)
.stream()
.collect(Collectors.groupingBy(UserPostVO::getUserId));
// 5. 组装
List<UserAggregateVO> records = users.stream().map(user -> {
UserAggregateVO vo = new UserAggregateVO();
BeanUtils.copyProperties(user, vo);
vo.setDepts(deptMap.getOrDefault(user.getId(), Collections.emptyList()));
vo.setPosts(postMap.getOrDefault(user.getId(), Collections.emptyList()));
// 提取主部门
vo.getDepts().stream()
.filter(UserDeptVO::getMain)
.findFirst()
.ifPresent(main -> {
vo.setMainDeptId(main.getDeptId());
vo.setMainDeptName(main.getDeptName());
});
return vo;
}).collect(Collectors.toList());
// 7. 构建分页结果
Page<UserAggregateVO> result = new Page<>();
result.setCurrent(userPage.getCurrent());
result.setSize(userPage.getSize());
result.setTotal(userPage.getTotal());
result.setRecords(records);
return result;
}
```
**SQL 示例(批量查询部门):**
```xml
<select id="selectDeptListByUserIds" resultType="com.rui.service.user.vo.UserDeptVO">
SELECT
ud.user_id as userId,
ud.dept_id as deptId,
d.name as deptName,
ud.is_main as main
FROM uc_user_dept ud
INNER JOIN uc_dept d ON ud.dept_id = d.id
WHERE ud.user_id IN
<foreach collection="userIds" item="id" open="(" separator="," close=")">
#{id}
</foreach>
AND ud.deleted = 0
AND d.deleted = 0
</select>
```
---
## 8. 性能优化点
### 8.1 数据库层面
1. **索引优化**:确保 `uc_user_dept.user_id``uc_user_post.user_id` 有索引
2. **批量查询**:使用 IN 代替循环查询
3. **按需加载**:列表页只加载必要的字段
### 8.2 缓存层面
1. **单用户缓存**:详情页使用缓存,10分钟TTL
2. **列表页不缓存**:列表数据变化频繁,直接查数据库
3. **缓存预热**:系统启动时可选择预热(可选)
### 8.3 代码层面
1. **并行查询**:单用户查询时,部门、岗位、详情可并行(CompletableFuture
2. **懒加载**:如果前端不需要详情,可以不加载 `uc_user_detail`
---
## 9. 边界情况处理
### 9.1 用户无部门/岗位
- `depts` 返回空列表 `[]`
- `posts` 返回空列表 `[]`
- `mainDeptId``mainDeptName``null`
### 9.2 缓存穿透
- 用户不存在时,缓存空值(TTL 1分钟)
- 使用 `Optional` 包装返回
### 9.3 缓存雪崩
- TTL 加随机偏移:`10分钟 + random(0, 60)秒`
- 使用互斥锁(可选)
### 9.4 数据更新同步
- 所有更新操作后主动失效缓存
- 使用事务确保数据库和缓存一致性
---
## 10. 兼容性
### 10.1 向后兼容
- 现有接口完全保留
- 新增 `/aggregate` 接口,不影响旧接口
- 前端可以逐步迁移
### 10.2 前端迁移路径
1. **第一阶段**:新增聚合接口,前端详情页切换到新接口
2. **第二阶段**:列表页切换到批量查询
3. **第三阶段**:废弃旧接口(可选)
---
## 11. 安全考虑
1. **权限校验**:复用现有 `@AutoPermission("uc:user")`
2. **数据隔离**:所有查询自动加上 `tenant_id` 条件
3. **敏感信息**:密码字段不返回
---
## 12. 监控与日志
1. **缓存命中率**:监控 `user:agg:*` 的命中情况
2. **查询耗时**:记录批量查询的执行时间
3. **慢查询**:超过100ms的查询记录日志
---
## 13. 风险评估
| 风险 | 概率 | 影响 | 缓解措施 |
|------|------|------|----------|
| 缓存数据不一致 | 中 | 中 | 更新时主动失效缓存 |
| 批量查询性能差 | 低 | 高 | 索引优化 + 分页 |
| 内存占用过高 | 低 | 中 | 控制缓存TTL + 分页大小 |
---
## 14. 附录
### 14.1 涉及文件清单
**新增文件:**
- `rui-service-user/src/main/java/com/rui/service/user/vo/UserAggregateVO.java`
- `rui-service-user/src/main/java/com/rui/service/user/vo/UserDeptVO.java`
- `rui-service-user/src/main/java/com/rui/service/user/vo/UserPostVO.java`
- `rui-service-user/src/main/java/com/rui/service/user/dto/LoginAccountDTO.java`
- `rui-service-user/src/main/java/com/rui/service/user/enums/AccountType.java`
**修改文件:**
- `rui-service-user/src/main/java/com/rui/service/user/entity/User.java`(添加 phone 字段)
- `rui-service-user/src/main/java/com/rui/service/user/entity/UserDetail.java`(移除 phone 字段)
- `rui-service-user/src/main/java/com/rui/service/user/service/IUserService.java`
- `rui-service-user/src/main/java/com/rui/service/user/service/impl/UserServiceImpl.java`
- `rui-service-user/src/main/java/com/rui/service/user/controller/UserController.java`
- `rui-service-user/src/main/java/com/rui/service/user/controller/inner/UserInnerController.java`
- `rui-service-user/src/main/java/com/rui/service/user/mapper/UserDeptMapper.java`
- `rui-service-user/src/main/java/com/rui/service/user/mapper/UserPostMapper.java`
- `rui-service-user/src/main/resources/mapper/UserDeptMapper.xml`
- `rui-service-user/src/main/resources/mapper/UserPostMapper.xml`
- `rui-common/rui-common-oauth2/src/main/java/com/rui/common/oauth2/feign/UserAuthFeign.java`
**SQL 脚本:**
- `sql/upgrade-v2.x-add-phone-to-user.sql`(新增)
### 14.2 数据库变更
#### 14.2.1 uc_user 表添加 phone 字段
```sql
-- 添加 phone 字段
ALTER TABLE rui_uc_user
ADD COLUMN phone VARCHAR(20) DEFAULT NULL COMMENT '手机号' AFTER username;
-- 添加唯一索引(按租户)
ALTER TABLE rui_uc_user
ADD UNIQUE KEY uk_phone (tenant_id, phone);
-- 添加普通索引(用于查询)
ALTER TABLE rui_uc_user
ADD INDEX idx_phone (phone);
```
#### 14.2.2 uc_user_detail 表移除 phone 字段
```sql
-- 迁移数据(如果有)
-- UPDATE rui_uc_user u
-- JOIN rui_uc_user_detail d ON u.id = d.user_id
-- SET u.phone = d.phone
-- WHERE u.phone IS NULL AND d.phone IS NOT NULL;
-- 移除 phone 字段
ALTER TABLE rui_uc_user_detail
DROP COLUMN phone;
```
#### 14.2.3 索引检查
```sql
-- uc_user_dept 表
CREATE INDEX idx_user_dept_user_id ON uc_user_dept(user_id);
CREATE INDEX idx_user_dept_dept_id ON uc_user_dept(dept_id);
-- uc_user_post 表
CREATE INDEX idx_user_post_user_id ON uc_user_post(user_id);
CREATE INDEX idx_user_post_post_id ON uc_user_post(post_id);
```
---
## 15. 决策记录
| 决策 | 选择 | 理由 |
|------|------|------|
| 是否改表结构 | 是 | 手机号是登录凭证,应提升到 uc_user 表 |
| 手机号位置 | uc_user 表 | 便于认证查询,支持唯一约束 |
| 认证接口方式 | POST + JSON + 枚举 | 支持多种登录方式,便于扩展 |
| 列表页是否缓存 | 否 | 数据变化频繁,直接查库更可靠 |
| 单用户查询方式 | 并行查询 | 减少RT,提升用户体验 |
| 缓存失效策略 | 主动失效 | 数据更新时立即失效,保证一致性 |
| 是否返回密码 | 否 | 安全考虑,聚合数据不包含敏感字段 |
@@ -0,0 +1,852 @@
# 多方式登录与第三方登录设计文档
> **日期**: 2026-06-07
> **状态**: 已批准,待实现
> **作者**: AI Assistant
---
## 1. 背景与目标
### 1.1 现状
当前系统仅支持用户名密码登录(`grant_type=password`),且 `PasswordAuthenticationConverter` 只提取 `username` 参数,无法支持手机号、邮箱登录。微信、支付宝、短信登录的 `Converter``Provider` 均为空实现。
### 1.2 目标
1. **扩展密码登录**:支持用户名、手机号、邮箱三种账号类型登录
2. **实现短信登录**:框架结构先行,验证码逻辑后续填充
3. **实现微信登录**:支持微信授权码换取用户信息并自动创建账号
4. **实现支付宝登录**:支持支付宝授权码换取用户信息并自动创建账号
5. **第三方账号管理**:存储 openId/unionId,支持 unionId 优先查询
6. **手机号为主键**:系统以手机号作为用户唯一标识,第三方登录自动创建新用户
7. **字段迁移**:将 `email``uc_user_detail` 迁移到 `uc_user`
---
## 2. 核心设计原则
1. **独立授权模式**:每种登录方式使用独立的 `grant_type`,符合 OAuth2 扩展规范
2. **手机号唯一性**:手机号是系统用户的唯一标识,第三方登录时优先用手机号创建/查找用户
3. **自动创建用户**:第三方登录无手机号时,自动生成 `userNo` 作为用户名,后续用户可自行修改
4. **unionId 优先**:查询第三方用户信息时,优先使用 unionId,其次使用 openId
5. **向后兼容**:保留现有 `password` 模式的 `username` 参数,同时新增 `account` + `accountType` 参数
---
## 3. 架构设计
### 3.1 整体架构
```
前端调用
POST /oauth2/token
┌─────────────────────────────────────┐
│ DelegatingAuthenticationConverter │
│ ┌─────────┐ ┌─────┐ ┌─────────┐ │
│ │ Password│ │ Sms │ │ Wechat │ │
│ │ Converter│ │Converter│ │Converter│ │
│ └─────────┘ └─────┘ └─────────┘ │
│ ┌─────────┐ │
│ │ Alipay │ │
│ │Converter│ │
│ └─────────┘ │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ AuthenticationProvider 链 │
│ ┌─────────┐ ┌─────┐ ┌─────────┐ │
│ │ Password│ │ Sms │ │ Wechat │ │
│ │ Provider│ │Provider│ │Provider│ │
│ └─────────┘ └─────┘ └─────────┘ │
│ ┌─────────┐ │
│ │ Alipay │ │
│ │Provider │ │
│ └─────────┘ │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ 用户查找 / 创建 / 绑定 │
│ • 根据手机号/用户名/邮箱查找用户 │
│ • 第三方登录:调平台API获取用户信息 │
│ • 自动创建新用户(手机号或userNo) │
│ • 记录第三方绑定关系 │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ 生成 OAuth2 Token │
│ Access Token + Refresh Token │
└─────────────────────────────────────┘
```
### 3.2 登录方式对照表
| 登录方式 | grant_type | 必填参数 | 可选参数 | 说明 |
|---------|-----------|---------|---------|------|
| 用户名密码 | `password` | `username`, `password` | - | 兼容现有方式 |
| 手机号密码 | `password` | `account`, `accountType=PHONE`, `password` | - | 扩展方式 |
| 邮箱密码 | `password` | `account`, `accountType=EMAIL`, `password` | - | 扩展方式 |
| 短信验证码 | `sms` | `phone`, `code` | - | 框架先行 |
| 微信登录 | `wechat` | `code` | `phone` | 授权码模式 |
| 支付宝登录 | `alipay` | `code` | `phone` | 授权码模式 |
---
## 4. 数据库设计
### 4.1 新增表:`rui_uc_user_social`
存储用户与第三方平台的绑定关系。
```sql
CREATE TABLE rui_uc_user_social (
id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID',
user_id BIGINT NOT NULL COMMENT '用户ID',
provider VARCHAR(20) NOT NULL COMMENT '平台 wechat/alipay',
union_id VARCHAR(100) DEFAULT NULL COMMENT 'unionId(微信开放平台)',
open_id VARCHAR(100) NOT NULL COMMENT 'openId',
extra JSON DEFAULT NULL COMMENT '扩展信息(昵称、头像等)',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_user_provider (user_id, provider),
UNIQUE KEY uk_provider_openid (provider, open_id),
INDEX idx_union_id (union_id),
INDEX idx_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户第三方账号关联';
```
**字段说明**
- `provider`: 平台标识,`wechat``alipay`
- `union_id`: 微信开放平台统一标识,同一主体下的不同应用 unionId 相同
- `open_id`: 各应用内的唯一标识
- `extra`: JSON 格式,存储第三方平台的额外信息(昵称、头像、性别等)
**索引设计**
- `uk_user_provider`: 一个用户在同一平台只能绑定一个账号
- `uk_provider_openid`: 同一平台的 openId 唯一
- `idx_union_id`: 支持 unionId 查询
### 4.2 修改表:`rui_uc_user`
新增 `email` 字段:
```sql
-- 在 rui_uc_user 表中添加 email 字段
ALTER TABLE rui_uc_user ADD COLUMN email VARCHAR(100) DEFAULT NULL COMMENT '邮箱' AFTER phone;
ALTER TABLE rui_uc_user ADD UNIQUE KEY uk_email (tenant_id, email);
```
修改后的表结构:
```sql
CREATE TABLE rui_uc_user (
id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID 0:系统级',
username VARCHAR(100) NOT NULL COMMENT '用户名',
phone VARCHAR(20) DEFAULT NULL COMMENT '手机号',
email VARCHAR(100) DEFAULT NULL COMMENT '邮箱',
user_no VARCHAR(50) DEFAULT NULL COMMENT '用户编号(短编码,前端展示用)',
password VARCHAR(255) NOT NULL COMMENT '密码(BCrypt加密)',
user_type TINYINT NOT NULL DEFAULT 1 COMMENT '用户类型 1:普通用户 2:管理员 3:系统用户',
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态 0:禁用 1:启用',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除 0:正常 1:已删',
created_by BIGINT DEFAULT NULL COMMENT '创建者ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_by BIGINT DEFAULT NULL COMMENT '更新者ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_username (tenant_id, username),
UNIQUE KEY uk_phone (tenant_id, phone),
UNIQUE KEY uk_email (tenant_id, email),
INDEX idx_tenant (tenant_id),
INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户';
```
### 4.3 修改表:`rui_uc_user_detail`
删除 `email` 字段:
```sql
-- 从 rui_uc_user_detail 表中删除 email 字段
ALTER TABLE rui_uc_user_detail DROP COLUMN email;
```
### 4.4 登录日志扩展
`rui_sys_login_log` 表的 `login_type` 字段已有定义:
- `1`: 密码登录
- `2`: 短信登录
- `3`: 微信登录
- `4`: 支付宝登录
**无需修改**,但需要在代码中确保所有登录方式都正确记录类型。
---
## 5. 核心流程设计
### 5.1 密码登录流程(扩展)
```
前端请求
POST /oauth2/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic {client_credentials}
# 方式1:用户名密码(兼容现有)
grant_type=password
&username=admin
&password=123456
# 方式2:手机号密码(新增)
grant_type=password
&account=13800138000
&accountType=PHONE
&password=123456
# 方式3:邮箱密码(新增)
grant_type=password
&account=user@example.com
&accountType=EMAIL
&password=123456
PasswordAuthenticationConverter
├─ 提取 grant_type=password
├─ 如果有 username → 走兼容模式
└─ 如果有 account + accountType → 走扩展模式
PasswordAuthenticationProvider
├─ 校验客户端支持 password 授权
├─ 构建 UsernamePasswordAuthenticationToken
│ ├─ 兼容模式: username 作为 principal
│ └─ 扩展模式: account 作为 principal
AuthenticationManager
DaoAuthenticationProvider
├─ 调用 RemoteUserDetailsService.loadUserByUsername(username)
│ 或 RemoteUserDetailsService.loadUserByAccount(account, accountType)
RemoteUserDetailsService
├─ USERNAME → userAuthFeign.loadUser(account)
├─ PHONE → userAuthFeign.loadUser({account, PHONE})
└─ EMAIL → userAuthFeign.loadUser({account, EMAIL})
UserInnerController.loadUser(LoginAccountDTO)
├─ 根据 accountType 查询用户
├─ PHONE → lambdaQuery().eq(User::getPhone, account)
├─ EMAIL → lambdaQuery().eq(User::getEmail, account)
└─ USERNAME → lambdaQuery().eq(User::getUsername, account)
返回 UserDetails → 生成 Token
```
### 5.2 短信登录流程
```
前端请求
POST /oauth2/token
grant_type=sms
&phone=13800138000
&code=123456
SmsAuthenticationConverter
├─ 校验 grant_type=sms
├─ 校验 phone 必填
└─ 校验 code 必填
SmsAuthenticationProvider
├─ 校验客户端支持 sms 授权
├─ 从 Redis 获取验证码(key: sms:code:{phone}
├─ 比对验证码
├─ 验证码错误 → 抛出异常
└─ 验证码正确 → 继续
根据 phone 查询用户
├─ 找到 → 生成 Token
└─ 未找到 → 创建新用户
├─ username = phone
├─ phone = phone
├─ password = 随机生成(BCrypt加密)
└─ user_no = 自动生成
生成 OAuth2 Token
```
**注意**:短信验证码发送接口(`POST /sms/send`)本次不实现,只预留框架结构。Redis 中的验证码需要前端开发时手动设置或通过其他方式注入。
### 5.3 微信登录流程
```
前端请求
POST /oauth2/token
grant_type=wechat
&code=wx_auth_code
&phone=13800138000 ← 可选
WechatAuthenticationConverter
├─ 校验 grant_type=wechat
├─ 校验 code 必填
└─ 提取 phone(可选)
WechatAuthenticationProvider
├─ 校验客户端支持 wechat 授权
├─ 调用微信 API 换取 access_token
│ GET https://api.weixin.qq.com/sns/oauth2/access_token
│ ?appid={appid}&secret={secret}&code={code}&grant_type=authorization_code
├─ 获取 openId, unionId, access_token
├─ 根据 unionId 查询 rui_uc_user_social
│ ├─ 找到 → 获取 user_id → 查询用户 → 生成 Token
│ └─ 未找到 → 根据 openId 查询
│ ├─ 找到 → 获取 user_id → 查询用户 → 生成 Token
│ └─ 未找到 → 创建新用户
创建新用户流程
├─ 有 phone 参数
│ ├─ 查询 phone 是否已存在
│ ├─ 存在 → 使用该用户,记录绑定关系
│ └─ 不存在 → 创建新用户
│ ├─ username = phone
│ ├─ phone = phone
│ └─ password = 随机生成
└─ 无 phone 参数
├─ username = 随机生成(如 WX_ + 时间戳)
├─ phone = null
└─ password = 随机生成
记录绑定关系
INSERT INTO rui_uc_user_social
(user_id, provider, union_id, open_id, extra)
VALUES (?, 'wechat', ?, ?, ?)
生成 OAuth2 Token
```
### 5.4 支付宝登录流程
与微信登录类似,区别:
1. 调用支付宝 API`alipay.system.oauth.token` 换取 access_token
2. 调用 `alipay.user.info.share` 获取用户信息
3. 支付宝没有 unionId,使用 userId 作为唯一标识
4. 存储到 `rui_uc_user_social` 时,`union_id` 为 null
---
## 6. 代码结构
### 6.1 新增/修改文件清单
#### rui-common-oauth2 模块
```
rui-common-oauth2/src/main/java/com/rui/common/oauth2/
├── authentication/
│ ├── BaseAuthenticationConverter.java # 已有,无需修改
│ ├── BaseAuthenticationProvider.java # 已有,无需修改
│ ├── password/
│ │ ├── PasswordAuthenticationConverter.java # 修改:支持 accountType
│ │ └── PasswordAuthenticationProvider.java # 已有,无需修改
│ ├── sms/
│ │ ├── SmsAuthenticationConverter.java # 重写:实现短信参数提取
│ │ ├── SmsAuthenticationProvider.java # 重写:实现短信认证逻辑
│ │ └── SmsAuthenticationToken.java # 新增:短信认证令牌
│ ├── weixin/
│ │ ├── WeixinAuthenticationConverter.java # 重写:实现微信参数提取
│ │ ├── WeixinAuthenticationProvider.java # 重写:实现微信认证逻辑
│ │ └── WeixinAuthenticationToken.java # 新增:微信认证令牌
│ └── alipay/
│ ├── AlipayAuthenticationConverter.java # 重写:实现支付宝参数提取
│ ├── AlipayAuthenticationProvider.java # 重写:实现支付宝认证逻辑
│ └── AlipayAuthenticationToken.java # 新增:支付宝认证令牌
├── config/
│ └── OAuth2ServerConfig.java # 修改:注册新的 Converter 和 Provider
└── service/
└── RemoteUserDetailsService.java # 修改:支持 EMAIL 类型
```
#### rui-service-user 模块
```
rui-service-user/src/main/java/com/rui/service/user/
├── entity/
│ ├── User.java # 修改:新增 email 字段
│ ├── UserDetail.java # 修改:删除 email 字段
│ └── UserSocial.java # 新增:第三方账号关联实体
├── mapper/
│ └── UserSocialMapper.java # 新增
├── service/
│ ├── IUserSocialService.java # 新增
│ └── impl/
│ └── UserSocialServiceImpl.java # 新增
├── controller/
│ └── inner/
│ └── UserInnerController.java # 修改:支持 EMAIL 查询
└── dto/
└── LoginAccountDTO.java # 已有,无需修改
```
### 6.2 关键类设计
#### 6.2.1 PasswordAuthenticationConverter(修改)
```java
public class PasswordAuthenticationConverter extends BaseAuthenticationConverter<PasswordAuthenticationToken> {
@Override
public void checkParams(HttpServletRequest request) {
MultiValueMap<String, String> parameters = OAuth2EndpointUtils.getParameters(request);
// 兼容模式:使用 username
String username = parameters.getFirst("username");
if (StringUtils.hasText(username)) {
// 校验 password
String password = parameters.getFirst("password");
if (!StringUtils.hasText(password)) {
OAuth2EndpointUtils.throwError(OAuth2ErrorCodes.INVALID_REQUEST, "password", ...);
}
return;
}
// 扩展模式:使用 account + accountType
String account = parameters.getFirst("account");
if (!StringUtils.hasText(account)) {
OAuth2EndpointUtils.throwError(OAuth2ErrorCodes.INVALID_REQUEST, "account", ...);
}
String accountType = parameters.getFirst("accountType");
if (!StringUtils.hasText(accountType)) {
OAuth2EndpointUtils.throwError(OAuth2ErrorCodes.INVALID_REQUEST, "accountType", ...);
}
// 校验 password
String password = parameters.getFirst("password");
if (!StringUtils.hasText(password)) {
OAuth2EndpointUtils.throwError(OAuth2ErrorCodes.INVALID_REQUEST, "password", ...);
}
}
@Override
public PasswordAuthenticationToken buildToken(...) {
// 将 accountType 放入 additionalParameters
// 供 Provider 使用
return new PasswordAuthenticationToken(...);
}
}
```
#### 6.2.2 WechatAuthenticationProvider(重写)
```java
@Slf4j
public class WechatAuthenticationProvider extends BaseAuthenticationProvider<WechatAuthenticationToken> {
private final WechatApiClient wechatApiClient;
private final UserSocialService userSocialService;
private final UserService userService;
@Override
public UsernamePasswordAuthenticationToken buildToken(Map<String, Object> reqParameters) {
String code = (String) reqParameters.get("code");
String phone = (String) reqParameters.get("phone");
// 调用微信 API 获取 openId, unionId
WechatTokenResponse wxResponse = wechatApiClient.getAccessToken(code);
String openId = wxResponse.getOpenid();
String unionId = wxResponse.getUnionid();
// 查找或创建用户
User user = findOrCreateUser(openId, unionId, phone);
// 构建认证令牌
return new UsernamePasswordAuthenticationToken(user.getUsername(), null);
}
private User findOrCreateUser(String openId, String unionId, String phone) {
// 1. 根据 unionId 查找
if (StringUtils.hasText(unionId)) {
UserSocial social = userSocialService.findByUnionId(unionId);
if (social != null) {
return userService.getById(social.getUserId());
}
}
// 2. 根据 openId 查找
UserSocial social = userSocialService.findByOpenId("wechat", openId);
if (social != null) {
return userService.getById(social.getUserId());
}
// 3. 创建新用户
User user = new User();
if (StringUtils.hasText(phone)) {
// 检查手机号是否已存在
User existUser = userService.findByPhone(phone);
if (existUser != null) {
user = existUser;
} else {
user.setUsername(phone);
user.setPhone(phone);
user.setPassword(generateRandomPassword());
userService.save(user);
}
} else {
// 无手机号,生成随机用户名
user.setUsername(generateRandomUsername());
user.setPassword(generateRandomPassword());
userService.save(user);
}
// 4. 记录绑定关系
UserSocial newSocial = new UserSocial();
newSocial.setUserId(user.getId());
newSocial.setProvider("wechat");
newSocial.setUnionId(unionId);
newSocial.setOpenId(openId);
userSocialService.save(newSocial);
return user;
}
}
```
#### 6.2.3 UserSocial 实体
```java
@Data
@TableName(value = "uc_user_social", keepGlobalPrefix = true)
public class UserSocial extends BaseEntity {
@Schema(description = "用户ID")
private Long userId;
@Schema(description = "平台 wechat/alipay")
private String provider;
@Schema(description = "unionId")
private String unionId;
@Schema(description = "openId")
private String openId;
@Schema(description = "扩展信息")
private String extra;
}
```
---
## 7. API 接口设计
### 7.1 密码登录
```http
###
POST /oauth2/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic c3ByaW5nLWJvb3Qtc3ByaW5nLWJvb3Qtc3ByaW5nLWJvb3Q6
grant_type=password
&username=admin
&password=123456
&scope=server
###
POST /oauth2/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic c3ByaW5nLWJvb3Qtc3ByaW5nLWJvb3Qtc3ByaW5nLWJvb3Q6
grant_type=password
&account=13800138000
&accountType=PHONE
&password=123456
&scope=server
###
POST /oauth2/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic c3ByaW5nLWJvb3Qtc3ByaW5nLWJvb3Qtc3ByaW5nLWJvb3Q6
grant_type=password
&account=user@example.com
&accountType=EMAIL
&password=123456
&scope=server
```
### 7.2 短信登录
```http
###
POST /oauth2/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic c3ByaW5nLWJvb3Qtc3ByaW5nLWJvb3Qtc3ByaW5nLWJvb3Q6
grant_type=sms
&phone=13800138000
&code=123456
&scope=server
```
### 7.3 微信登录
```http
###
POST /oauth2/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic c3ByaW5nLWJvb3Qtc3ByaW5nLWJvb3Qtc3ByaW5nLWJvb3Q6
grant_type=wechat
&code=wx_auth_code_xxx
&phone=13800138000
&scope=server
```
### 7.4 支付宝登录
```http
###
POST /oauth2/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic c3ByaW5nLWJvb3Qtc3ByaW5nLWJvb3Qtc3ByaW5nLWJvb3Q6
grant_type=alipay
&code=alipay_auth_code_xxx
&phone=13800138000
&scope=server
```
### 7.5 响应格式
所有登录方式返回统一的 OAuth2 Token 响应:
```json
{
"access_token": "abc123...",
"token_type": "Bearer",
"expires_in": 7200,
"refresh_token": "def456...",
"scope": "server"
}
```
---
## 8. 配置设计
### 8.1 微信配置
```yaml
# Nacos 配置:rui-service-auth.yaml 或 rui-common.yaml
social:
wechat:
app-id: wx1234567890abcdef
app-secret: your-app-secret
# 可选:token 刷新地址
token-url: https://api.weixin.qq.com/sns/oauth2/access_token
# 可选:用户信息地址
user-info-url: https://api.weixin.qq.com/sns/userinfo
```
### 8.2 支付宝配置
```yaml
social:
alipay:
app-id: 2024XXXXXXXXXXXX
private-key: your-private-key
public-key: alipay-public-key
# 可选:网关地址
gateway-url: https://openapi.alipay.com/gateway.do
```
### 8.3 客户端授权类型配置
修改 `sys_oauth_client` 表,为客户端添加新的授权类型:
```sql
-- 更新默认客户端,支持所有登录方式
UPDATE sys_oauth_client
SET grant_types = 'password,refresh_token,client_credentials,sms,wechat,alipay'
WHERE client_id = 'rui-client';
```
---
## 9. 安全设计
### 9.1 验证码安全
- 短信验证码有效期:5 分钟
- 验证码错误次数限制:5 次/小时
- 验证码存储:Rediskey = `sms:code:{phone}`
### 9.2 第三方登录安全
- 微信/支付宝授权码只能使用一次
- 授权码有效期:5 分钟(由微信/支付宝平台控制)
- 后端必须校验授权码的真实性(调平台 API)
### 9.3 密码安全
- 第三方登录自动创建的用户,生成随机密码(32 位随机字符串)
- 用户首次设置密码时,要求提供原密码或通过手机验证码验证
---
## 10. 错误码设计
| 错误码 | 描述 | 场景 |
|-------|------|------|
| `invalid_request` | 请求参数错误 | 缺少必填参数、参数格式错误 |
| `invalid_grant` | 授权失败 | 验证码错误、授权码无效 |
| `invalid_client` | 客户端认证失败 | 客户端不存在、授权类型不支持 |
| `unauthorized_client` | 客户端未授权 | 客户端不支持该授权类型 |
| `server_error` | 服务器内部错误 | 调用第三方 API 失败 |
---
## 11. 测试策略
### 11.1 单元测试
- `PasswordAuthenticationConverterTest`: 测试参数提取和校验
- `SmsAuthenticationProviderTest`: 测试验证码校验逻辑
- `WechatAuthenticationProviderTest`: Mock 微信 API,测试用户创建流程
### 11.2 集成测试
- 使用 H2 内存数据库测试完整登录流程
- 使用 WireMock 模拟微信/支付宝 API
### 11.3 手动测试清单
- [ ] 用户名密码登录(兼容测试)
- [ ] 手机号密码登录
- [ ] 邮箱密码登录
- [ ] 短信验证码登录(使用 Redis 手动设置验证码)
- [ ] 微信登录(使用测试授权码)
- [ ] 支付宝登录(使用测试授权码)
- [ ] 第三方登录后绑定手机号
- [ ] 同一微信不同手机号创建不同用户
- [ ] unionId 优先查询验证
---
## 12. 风险与回滚
### 12.1 风险
| 风险 | 影响 | 缓解措施 |
|------|------|---------|
| 微信/支付宝 API 变更 | 登录失败 | 封装 API 调用,便于快速适配 |
| 手机号重复 | 数据不一致 | 数据库唯一索引 + 代码校验 |
| 性能问题 | 登录慢 | Redis 缓存 + 异步记录日志 |
### 12.2 回滚方案
- 数据库变更:保留原字段,新增字段不影响现有数据
- 代码回滚:新授权模式独立实现,不影响现有 `password` 模式
- 配置回滚:移除新 grant_type 即可禁用
---
## 13. 后续优化
1. **短信服务商接入**:实现真实的短信发送功能
2. **社交账号解绑**:提供 API 解除第三方绑定
3. **多账号合并**:支持将多个第三方账号合并到同一用户
4. **登录设备管理**:记录登录设备,支持远程登出
5. **扫码登录**:支持微信扫码登录 PC 端
---
## 14. 附录
### 14.1 登录类型枚举
```java
public enum LoginType {
PASSWORD(1, "密码登录"),
SMS(2, "短信登录"),
WECHAT(3, "微信登录"),
ALIPAY(4, "支付宝登录");
private final int code;
private final String description;
LoginType(int code, String description) {
this.code = code;
this.description = description;
}
}
```
### 14.2 账号类型枚举
```java
public enum AccountType {
USERNAME("用户名"),
PHONE("手机号"),
EMAIL("邮箱");
private final String description;
AccountType(String description) {
this.description = description;
}
}
```
### 14.3 第三方平台枚举
```java
public enum SocialProvider {
WECHAT("微信"),
ALIPAY("支付宝");
private final String description;
SocialProvider(String description) {
this.description = description;
}
}
```
---
**文档结束**
@@ -0,0 +1,253 @@
# 第三方应用管理(SysApp)设计文档
> **日期**: 2026-06-07
> **状态**: 已批准,待实现
> **作者**: AI Assistant
---
## 1. 背景与目标
### 1.1 现状
`rui-common-core` 中已有 `AppProperties` 通用第三方应用 POJO(含 appId/appSecret/appKey/privateKey/publicKey/redirectUri)。
`rui-common-oauth2` 通过 `@Bean + @ConfigurationProperties` 读取 `thirdparty.wechat.*` / `thirdparty.alipay.*` 配置,固定为系统级配置。
**问题**
- 普通租户无法管理自己的第三方应用凭证
- 配置硬编码在 Nacos,修改需要运维介入
- 字段不够用(缺支付平台字段、AES key、证书文件等)
- 无法区分"平台默认配置"和"租户自配"
### 1.2 目标
1.`rui-service-system` 增加 `SysApp` 实体 + CRUD + 内部接口
2. 支持多租户:每条记录区分 `owner_type=PLATFORM`(平台默认)或 `TENANT`(租户自配)
3. 凭证字段完整:覆盖社交登录 + 第三方支付 + AES 对称加密 + 证书文件
4. `OAuth2ServerConfig` 改为运行时从 `SysApp` 拉凭证,Redis 缓存 30min**用 `appId` 作为唯一标识**(来自请求头 `X-App-Id`
---
## 2. 核心设计原则
1. **多租户隔离**:通过 `owner_type` + `tenant_id` 双重区分
2. **凭证集中管理**:一个 `SysApp` 记录 = 一个第三方应用在本系统的接入凭证
3. **缓存优先**:高频读取走 Redis,CRUD 操作失效缓存
4. **简单文本用列 / 多证书用 JSON**:简单凭证(app_id、app_secret、app_key、aes_key)直接用 VARCHAR 列;多证书场景(如支付宝 p12 包含 private_key+public_key+证书链)用 JSON 数组存,每项含 `name/path/password`
5. **兼容现有 `AppProperties`**`AppProperties` 仍作为通用 POJO 留在 `rui-common-core`,但实际数据从 DB 加载后映射到 `AppProperties`(简单字段直接映射;`certificates` 数组由调用方单独处理)
6. **加密占位**:预留 `is_encrypted` 字段,暂不实现 AES 加密逻辑
---
## 3. 数据库设计
### 3.1 表 `sys_app`
```sql
CREATE TABLE sys_app (
id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID 0:系统级',
owner_type VARCHAR(20) NOT NULL COMMENT '所有者类型 PLATFORM/TENANT',
platform VARCHAR(50) NOT NULL COMMENT '平台编码 wechat/alipay/stripe',
name VARCHAR(100) NOT NULL COMMENT '管理用名称',
-- 凭证:app_id / app_secret / app_key
app_id VARCHAR(200) DEFAULT NULL COMMENT '应用ID',
app_secret VARCHAR(500) DEFAULT NULL COMMENT '应用密钥',
app_key VARCHAR(200) DEFAULT NULL COMMENT '应用Key(部分平台如支付宝)',
-- 多证书:支付宝 p12 等含 private_key+public_key+证书链的复合证书
-- 每项:{name, path, password}path 存对象存储相对路径
certificates JSON DEFAULT NULL COMMENT '多证书列表(p12等复合证书)',
-- 应用自定义 AES key
aes_key VARCHAR(100) DEFAULT NULL COMMENT '应用AES对称密钥(16/24/32字节)',
-- 通用
redirect_uri VARCHAR(500) DEFAULT NULL COMMENT 'OAuth2授权回调地址',
-- 支付平台专用
merchant_id VARCHAR(100) DEFAULT NULL COMMENT '商户号',
sign_type VARCHAR(20) DEFAULT NULL COMMENT '签名方式 RSA2/MD5/HMAC',
notify_url VARCHAR(500) DEFAULT NULL COMMENT '支付回调URL',
api_base VARCHAR(500) DEFAULT NULL COMMENT 'API根地址',
is_sandbox TINYINT NOT NULL DEFAULT 0 COMMENT '是否沙箱环境 0:否 1:是',
extra JSON DEFAULT NULL COMMENT '扩展字段',
-- 状态与审计
is_encrypted TINYINT NOT NULL DEFAULT 0 COMMENT '预留:是否加密 0:否 1:是(暂不实现)',
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态 0:禁用 1:启用',
description VARCHAR(500) DEFAULT NULL COMMENT '备注',
sort_no INT NOT NULL DEFAULT 0 COMMENT '排序号',
created_by BIGINT DEFAULT NULL COMMENT '创建者ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_by BIGINT DEFAULT NULL COMMENT '更新者ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_tenant_owner_platform (tenant_id, owner_type, platform, status),
UNIQUE KEY uk_app_id (app_id),
INDEX idx_tenant (tenant_id),
INDEX idx_platform (platform),
INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='第三方应用集成';
```
> 注:`status` 参与唯一约束,避免"软删除"后还能插同 key。逻辑删除在 `BaseEntity` 里有 `deleted` 字段。
### 3.2 枚举 `SysAppOwnerType`
| 值 | 说明 |
|---|---|
| `PLATFORM` | 平台默认(`tenant_id=0`),所有租户可继承使用 |
| `TENANT` | 租户自配,覆盖对应 PLATFORM 默认 |
---
## 4. Java 包结构
```
com.rui.service.system/
├── entity/
│ ├── SysApp.java
│ └── SysAppOwnerType.java # 枚举 PLATFORM/TENANT
├── mapper/
│ └── SysAppMapper.java
├── service/
│ ├── ISysAppService.java
│ └── impl/SysAppServiceImpl.java
├── dto/
│ ├── SysAppDTO.java # 详情响应(app_secret 脱敏为 ******
│ ├── SysAppSaveDTO.java # 创建/更新
│ └── SysAppQueryDTO.java # 分页查询
├── vo/
│ └── AppCredentialsVO.java # 给 oauth2 用的精简视图(仅凭证字段)
└── controller/
├── SysAppController.java # /system/app/** (管理后台 CRUD
└── inner/
└── SysAppInnerController.java # /system/inner/app/** oauth2 Feign 调用)
```
`rui-common-oauth2` 增加:
```
com.rui.common.oauth2.feign/
└── SysAppFeign.java # @FeignClient 调用 system
com.rui.common.oauth2.cache/
└── AppCredentialsCache.java # Redis 包装,30min TTL
```
`rui-common-core` 保持 `AppProperties` 不变(POJO 通用载体)。
---
## 5. 关键流程
### 5.1 OAuth2 登录运行时获取凭证
```
1. POST /oauth2/token?grant_type=wechat&code=xxx
Header: X-App-Id: wx1234567890
2. authorizationServerFilterChain 被调用
3. 从请求头 X-App-Id 取 appId
4. AppCredentialsCache.get(appId)
├─ HIT → 直接返回
└─ MISS → Feign: GET /system/inner/app/getCredentials?appId={appId}
SysAppInnerController 查 sys_app
- WHERE app_id = {appId} AND status = 1
app_id 字段已加 UNIQUE 约束)
- 找不到 → 返回 404
把 SysApp 转 AppCredentialsVO 返回
oauth2 端写 Redis: SET app:creds:{appId} = json EX 1800
5. 拿到 AppCredentialsVO → 构造 WechatApiClient(appId, appSecret) 等
6. 走完登录流程,返回 token
```
### 5.2 租户管理自己的 SysApp
```
1. 租户管理员登录管理后台
2. POST /system/app Body: SysAppSaveDTO
- owner_type=TENANT
- platform=wechat
- app_id = "wx9999999"
- tenant_id = 当前用户 tenantId
- 业务校验:uk_tenant_owner_platform + uk_app_id 唯一性
3. 写 DB
4. 删缓存:DEL app:creds:{app_id}
5. 返回成功
```
### 5.3 超管配置 PLATFORM 默认
```
1. 超管登录管理后台(tenant_id=0
2. POST /system/app
- owner_type=PLATFORM
- platform=wechat
- app_id = "wx1234567"
- tenant_id=0
3. 删缓存:DEL app:creds:wx1234567
4. 所有未自配(uk_app_id 不冲突)的租户下次登录会用 appId=wx1234567 拉这条
```
---
## 6. 缓存策略
- **Key**: `app:creds:{appId}`(用 `app_id` 唯一标识,不用 platform/tenantId 因为多租户都叫 platform=wechat 会冲突)
- **TTL**: 30 分钟(1800 秒)
- **失效时机**
- `SysApp` CRUD 写操作完成后
- `SysApp` 启/禁用操作后
- 超管强制刷新(可选接口)
- **防穿透**:缓存空对象 5 分钟(针对不存在的 appId)
- **序列化**:用 fastjson2 序列化 `AppCredentialsVO`
---
## 7. OAuth2 端改造
### 7.1 改造点
| 文件 | 改动 |
|---|---|
| `OAuth2ServerConfig` | 删 `@Bean @ConfigurationProperties` 声明 wechat/alipay AppProperties |
| 同上 | 注入 `SysAppFeign` + `AppCredentialsCache` |
| `authorizationServerFilterChain` | 从 `request.getHeader("X-App-Id")` 拿 appId,调 `appCredentialsCache.get(appId)` 拿凭证 |
### 7.2 兼容与过渡
- 旧的 `thirdparty.*` Nacos 配置可以保留一段过渡期,但不读取
- `AppProperties` 仍可作为"应用启动时的兜底",但 OAuth2 登录链路不再使用
---
## 8. 安全考虑
1. **脱敏返回**`SysAppDTO``app_secret` 返回 `******`,详情接口需要 `*:*:app:detail` 权限
2. **审计日志**:所有 CRUD 写操作记录操作人
3. **加密占位**`is_encrypted` 字段保留,后续接入 AES 时只改 service 层
4. **租户越权防护**:CRUD 接口根据当前用户 tenantId 过滤;非超管只能操作 `tenant_id=current` 的记录
---
## 9. 边界与不做
- **不做**:AES 加密实现(仅预留 `is_encrypted` 字段)
- **不做**SysApp 导入导出
- **不做**:SysApp 版本控制/变更历史(仅靠 `updated_by/at`
- **不做**:多语言 i18n(所有界面文案中文)
- **不涉及**`rui-service-user` 模块(user 表/用户登录管理不在本次范围)
---
## 10. 验收标准
1. 超管能创建 `owner_type=PLATFORM` 的 wechat 默认配置
2. 租户能创建 `owner_type=TENANT` 的 wechat 自配(覆盖默认)
3. CRUD 接口都通;`uk_tenant_owner_platform` 唯一约束生效
4. `SysAppInnerController.getCredentials` 能被 oauth2 Feign 调用
5. OAuth2 登录时凭证从缓存读,CRUD 后缓存被清
6. 不存在的 tenantId 第二次调用不会再打 DB(空对象缓存)
7. 编译通过 21 个模块 BUILD SUCCESS