Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4540af71ae | |||
| eba4f07832 | |||
| 3395f69b42 | |||
| 22889afedc | |||
| c576053ab6 | |||
| 33690fe80b | |||
| ae78e0f673 | |||
| 856e16beff | |||
| 6df6e7ad0c | |||
| 365aa49cbd | |||
| 792b10bd34 | |||
| 2249b3649a | |||
| b3c66245e9 | |||
| eb99ae43cb | |||
| c69c34ff25 | |||
| 938302c164 | |||
| f1f4440be2 | |||
| 47ef4c6938 | |||
| 00c77529c5 | |||
| 23823074f6 | |||
| 84b3bb601e | |||
| 3c618b57bb | |||
| 9d0cffa86e | |||
| a4767ee3d0 | |||
| 3c2fa877a6 | |||
| de78c21799 | |||
| a8c164459a |
+2
-1
@@ -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:}
|
||||||
|
|||||||
@@ -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 | 新增 DTO:LoginAccountDTO | 高 | 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_detail,phone 字段将移除)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 次/小时
|
||||||
|
- 验证码存储:Redis,key = `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
|
||||||
Reference in New Issue
Block a user