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 # 工单处理流程
|
||||
├── gitea-api.md # Gitea API 使用指南
|
||||
├── menu-config.md # 菜单配置规范
|
||||
└── commit-standards.md # 提交规范
|
||||
├── commit-standards.md # 提交规范
|
||||
└── sql-deploy.md # SQL 变更后置流程(推本地库 + 菜单 + 前端工单)
|
||||
```
|
||||
|
||||
## 使用方式
|
||||
|
||||
@@ -5,3 +5,23 @@
|
||||
# 服务端口:9301(认证中心,所有服务依赖)
|
||||
server:
|
||||
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