完善项目配置和测试用例

新增内容:
1. 添加 AGENTS.md 和 CLAUDE.md AI 助手配置文件
2. 添加安全修复说明文档 (doc/security-fixes.md)
3. 新增单元测试用例:
   - fund-common: TenantContextHolderTest, UserContextHolderTest, PageResultTest, ResultTest
   - fund-sys: AuthServiceImplTest, RoleServiceImplTest, TenantServiceImplTest

修改内容:
1. 数据库初始化脚本更新 (fund_sys_init.sql)
2. 前端依赖更新 (package.json)
3. 登录和密码管理功能优化:
   - 管理后台和移动端登录页面
   - 密码修改功能
4. 租户上下文处理优化 (TenantLineHandlerImpl)
5. 网关过滤器增强:
   - TenantGatewayFilter 租户过滤
   - TokenAuthFilter 认证过滤
6. Controller 层代码优化
7. DTO 和 Service 层代码改进

技术改进:
- 密码加密方式从 BCrypt 改为 MD5(前后端一致)
- 登录验证流程优化,支持多租户
- 增加日志输出便于调试
- 代码规范性和可维护性提升
This commit is contained in:
zhangjf 2026-03-01 19:06:42 +08:00
parent 645056eaf0
commit 455a20c1df
35 changed files with 1888 additions and 144 deletions

33
AGENTS.md Normal file
View File

@ -0,0 +1,33 @@
# Repository Guidelines
## 项目结构与模块组织
- 后端为 Maven 多模块 Spring Boot 工程。核心服务位于 `fund-*` 模块(如 `fund-sys``fund-cust``fund-proj``fund-gateway`),共享代码在 `fund-common`
- 前端为独立 Vite 项目:管理端在 `fund-admin`,移动端 H5 在 `fund-mobile`
- 运维与打包相关内容在 `scripts/``deploy/``docker/``assembly/`。前端构建先生成各模块 `dist/`,再通过 `scripts/build-frontend.sh` 复制到 `deploy/`;后端构建产物也统一放置在 `deploy/`
## 构建、测试与本地开发命令
- `mvn -q -DskipTests package`: 在仓库根目录构建所有后端模块。
- `mvn test`: 运行后端单元测试(依赖 Spring Boot Test
- `cd fund-admin && npm install && npm run dev`: 本地启动管理端。
- `cd fund-mobile && npm install && npm run dev`: 本地启动移动端。
- `./scripts/build-frontend.sh [admin|mobile]`: 构建单个或全部前端。
- `./scripts/docker-build.sh build-all`: 构建后端服务 Docker 镜像。
- `docker-compose up -d`: 启动 `docker-compose.yml` 中定义的本地依赖/服务。
## 编码风格与命名规范
- Java 采用常规 Spring Boot 风格,包名格式如 `com.fundplatform.{模块}.{层级}`
- 后端类命名按职责清晰命名(如 `CustomerController``CustomerServiceImpl`)。
- 前端 TypeScript/Vue 没有统一的格式化配置,保持与现有代码风格一致。
## 测试指南
- 后端测试放在各模块 `src/test/java`,通过 `mvn test` 运行。
- 前端 `package.json` 未配置测试脚本,若新增测试需同时引入对应测试工具。
## 提交与拉取请求规范
- Git 历史显示轻量 Conventional Commits 风格,建议使用 `feat:``fix:` 等前缀,描述简洁明确。
- PR 需说明范围;有需求单则关联;涉及 UI`fund-admin``fund-mobile`)请附截图。
- 涉及配置或部署脚本的变更(`scripts/``deploy/``docker/`)需显式说明。
## 配置与运行说明
- 服务启动脚本读取 `conf/env.properties``conf/service.properties`(见 `scripts/start.sh`)。
- 根 `pom.xml` 指定 Java 版本为 `21`

165
CLAUDE.md Normal file
View File

@ -0,0 +1,165 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目概述
**资金服务平台FundPlatform** — 多租户微服务架构的项目资金管理系统。
- **技术栈**: Java 21 + Spring Boot 3.2.0 + Spring Cloud Alibaba 2023.0.0.0 + MyBatis-Plus 3.5.5 + MySQL 8.0 + Redis
- **前端**: Vue 3 + TypeScript管理后台用 Element Plus移动端用 Vant
## 常用命令
### 后端构建
```bash
# 构建所有模块(跳过测试)
mvn -q -DskipTests package
# 构建单个模块
mvn -q -DskipTests package -pl fund-sys -am
# 运行测试
mvn test
# 打包成 tar.gz 部署包(使用 Assembly
mvn -q -DskipTests package -pl fund-sys -am -P assembly
```
### 前端开发
```bash
# 管理后台
cd fund-admin && npm install && npm run dev
# 移动端
cd fund-mobile && npm install && npm run dev
# 打包前端
./scripts/build-frontend.sh admin # 或 mobile
```
### 本地环境
```bash
# 启动基础设施MySQL、Redis、Nacos、Prometheus、Grafana
docker-compose up -d
# 停止并清理
docker-compose down -v
```
## 模块结构
| 模块 | 端口 | 职责 |
|------|------|------|
| fund-common | — | 公共工具Token、Redis、多租户上下文、统一响应 |
| fund-gateway | 8000 | API网关路由、认证、限流 |
| fund-sys | 8100 | 系统服务:用户、角色、菜单、部门、租户管理 |
| fund-cust | 8200 | 客户管理 |
| fund-proj | 8300 | 项目与合同管理 |
| fund-req | 8400 | 需求工单 |
| fund-exp | 8500 | 支出管理 |
| fund-receipt | 8600 | 收款管理 |
| fund-report | 8700 | 报表统计 |
| fund-file | 8800 | 文件存储腾讯云COS |
## 代码架构
### 分层约定
每个业务服务模块遵循统一分层:
```
com.fundplatform.{module}/
├── controller/ REST API返回 Result<T>
├── service/ 业务逻辑
│ └── impl/
├── data/
│ ├── entity/ MyBatis-Plus 实体(对应数据库表)
│ ├── mapper/ Mapper 接口
│ └── service/ 数据层服务IService
├── dto/ 请求参数对象
├── vo/ 响应视图对象
├── feign/ Feign 客户端(调用其他服务)
├── aop/ 切面(操作日志等)
└── config/ 模块配置
```
### 统一响应
所有接口使用 `fund-common` 中的 `Result<T>``PageResult<T>`
```java
Result.success(data)
Result.success(data, "message")
Result.error("message")
```
### 基础实体
实体类继承 `BaseEntity`(包含 `id``tenantId``createdBy``createdTime``updatedBy``updatedTime``deleted`)。
### 认证机制
- Token 基于 UUID存储在 RedisKey: `auth:token:{token}`有效期24小时
- 密码使用 MD5 加密(`Md5Util`
- 请求头 `Authorization` 携带 Token`X-Tenant-Id` 携带租户ID
## 多租户架构
核心设计:**一库多租户 + VIP专属实例混合模式**
1. **数据隔离**:所有业务表含 `tenant_id` 字段MyBatis-Plus 租户插件自动注入 SQL 条件
2. **上下文传递**`TenantContextHolder` 存储当前租户,通过 HTTP Header 传递Feign 拦截器自动转发
3. **VIP专属实例**Nacos 元数据 `tenant-id` 标记服务实例,`TenantAwareLoadBalancer` 将 VIP 租户路由到专属实例
```yaml
# Nacos 元数据配置(区分共享/专属实例)
spring.cloud.nacos.discovery.metadata:
tenant-id: ${TENANT_ID:} # 空=共享实例,有值=VIP专属
```
## 打包与部署
### 部署包结构
Maven Assembly 打包输出 tar.gz解压后
```
bin/ 启动脚本start.sh
conf/ 配置文件application.yml、bootstrap.yml、logback-spring.xml、env.properties
lib/ 所有 JAR依赖 + 应用本身)
```
> **注意**:根 pom.xml 禁用了 Spring Boot repackage各服务的 fat jar 由 Assembly 统一管理。
### 数据库初始化
SQL 脚本位于 `doc/sql/`,执行顺序:
1. `01_create_user.sql` — 创建 MySQL 用户fundsp / fundSP@123
2. `02_grant_user.sql` — 授权
3. `fund_*_init.sql` — 各模块建表及初始数据
### 环境变量
关键变量在根目录 `.env` 文件中配置MySQL、Redis、Nacos 连接信息、腾讯云COS凭证
## 服务间通信
使用 OpenFeign 声明式调用,`FeignChainInterceptor` 自动传递 `Authorization``X-Tenant-Id``X-Trace-Id` 等 Header。
```java
@FeignClient(name = "fund-sys")
public interface SysUserFeign {
@GetMapping("/sys/user/{id}")
Result<UserDto> getUserById(@PathVariable Long id);
}
```
## 注意事项
- **fund-gateway** 使用 WebFlux不能引入 `spring-boot-starter-web`,须排除 fund-common 中的 web 自动配置
- **租户忽略**:特殊场景(如登录、租户管理)需用 `TenantIgnoreHelper` 标记绕过租户过滤
- **日志**:使用 Logback + Logstash Encoder日志格式为 JSON适配 ELK 收集

168
doc/security-fixes.md Normal file
View File

@ -0,0 +1,168 @@
# 租户隔离安全漏洞修复记录
**修复日期**: 2026-03-01
**修复分支**: master
**修复范围**: 多租户数据隔离机制
---
## 漏洞一:网关未覆盖客户端伪造的 X-Tenant-Id高危
### 漏洞描述
`TokenAuthFilter` 在 Token 验证通过后,使用 Spring WebFlux 的 `request.mutate().header()` 将 Token 中的 tenantId 写入请求头。然而 WebFlux 的 `header()` 方法是**追加**而非替换语义,若客户端预先在请求中设置了 `X-Tenant-Id`,下游服务从 `getFirst()` 取到的仍然是客户端伪造的值。攻击者可以通过在请求中携带任意 `X-Tenant-Id` 来访问其他租户的数据。
### 攻击场景
```
攻击者发送请求:
Authorization: Bearer <合法Token属于租户A>
X-Tenant-Id: 999 ← 伪造的租户ID
```
原始代码中,下游 `ContextInterceptor` 从请求头取到的 `X-Tenant-Id` 可能是 999导致 `TenantContextHolder` 被设置为 999进而 MyBatis-Plus 租户插件以 tenant_id=999 过滤 SQL越权访问其他租户数据。
### 修复方案
`TokenAuthFilter` 中先移除客户端传来的 `X-Tenant-Id`,再写入 Token 中已认证的值:
```java
// 安全修复:先移除客户端传来的 X-Tenant-Id再写入 Token 中已认证的值
ServerHttpRequest mutatedRequest = request.mutate()
.headers(headers -> headers.remove(TENANT_ID_HEADER)) // 先删除客户端值
.header(USER_ID_HEADER, String.valueOf(tokenInfo.getUserId()))
.header(USERNAME_HEADER, tokenInfo.getUsername())
.header(TENANT_ID_HEADER, String.valueOf(tokenInfo.getTenantId())) // 再写入认证值
.build();
```
### 修复位置
`fund-gateway/src/main/java/com/fundplatform/gateway/filter/TokenAuthFilter.java` 第 86-93 行
---
## 漏洞二IGNORE_TABLES 包含业务敏感表(高危)
### 漏洞描述
`TenantLineHandlerImpl.IGNORE_TABLES` 中包含了 `sys_user``sys_role``sys_dept``sys_config` 等业务表,这些表均有 `tenant_id` 字段属于租户私有数据。将其加入忽略列表后MyBatis-Plus 不会为这些表的查询自动注入 `tenant_id` 过滤条件,导致以下问题:
1. **跨租户用户数据泄漏**:查询 `sys_user` 时返回所有租户的用户数据
2. **跨租户角色泄漏**:查询 `sys_role` 时返回所有租户的角色配置
3. **跨租户部门泄漏**:查询 `sys_dept` 时返回所有租户的部门结构
### 修复方案
`IGNORE_TABLES` 中仅保留真正全平台共享的静态数据表:
```java
// 修复后:仅保留平台级全局共享表
private static final Set<String> IGNORE_TABLES = new HashSet<>(Arrays.asList(
"sys_menu", // 菜单表(系统菜单结构全局共享)
"sys_dict", // 字典表(枚举数据全局共享)
"sys_log", // 日志表(独立存储逻辑)
"gen_table", // 代码生成表
"gen_table_column"
));
// 已移除sys_user、sys_role、sys_dept、sys_config均为租户私有数据
```
### 修复位置
`fund-common/src/main/java/com/fundplatform/common/mybatis/TenantLineHandlerImpl.java` 第 34-46 行
---
## 漏洞三:租户 ID 缺失时 Fallback 到默认值 1中危
### 漏洞描述
`TenantLineHandlerImpl.getTenantId()` 中,当从 `TenantContextHolder` 获取不到租户 ID 时,代码回退到默认值 `1L`
```java
// 漏洞代码
if (tenantId == null) {
tenantId = 1L; // 危险误操作租户1的数据
}
```
这导致在没有认证上下文的场景如定时任务、内部错误等SQL 会意外查询租户1的数据可能造成租户1数据泄漏或误写入。
### 修复方案
当租户上下文缺失时直接抛出异常,中断 SQL 执行:
```java
// 修复后:缺失租户上下文时强制报错
if (tenantId == null) {
throw new IllegalStateException("[Security] 当前请求缺少租户上下文拒绝执行SQL");
}
```
### 修复位置
`fund-common/src/main/java/com/fundplatform/common/mybatis/TenantLineHandlerImpl.java` 第 56-66 行
---
## 漏洞四:登录接口跨租户用户名混乱(中危)
### 漏洞描述
登录接口 `/auth/login` 在 Token 白名单中,`TenantContextHolder` 为空。由于 `sys_user` 原来在 `IGNORE_TABLES` 中(全局查询),登录时仅按用户名查询不区分租户:
```java
// 漏洞代码:不同租户可能有同名用户,查到哪个是不确定的
wrapper.eq(SysUser::getUsername, request.getUsername());
// 未加 tenantId 条件
SysUser user = userDataService.getOne(wrapper);
```
当不同租户有同名用户时,认证结果不确定,存在以下风险:
- 用户 A租户1可能以租户2用户的身份登录
- 登录成功后 Token 中绑定了错误的 tenantId
### 修复方案
1. `LoginRequestDTO` 增加 `tenantId` 字段(必填),前端登录时显式指定租户
2. 登录查询使用 `TenantIgnoreHelper` 跳过自动租户过滤,同时显式加入 `tenant_id` 条件:
```java
// 修复后:使用 TenantIgnoreHelper + 显式 tenantId 条件
SysUser user = TenantIgnoreHelper.ignore(() -> {
LambdaQueryWrapper<SysUser> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(SysUser::getUsername, request.getUsername());
wrapper.eq(SysUser::getTenantId, request.getTenantId()); // 显式限定租户
wrapper.eq(SysUser::getDeleted, 0);
return userDataService.getOne(wrapper);
});
```
### 修复位置
- `fund-sys/src/main/java/com/fundplatform/sys/dto/LoginRequestDTO.java` — 新增 tenantId 字段
- `fund-sys/src/main/java/com/fundplatform/sys/service/impl/AuthServiceImpl.java` 第 31-44 行
---
## 修复总结
| 编号 | 漏洞类型 | 危险级别 | 修复文件 |
|------|---------|---------|---------|
| 1 | 网关未覆盖客户端伪造的 X-Tenant-Id | 高危 | `fund-gateway/.../TokenAuthFilter.java` |
| 2 | IGNORE_TABLES 包含业务敏感表 | 高危 | `fund-common/.../TenantLineHandlerImpl.java` |
| 3 | 租户 ID 缺失时 fallback 到默认值 | 中危 | `fund-common/.../TenantLineHandlerImpl.java` |
| 4 | 登录查询未限定租户范围 | 中危 | `fund-sys/.../AuthServiceImpl.java`, `LoginRequestDTO.java` |
## 修复后的安全保证
1. **网关层**:经过 Token 验证的请求,`X-Tenant-Id` 由 Token 中的认证值强制覆盖,客户端无法伪造
2. **数据层**业务表sys_user、sys_role、sys_dept、sys_config均受 MyBatis-Plus 租户插件保护,自动注入 `tenant_id` 过滤
3. **兜底保护**:租户上下文为空时 SQL 拒绝执行,不会 fallback 到任何租户
4. **登录安全**:登录时必须指定 tenantId确保同名用户不会跨租户混乱认证
## 注意事项
- 所有需要跨租户操作的合法场景(如超管管理所有租户),必须显式使用 `TenantIgnoreHelper.ignore()` 包装,并在代码中注明安全意图
- 定时任务、事件监听器等异步场景在执行 DB 操作前,必须先设置 `TenantContextHolder`,执行完后在 finally 中清理

View File

@ -255,9 +255,9 @@ INSERT INTO sys_tenant (id, tenant_code, tenant_name, contact, phone, status, ma
VALUES (1, 'DEFAULT', '默认租户', '管理员', '13800138000', 1, 100, '系统默认租户', NOW())
ON DUPLICATE KEY UPDATE tenant_code=tenant_code;
-- 插入超级管理员用户 (租户ID=1, 密码: admin123)
-- 插入超级管理员用户 (租户ID=1, 密码: admin123, MD5: 0192023a7bbd73250516f069df18b500)
INSERT INTO sys_user (id, tenant_id, username, password, real_name, phone, status, created_by, created_time)
VALUES (1, 1, 'admin', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iAt6Z5E', '超级管理员', '13800138000', 1, 1, NOW())
VALUES (1, 1, 'admin', '0192023a7bbd73250516f069df18b500', '超级管理员', '13800138000', 1, 1, NOW())
ON DUPLICATE KEY UPDATE username=username;
-- 插入超级管理员角色

View File

@ -14,6 +14,7 @@
"axios": "^1.13.5",
"echarts": "^6.0.0",
"element-plus": "^2.13.2",
"js-md5": "^0.8.3",
"pinia": "^3.0.4",
"vue": "^3.5.25",
"vue-router": "^5.0.2"

View File

@ -51,6 +51,7 @@ import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { useUserStore } from '@/stores/user'
import md5 from 'js-md5'
const router = useRouter()
const route = useRoute()
@ -70,24 +71,6 @@ const rules: FormRules = {
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
}
// MD5 使 crypto-js
const md5 = (str: string): string => {
// 使 MD5 crypto-js blueimp-md5
// npm install crypto-js
// import CryptoJS from 'crypto-js'
// return CryptoJS.MD5(str).toString()
// MD5
// 使npm install blueimp-md5 && import md5 from 'blueimp-md5'
let hash = 0
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i)
hash = ((hash << 5) - hash) + char
hash = hash & hash
}
return Math.abs(hash).toString(16).padStart(32, '0')
}
const handleLogin = async () => {
if (!formRef.value) return
@ -97,7 +80,6 @@ const handleLogin = async () => {
// MD5
const encryptedPassword = md5(form.password)
console.log('登录 - 原始密码:', form.password, 'MD5 加密后:', encryptedPassword)
await userStore.loginAction(form.username, encryptedPassword)

View File

@ -127,6 +127,7 @@ import { ElMessage, FormInstance, FormRules, UploadProps } from 'element-plus'
import { UserFilled, OfficeBuilding } from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
import { updateProfile, updatePassword } from '@/api/user'
import md5 from 'js-md5'
const userStore = useUserStore()
const defaultAvatar = 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png'
@ -236,7 +237,11 @@ const handleUpdatePassword = async () => {
if (valid) {
saving.value = true
try {
await updatePassword(passwordForm)
await updatePassword({
oldPassword: md5(passwordForm.oldPassword),
newPassword: md5(passwordForm.newPassword),
confirmPassword: md5(passwordForm.confirmPassword)
})
ElMessage.success('密码修改成功')
resetPasswordForm()
} catch (error: any) {

View File

@ -31,32 +31,34 @@ public class TenantLineHandlerImpl implements TenantLineHandler {
private static final Logger logger = LoggerFactory.getLogger(TenantLineHandlerImpl.class);
/**
* 忽略租户过滤的表系统表字典表等公共数据
* 忽略租户过滤的表仅限平台级公共数据所有租户共享
*
* <p>安全说明仅将真正全平台共享的静态配置表列为忽略表</p>
* <p>sys_user/sys_role/sys_dept 均属于租户私有数据 tenant_id 字段
* 不得加入此列表否则会导致跨租户数据泄漏</p>
*/
private static final Set<String> IGNORE_TABLES = new HashSet<>(Arrays.asList(
"sys_user", // 用户表可能跨租户
"sys_role", // 角色表可能跨租户
"sys_menu", // 菜单表所有租户共享
"sys_dict", // 字典表所有租户共享
"sys_config", // 配置表所有租户共享
"sys_dept", // 部门表可能跨租户
"sys_log", // 日志表独立存储
"gen_table", // 代码生成表
"sys_menu", // 菜单表所有租户共享的系统菜单结构
"sys_dict", // 字典表所有租户共享的枚举数据
"sys_log", // 日志表独立存储由专属逻辑管理
"gen_table", // 代码生成表开发工具非业务数据
"gen_table_column" // 代码生成字段表
));
/**
* 获取租户 ID
*
* <p> TenantContextHolder 获取当前线程的租户 ID</p>
* <p> TenantContextHolder 获取当前线程的租户 ID</p>
* <p>安全修复不再使用 fallback 默认值 1L若租户上下文为空则直接报错
* 防止在缺少认证上下文的情况下误操作租户1的数据</p>
*/
@Override
public Expression getTenantId() {
Long tenantId = getCurrentTenantId();
if (tenantId == null) {
logger.debug("[MyBatis Tenant] 未获取到租户 ID使用默认值 1");
tenantId = 1L;
// 安全修复租户上下文缺失时必须中断不能 fallback 到任意租户
throw new IllegalStateException("[Security] 当前请求缺少租户上下文拒绝执行SQL请确保请求经过认证过滤器");
}
logger.debug("[MyBatis Tenant] 当前租户 ID: {}", tenantId);

View File

@ -0,0 +1,76 @@
package com.fundplatform.common.context;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* TenantContextHolder 租户上下文持有者单元测试
*/
class TenantContextHolderTest {
@AfterEach
void cleanup() {
TenantContextHolder.clear();
}
@Test
@DisplayName("设置租户ID后可以正确获取")
void setAndGetTenantId() {
TenantContextHolder.setTenantId(100L);
assertEquals(100L, TenantContextHolder.getTenantId());
}
@Test
@DisplayName("未设置时 getTenantId 返回 null")
void getTenantId_null_whenNotSet() {
assertNull(TenantContextHolder.getTenantId());
}
@Test
@DisplayName("clear 后 getTenantId 返回 null")
void clear_removesValue() {
TenantContextHolder.setTenantId(200L);
TenantContextHolder.clear();
assertNull(TenantContextHolder.getTenantId());
}
@Test
@DisplayName("多次设置取最后一次的值")
void setMultipleTimes_lastValueWins() {
TenantContextHolder.setTenantId(10L);
TenantContextHolder.setTenantId(20L);
TenantContextHolder.setTenantId(30L);
assertEquals(30L, TenantContextHolder.getTenantId());
}
@Test
@DisplayName("设置 null 值后 getTenantId 返回 null")
void setNullTenantId() {
TenantContextHolder.setTenantId(100L);
TenantContextHolder.setTenantId(null);
assertNull(TenantContextHolder.getTenantId());
}
@Test
@DisplayName("线程隔离 - 子线程设置的值不影响主线程")
void threadIsolation() throws InterruptedException {
TenantContextHolder.setTenantId(1L);
Thread subThread = new Thread(() -> {
TenantContextHolder.setTenantId(999L);
assertEquals(999L, TenantContextHolder.getTenantId());
});
subThread.start();
subThread.join();
// 主线程的值不受子线程影响
assertEquals(1L, TenantContextHolder.getTenantId());
}
}

View File

@ -0,0 +1,107 @@
package com.fundplatform.common.context;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* UserContextHolder 用户上下文持有者单元测试
*/
class UserContextHolderTest {
@AfterEach
void cleanup() {
UserContextHolder.clear();
}
@Test
@DisplayName("设置用户ID后可以正确获取")
void setAndGetUserId() {
UserContextHolder.setUserId(1L);
assertEquals(1L, UserContextHolder.getUserId());
}
@Test
@DisplayName("设置用户名后可以正确获取")
void setAndGetUserName() {
UserContextHolder.setUserName("admin");
assertEquals("admin", UserContextHolder.getUserName());
}
@Test
@DisplayName("同时设置 userId 和 userName")
void setBoth_userId_and_userName() {
UserContextHolder.setUserId(42L);
UserContextHolder.setUserName("testuser");
assertEquals(42L, UserContextHolder.getUserId());
assertEquals("testuser", UserContextHolder.getUserName());
}
@Test
@DisplayName("未设置时 getUserId 返回 null")
void getUserId_null_whenNotSet() {
assertNull(UserContextHolder.getUserId());
}
@Test
@DisplayName("未设置时 getUserName 返回 null")
void getUserName_null_whenNotSet() {
assertNull(UserContextHolder.getUserName());
}
@Test
@DisplayName("clear 后 userId 和 userName 均为 null")
void clear_removesAllValues() {
UserContextHolder.setUserId(1L);
UserContextHolder.setUserName("admin");
UserContextHolder.clear();
assertNull(UserContextHolder.getUserId());
assertNull(UserContextHolder.getUserName());
}
@Test
@DisplayName("多次设置 userId 取最后一次的值")
void setUserId_multiple_lastWins() {
UserContextHolder.setUserId(1L);
UserContextHolder.setUserId(2L);
UserContextHolder.setUserId(3L);
assertEquals(3L, UserContextHolder.getUserId());
}
@Test
@DisplayName("线程隔离 - 子线程设置的 userId 不影响主线程")
void threadIsolation_userId() throws InterruptedException {
UserContextHolder.setUserId(1L);
Thread subThread = new Thread(() -> {
UserContextHolder.setUserId(999L);
assertEquals(999L, UserContextHolder.getUserId());
});
subThread.start();
subThread.join();
assertEquals(1L, UserContextHolder.getUserId());
}
@Test
@DisplayName("线程隔离 - 子线程设置的 userName 不影响主线程")
void threadIsolation_userName() throws InterruptedException {
UserContextHolder.setUserName("mainthread");
Thread subThread = new Thread(() -> {
UserContextHolder.setUserName("subthread");
assertEquals("subthread", UserContextHolder.getUserName());
});
subThread.start();
subThread.join();
assertEquals("mainthread", UserContextHolder.getUserName());
}
}

View File

@ -0,0 +1,91 @@
package com.fundplatform.common.core;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
* PageResult 分页返回结构单元测试
*/
class PageResultTest {
@Test
@DisplayName("无参构造函数 - records 默认为空列表")
void defaultConstructor_emptyRecords() {
PageResult<String> pageResult = new PageResult<>();
assertNotNull(pageResult.getRecords());
assertTrue(pageResult.getRecords().isEmpty());
}
@Test
@DisplayName("全参构造函数 - 正确设置所有字段")
void fullConstructor() {
List<String> records = Arrays.asList("a", "b", "c");
PageResult<String> pageResult = new PageResult<>(1, 10, 3, records);
assertEquals(1, pageResult.getPageNum());
assertEquals(10, pageResult.getPageSize());
assertEquals(3, pageResult.getTotal());
assertEquals(3, pageResult.getRecords().size());
assertEquals("a", pageResult.getRecords().get(0));
}
@Test
@DisplayName("全参构造函数 - records 为 null 时自动转为空列表")
void fullConstructor_nullRecords() {
PageResult<String> pageResult = new PageResult<>(1, 10, 0, null);
assertNotNull(pageResult.getRecords());
assertTrue(pageResult.getRecords().isEmpty());
}
@Test
@DisplayName("setter/getter - 可正常设置和获取所有字段")
void settersAndGetters() {
PageResult<Integer> pageResult = new PageResult<>();
pageResult.setPageNum(2);
pageResult.setPageSize(20);
pageResult.setTotal(100);
pageResult.setRecords(Arrays.asList(1, 2, 3));
assertEquals(2, pageResult.getPageNum());
assertEquals(20, pageResult.getPageSize());
assertEquals(100, pageResult.getTotal());
assertEquals(3, pageResult.getRecords().size());
}
@Test
@DisplayName("total 为 0 时表示无数据")
void total_zero() {
PageResult<Object> pageResult = new PageResult<>(1, 10, 0, Collections.emptyList());
assertEquals(0, pageResult.getTotal());
assertTrue(pageResult.getRecords().isEmpty());
}
@Test
@DisplayName("记录数量与 total 可以不一致(当前页可为最后一页不满页)")
void records_lessThanPageSize() {
List<String> records = List.of("x", "y");
PageResult<String> pageResult = new PageResult<>(5, 10, 42, records);
assertEquals(42, pageResult.getTotal());
assertEquals(2, pageResult.getRecords().size());
}
@Test
@DisplayName("setRecords - 可覆盖原有记录")
void setRecords_override() {
PageResult<String> pageResult = new PageResult<>(1, 10, 2, Arrays.asList("old1", "old2"));
pageResult.setRecords(List.of("new1"));
assertEquals(1, pageResult.getRecords().size());
assertEquals("new1", pageResult.getRecords().get(0));
}
}

View File

@ -0,0 +1,125 @@
package com.fundplatform.common.core;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* Result 统一响应封装单元测试
*/
class ResultTest {
@Test
@DisplayName("success() - 无参成功返回code=200, message=success, data=null")
void success_noArgs() {
Result<Void> result = Result.success();
assertEquals(200, result.getCode());
assertEquals("success", result.getMessage());
assertNull(result.getData());
assertTrue(result.isSuccess());
}
@Test
@DisplayName("success(data) - 携带非String数据的成功返回")
void success_withData() {
Integer data = 42;
Result<Integer> result = Result.success(data);
assertEquals(200, result.getCode());
assertEquals("success", result.getMessage());
assertEquals(42, result.getData());
assertTrue(result.isSuccess());
}
@Test
@DisplayName("success(message) - 仅消息的成功返回data 为 null")
void success_withMessage() {
Result<Void> result = Result.success("操作成功");
assertEquals(200, result.getCode());
assertEquals("操作成功", result.getMessage());
assertNull(result.getData());
assertTrue(result.isSuccess());
}
@Test
@DisplayName("success(data, message) - 携带数据和自定义消息的成功返回")
void success_withDataAndMessage() {
Integer data = 42;
Result<Integer> result = Result.success(data, "查询成功");
assertEquals(200, result.getCode());
assertEquals("查询成功", result.getMessage());
assertEquals(42, result.getData());
assertTrue(result.isSuccess());
}
@Test
@DisplayName("error(message) - 使用默认错误码 500")
void error_withMessage() {
Result<Void> result = Result.error("系统错误");
assertEquals(500, result.getCode());
assertEquals("系统错误", result.getMessage());
assertNull(result.getData());
assertFalse(result.isSuccess());
}
@Test
@DisplayName("error(code, message) - 自定义错误码")
void error_withCodeAndMessage() {
Result<Void> result = Result.error(404, "资源未找到");
assertEquals(404, result.getCode());
assertEquals("资源未找到", result.getMessage());
assertNull(result.getData());
assertFalse(result.isSuccess());
}
@Test
@DisplayName("isSuccess() - code=200 时返回 true")
void isSuccess_true() {
Result<Object> result = new Result<>(200, "ok", null);
assertTrue(result.isSuccess());
}
@Test
@DisplayName("isSuccess() - code!=200 时返回 false")
void isSuccess_false() {
Result<Object> result = new Result<>(500, "error", null);
assertFalse(result.isSuccess());
}
@Test
@DisplayName("无参构造函数 + setter - 可正常设置字段")
void defaultConstructor_withSetters() {
Result<String> result = new Result<>();
result.setCode(200);
result.setMessage("ok");
result.setData("test");
assertEquals(200, result.getCode());
assertEquals("ok", result.getMessage());
assertEquals("test", result.getData());
}
@Test
@DisplayName("success(data, message) 中 data 为 null 也能正常返回")
void success_nullData() {
Result<String> result = Result.success(null, "查询成功");
assertEquals(200, result.getCode());
assertNull(result.getData());
assertEquals("查询成功", result.getMessage());
assertTrue(result.isSuccess());
}
@Test
@DisplayName("常量值SUCCESS=200, ERROR=500")
void constants() {
assertEquals(200, Result.SUCCESS);
assertEquals(500, Result.ERROR);
}
}

View File

@ -61,6 +61,13 @@
<version>7.4</version>
</dependency>
<!-- SpringDoc OpenAPI (Swagger UI) -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.3.0</version>
</dependency>
<!-- Nacos服务注册发现 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>

View File

@ -5,12 +5,16 @@ import com.fundplatform.common.core.Result;
import com.fundplatform.cust.dto.ContactDTO;
import com.fundplatform.cust.service.ContactService;
import com.fundplatform.cust.vo.ContactVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
/**
* 联系人管理Controller
*/
@Tag(name = "联系人管理", description = "客户联系人的增删改查及主要联系人设置")
@RestController
@RequestMapping("/api/v1/customer/contact")
public class ContactController {
@ -24,6 +28,7 @@ public class ContactController {
/**
* 创建联系人
*/
@Operation(summary = "创建联系人")
@PostMapping
public Result<Long> create(@Valid @RequestBody ContactDTO dto) {
Long id = contactService.createContact(dto);
@ -33,8 +38,9 @@ public class ContactController {
/**
* 更新联系人
*/
@Operation(summary = "更新联系人信息")
@PutMapping("/{id}")
public Result<Boolean> update(@PathVariable Long id, @Valid @RequestBody ContactDTO dto) {
public Result<Boolean> update(@Parameter(description = "联系人ID") @PathVariable Long id, @Valid @RequestBody ContactDTO dto) {
boolean result = contactService.updateContact(id, dto);
return Result.success(result);
}
@ -42,8 +48,9 @@ public class ContactController {
/**
* 根据ID查询联系人
*/
@Operation(summary = "根据ID查询联系人")
@GetMapping("/{id}")
public Result<ContactVO> getById(@PathVariable Long id) {
public Result<ContactVO> getById(@Parameter(description = "联系人ID") @PathVariable Long id) {
ContactVO vo = contactService.getContactById(id);
return Result.success(vo);
}
@ -51,11 +58,12 @@ public class ContactController {
/**
* 分页查询联系人
*/
@Operation(summary = "分页查询联系人")
@GetMapping("/page")
public Result<Page<ContactVO>> page(
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "10") int pageSize,
@RequestParam(required = false) Long customerId) {
@Parameter(description = "页码") @RequestParam(defaultValue = "1") int pageNum,
@Parameter(description = "每页条数") @RequestParam(defaultValue = "10") int pageSize,
@Parameter(description = "客户ID") @RequestParam(required = false) Long customerId) {
Page<ContactVO> page = contactService.pageContacts(pageNum, pageSize, customerId);
return Result.success(page);
}
@ -63,8 +71,9 @@ public class ContactController {
/**
* 删除联系人
*/
@Operation(summary = "删除联系人")
@DeleteMapping("/{id}")
public Result<Boolean> delete(@PathVariable Long id) {
public Result<Boolean> delete(@Parameter(description = "联系人ID") @PathVariable Long id) {
boolean result = contactService.deleteContact(id);
return Result.success(result);
}
@ -72,10 +81,11 @@ public class ContactController {
/**
* 设置主要联系人
*/
@Operation(summary = "设置主要联系人", description = "将指定联系人设为客户的主要联系人")
@PutMapping("/{customerId}/contact/{contactId}/primary")
public Result<Boolean> setPrimary(
@PathVariable Long customerId,
@PathVariable Long contactId) {
@Parameter(description = "客户ID") @PathVariable Long customerId,
@Parameter(description = "联系人ID") @PathVariable Long contactId) {
boolean result = contactService.setPrimaryContact(customerId, contactId);
return Result.success(result);
}

View File

@ -7,12 +7,16 @@ import com.fundplatform.cust.dto.CustomerCreateDTO;
import com.fundplatform.cust.dto.CustomerUpdateDTO;
import com.fundplatform.cust.service.CustomerService;
import com.fundplatform.cust.vo.CustomerVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
/**
* 客户Controller
*/
@Tag(name = "客户管理", description = "客户的增删改查")
@RestController
@RequestMapping("/api/v1/customer")
public class CustomerController {
@ -26,6 +30,7 @@ public class CustomerController {
/**
* 创建客户
*/
@Operation(summary = "创建客户")
@PostMapping
public Result<Long> createCustomer(@Valid @RequestBody CustomerCreateDTO dto) {
Long id = customerService.createCustomer(dto);
@ -35,8 +40,9 @@ public class CustomerController {
/**
* 更新客户
*/
@Operation(summary = "更新客户信息")
@PutMapping("/{id}")
public Result<Void> updateCustomer(@PathVariable Long id, @Valid @RequestBody CustomerUpdateDTO dto) {
public Result<Void> updateCustomer(@Parameter(description = "客户ID") @PathVariable Long id, @Valid @RequestBody CustomerUpdateDTO dto) {
customerService.updateCustomer(id, dto);
return Result.success();
}
@ -44,8 +50,9 @@ public class CustomerController {
/**
* 查询客户详情
*/
@Operation(summary = "查询客户详情")
@GetMapping("/{id}")
public Result<CustomerVO> getCustomer(@PathVariable Long id) {
public Result<CustomerVO> getCustomer(@Parameter(description = "客户ID") @PathVariable Long id) {
CustomerVO vo = customerService.getCustomerById(id);
return Result.success(vo);
}
@ -53,11 +60,12 @@ public class CustomerController {
/**
* 分页查询客户
*/
@Operation(summary = "分页查询客户")
@GetMapping("/page")
public Result<PageResult<CustomerVO>> pageCustomers(
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "10") int pageSize,
@RequestParam(required = false) String keyword) {
@Parameter(description = "页码") @RequestParam(defaultValue = "1") int pageNum,
@Parameter(description = "每页条数") @RequestParam(defaultValue = "10") int pageSize,
@Parameter(description = "关键词(客户名/编码/联系人模糊查询)") @RequestParam(required = false) String keyword) {
Page<CustomerVO> page = customerService.pageCustomers(pageNum, pageSize, keyword);
PageResult<CustomerVO> pageResult = new PageResult<>(
@ -72,8 +80,9 @@ public class CustomerController {
/**
* 删除客户
*/
@Operation(summary = "删除客户(逻辑删除)")
@DeleteMapping("/{id}")
public Result<Void> deleteCustomer(@PathVariable Long id) {
public Result<Void> deleteCustomer(@Parameter(description = "客户ID") @PathVariable Long id) {
customerService.deleteCustomer(id);
return Result.success();
}

View File

@ -66,7 +66,9 @@ public class TenantGatewayFilter implements GlobalFilter, Ordered {
return chain.filter(exchange);
}
// 检查X-Tenant-Id请求头
// 安全修复从请求头中取 X-Tenant-IdTokenAuthFilter 已用 Token 中的值覆盖客户端传入值
// 因此此处取到的必为 Token 认证后的租户ID无需再与客户端值单独比对
// 但仍需校验格式合法性防止异常数据流入下游服务
String tenantId = request.getHeaders().getFirst(HEADER_TENANT_ID);
if (tenantId == null || tenantId.trim().isEmpty()) {
logger.warn("[TenantGateway] 缺少X-Tenant-Id请求头路径: {}", path);

View File

@ -83,8 +83,10 @@ public class TokenAuthFilter implements GlobalFilter, Ordered {
return unauthorized(exchange, "Token无效或已过期");
}
// 将用户信息写入请求头
// 安全修复先移除客户端传来的 X-Tenant-Id再写入 Token 中已认证的值
// 防止攻击者通过伪造 X-Tenant-Id 请求头绕过租户隔离
ServerHttpRequest mutatedRequest = request.mutate()
.headers(headers -> headers.remove(TENANT_ID_HEADER))
.header(USER_ID_HEADER, String.valueOf(tokenInfo.getUserId()))
.header(USERNAME_HEADER, tokenInfo.getUsername())
.header(TENANT_ID_HEADER, String.valueOf(tokenInfo.getTenantId()))

View File

@ -11,6 +11,7 @@
},
"dependencies": {
"axios": "^1.6.0",
"js-md5": "^0.8.3",
"pinia": "^2.1.7",
"vant": "^4.9.22",
"vue": "^3.4.0",

View File

@ -52,6 +52,7 @@ import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { showToast, showSuccessToast } from 'vant'
import { login } from '@/api'
import md5 from 'js-md5'
const router = useRouter()
const loading = ref(false)
@ -74,7 +75,10 @@ const handleLogin = async () => {
loading.value = true
try {
const res: any = await login(form)
const res: any = await login({
username: form.username,
password: md5(form.password)
})
const data = res.data
localStorage.setItem('token', data.token)
localStorage.setItem('userInfo', JSON.stringify({

View File

@ -60,6 +60,7 @@ import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { showFailToast, showSuccessToast, showConfirmDialog } from 'vant'
import { updatePassword } from '@/api'
import md5 from 'js-md5'
const router = useRouter()
const submitting = ref(false)
@ -94,9 +95,9 @@ const onSubmit = async () => {
submitting.value = true
await updatePassword({
oldPassword: form.value.oldPassword,
newPassword: form.value.newPassword,
confirmPassword: form.value.confirmPassword
oldPassword: md5(form.value.oldPassword),
newPassword: md5(form.value.newPassword),
confirmPassword: md5(form.value.confirmPassword)
})
showSuccessToast('密码修改成功')

View File

@ -112,6 +112,20 @@
<artifactId>logstash-logback-encoder</artifactId>
<version>7.4</version>
</dependency>
<!-- SpringDoc OpenAPI (Swagger UI) -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.3.0</version>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>

View File

@ -5,6 +5,9 @@ import com.fundplatform.sys.dto.LoginRequestDTO;
import com.fundplatform.sys.service.AuthService;
import com.fundplatform.sys.vo.LoginVO;
import com.fundplatform.sys.vo.UserVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
@ -16,6 +19,7 @@ import org.springframework.web.bind.annotation.RestController;
/**
* 认证Controller
*/
@Tag(name = "认证管理", description = "登录、登出、Token刷新等认证接口")
@RestController
@RequestMapping("/api/v1/auth")
public class AuthController {
@ -29,6 +33,7 @@ public class AuthController {
/**
* 用户登录
*/
@Operation(summary = "用户登录", description = "使用用户名和MD5加密密码登录返回Token及用户信息")
@PostMapping("/login")
public Result<LoginVO> login(@Valid @RequestBody LoginRequestDTO request) {
LoginVO vo = authService.login(request);
@ -38,8 +43,9 @@ public class AuthController {
/**
* 用户登出
*/
@Operation(summary = "用户登出", description = "销毁当前用户Token使会话失效")
@PostMapping("/logout")
public Result<Void> logout(@RequestHeader(value = "X-User-Id", required = false) Long userId) {
public Result<Void> logout(@Parameter(description = "当前登录用户ID") @RequestHeader(value = "X-User-Id", required = false) Long userId) {
authService.logout(userId);
return Result.success();
}
@ -47,8 +53,9 @@ public class AuthController {
/**
* 刷新Token
*/
@Operation(summary = "刷新Token", description = "延长当前Token的有效期")
@PostMapping("/refresh")
public Result<LoginVO> refreshToken(@RequestHeader("X-User-Id") Long userId) {
public Result<LoginVO> refreshToken(@Parameter(description = "当前登录用户ID") @RequestHeader("X-User-Id") Long userId) {
LoginVO vo = authService.refreshToken(userId);
return Result.success(vo);
}
@ -56,8 +63,9 @@ public class AuthController {
/**
* 获取当前用户信息
*/
@Operation(summary = "获取当前用户信息", description = "根据Token中的用户ID获取用户详情")
@GetMapping("/info")
public Result<UserVO> getUserInfo(@RequestHeader("X-User-Id") Long userId) {
public Result<UserVO> getUserInfo(@Parameter(description = "当前登录用户ID") @RequestHeader("X-User-Id") Long userId) {
UserVO vo = authService.getUserInfo(userId);
return Result.success(vo);
}

View File

@ -4,6 +4,9 @@ import com.fundplatform.common.core.Result;
import com.fundplatform.sys.dto.DeptDTO;
import com.fundplatform.sys.service.DeptService;
import com.fundplatform.sys.vo.DeptVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
@ -12,6 +15,7 @@ import java.util.List;
/**
* 部门管理Controller
*/
@Tag(name = "部门管理", description = "部门的增删改查及树形结构查询")
@RestController
@RequestMapping("/api/v1/sys/dept")
public class DeptController {
@ -22,44 +26,51 @@ public class DeptController {
this.deptService = deptService;
}
@Operation(summary = "创建部门")
@PostMapping
public Result<Long> create(@Valid @RequestBody DeptDTO dto) {
Long deptId = deptService.createDept(dto);
return Result.success(deptId);
}
@Operation(summary = "更新部门信息")
@PutMapping
public Result<Boolean> update(@Valid @RequestBody DeptDTO dto) {
boolean result = deptService.updateDept(dto);
return Result.success(result);
}
@Operation(summary = "根据ID查询部门")
@GetMapping("/{id}")
public Result<DeptVO> getById(@PathVariable Long id) {
public Result<DeptVO> getById(@Parameter(description = "部门ID") @PathVariable Long id) {
DeptVO vo = deptService.getDeptById(id);
return Result.success(vo);
}
@Operation(summary = "获取部门树形结构")
@GetMapping("/tree")
public Result<List<DeptVO>> getTree() {
List<DeptVO> tree = deptService.getDeptTree();
return Result.success(tree);
}
@Operation(summary = "查询所有部门列表(扁平)")
@GetMapping("/list")
public Result<List<DeptVO>> listAll() {
List<DeptVO> list = deptService.listAllDepts();
return Result.success(list);
}
@Operation(summary = "删除部门")
@DeleteMapping("/{id}")
public Result<Boolean> delete(@PathVariable Long id) {
public Result<Boolean> delete(@Parameter(description = "部门ID") @PathVariable Long id) {
boolean result = deptService.deleteDept(id);
return Result.success(result);
}
@Operation(summary = "更新部门状态")
@PutMapping("/{id}/status")
public Result<Boolean> updateStatus(@PathVariable Long id, @RequestParam Integer status) {
public Result<Boolean> updateStatus(@Parameter(description = "部门ID") @PathVariable Long id, @Parameter(description = "状态0禁用 1启用") @RequestParam Integer status) {
boolean result = deptService.updateStatus(id, status);
return Result.success(result);
}

View File

@ -4,6 +4,9 @@ import com.fundplatform.common.core.Result;
import com.fundplatform.sys.dto.MenuDTO;
import com.fundplatform.sys.service.MenuService;
import com.fundplatform.sys.vo.MenuVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
@ -12,6 +15,7 @@ import java.util.List;
/**
* 菜单管理Controller
*/
@Tag(name = "菜单管理", description = "菜单和权限的增删改查")
@RestController
@RequestMapping("/api/v1/sys/menu")
public class MenuController {
@ -22,38 +26,44 @@ public class MenuController {
this.menuService = menuService;
}
@Operation(summary = "创建菜单/权限")
@PostMapping
public Result<Long> create(@Valid @RequestBody MenuDTO dto) {
Long menuId = menuService.createMenu(dto);
return Result.success(menuId);
}
@Operation(summary = "更新菜单/权限信息")
@PutMapping
public Result<Boolean> update(@Valid @RequestBody MenuDTO dto) {
boolean result = menuService.updateMenu(dto);
return Result.success(result);
}
@Operation(summary = "根据ID查询菜单")
@GetMapping("/{id}")
public Result<MenuVO> getById(@PathVariable Long id) {
public Result<MenuVO> getById(@Parameter(description = "菜单ID") @PathVariable Long id) {
MenuVO vo = menuService.getMenuById(id);
return Result.success(vo);
}
@Operation(summary = "获取菜单树(全量)")
@GetMapping("/tree")
public Result<List<MenuVO>> getTree() {
List<MenuVO> tree = menuService.getMenuTree();
return Result.success(tree);
}
@Operation(summary = "获取用户菜单树", description = "根据用户ID获取其有权访问的菜单树")
@GetMapping("/user/{userId}")
public Result<List<MenuVO>> getUserTree(@PathVariable Long userId) {
public Result<List<MenuVO>> getUserTree(@Parameter(description = "用户ID") @PathVariable Long userId) {
List<MenuVO> tree = menuService.getUserMenuTree(userId);
return Result.success(tree);
}
@Operation(summary = "删除菜单/权限")
@DeleteMapping("/{id}")
public Result<Boolean> delete(@PathVariable Long id) {
public Result<Boolean> delete(@Parameter(description = "菜单ID") @PathVariable Long id) {
boolean result = menuService.deleteMenu(id);
return Result.success(result);
}
@ -61,8 +71,9 @@ public class MenuController {
/**
* 获取用户权限标识列表
*/
@Operation(summary = "获取用户权限标识列表", description = "返回用户拥有的所有权限标识字符串")
@GetMapping("/permissions/{userId}")
public Result<List<String>> getUserPermissions(@PathVariable Long userId) {
public Result<List<String>> getUserPermissions(@Parameter(description = "用户ID") @PathVariable Long userId) {
List<String> permissions = menuService.getUserPermissions(userId);
return Result.success(permissions);
}

View File

@ -4,11 +4,15 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fundplatform.common.core.Result;
import com.fundplatform.sys.data.entity.OperationLog;
import com.fundplatform.sys.service.OperationLogService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.*;
/**
* 操作日志Controller
*/
@Tag(name = "操作日志", description = "系统操作日志查询与管理")
@RestController
@RequestMapping("/api/v1/log")
public class OperationLogController {
@ -22,30 +26,33 @@ public class OperationLogController {
/**
* 分页查询操作日志
*/
@Operation(summary = "分页查询操作日志")
@GetMapping("/page")
public Result<Page<OperationLog>> page(
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "10") int pageSize,
@RequestParam(required = false) Long userId,
@RequestParam(required = false) String operation,
@RequestParam(required = false) String startTime,
@RequestParam(required = false) String endTime) {
@Parameter(description = "页码") @RequestParam(defaultValue = "1") int pageNum,
@Parameter(description = "每页条数") @RequestParam(defaultValue = "10") int pageSize,
@Parameter(description = "用户ID") @RequestParam(required = false) Long userId,
@Parameter(description = "操作类型") @RequestParam(required = false) String operation,
@Parameter(description = "开始时间yyyy-MM-dd HH:mm:ss") @RequestParam(required = false) String startTime,
@Parameter(description = "结束时间yyyy-MM-dd HH:mm:ss") @RequestParam(required = false) String endTime) {
return Result.success(operationLogService.pageLogs(pageNum, pageSize, userId, operation, startTime, endTime));
}
/**
* 获取日志详情
*/
@Operation(summary = "获取操作日志详情")
@GetMapping("/{id}")
public Result<OperationLog> getById(@PathVariable Long id) {
public Result<OperationLog> getById(@Parameter(description = "日志ID") @PathVariable Long id) {
return Result.success(operationLogService.getById(id));
}
/**
* 清理历史日志
*/
@Operation(summary = "清理历史操作日志", description = "删除N天前的操作日志默认90天")
@DeleteMapping("/clean")
public Result<Integer> cleanLogs(@RequestParam(defaultValue = "90") int days) {
public Result<Integer> cleanLogs(@Parameter(description = "保留天数默认90天") @RequestParam(defaultValue = "90") int days) {
return Result.success(operationLogService.cleanLogs(days));
}
}

View File

@ -5,12 +5,16 @@ import com.fundplatform.sys.dto.PasswordDTO;
import com.fundplatform.sys.dto.ProfileDTO;
import com.fundplatform.sys.service.UserService;
import com.fundplatform.sys.vo.UserVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
/**
* 个人中心Controller
*/
@Tag(name = "个人中心", description = "当前登录用户的个人信息查询与修改")
@RestController
@RequestMapping("/api/v1/sys/profile")
public class ProfileController {
@ -24,8 +28,9 @@ public class ProfileController {
/**
* 获取个人信息
*/
@Operation(summary = "获取个人信息")
@GetMapping
public Result<UserVO> getProfile(@RequestHeader("X-User-Id") Long userId) {
public Result<UserVO> getProfile(@Parameter(description = "当前登录用户ID") @RequestHeader("X-User-Id") Long userId) {
UserVO vo = userService.getUserById(userId);
return Result.success(vo);
}
@ -33,9 +38,10 @@ public class ProfileController {
/**
* 更新个人信息
*/
@Operation(summary = "更新个人信息")
@PutMapping
public Result<Boolean> updateProfile(
@RequestHeader("X-User-Id") Long userId,
@Parameter(description = "当前登录用户ID") @RequestHeader("X-User-Id") Long userId,
@Valid @RequestBody ProfileDTO dto) {
boolean result = userService.updateProfile(userId, dto);
return Result.success(result);
@ -44,9 +50,10 @@ public class ProfileController {
/**
* 修改密码
*/
@Operation(summary = "修改密码", description = "需要提供旧密码和新密码MD5加密后传输")
@PutMapping("/password")
public Result<Boolean> updatePassword(
@RequestHeader("X-User-Id") Long userId,
@Parameter(description = "当前登录用户ID") @RequestHeader("X-User-Id") Long userId,
@Valid @RequestBody PasswordDTO dto) {
boolean result = userService.updatePassword(userId, dto);
return Result.success(result);

View File

@ -5,6 +5,9 @@ import com.fundplatform.common.core.Result;
import com.fundplatform.sys.dto.RoleDTO;
import com.fundplatform.sys.service.RoleService;
import com.fundplatform.sys.vo.RoleVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
@ -13,6 +16,7 @@ import java.util.List;
/**
* 角色管理Controller
*/
@Tag(name = "角色管理", description = "角色的增删改查及菜单权限分配")
@RestController
@RequestMapping("/api/v1/sys/role")
public class RoleController {
@ -23,48 +27,55 @@ public class RoleController {
this.roleService = roleService;
}
@Operation(summary = "创建角色")
@PostMapping
public Result<Long> create(@Valid @RequestBody RoleDTO dto) {
Long roleId = roleService.createRole(dto);
return Result.success(roleId);
}
@Operation(summary = "更新角色信息")
@PutMapping
public Result<Boolean> update(@Valid @RequestBody RoleDTO dto) {
boolean result = roleService.updateRole(dto);
return Result.success(result);
}
@Operation(summary = "根据ID查询角色")
@GetMapping("/{id}")
public Result<RoleVO> getById(@PathVariable Long id) {
public Result<RoleVO> getById(@Parameter(description = "角色ID") @PathVariable Long id) {
RoleVO vo = roleService.getRoleById(id);
return Result.success(vo);
}
@Operation(summary = "分页查询角色")
@GetMapping("/page")
public Result<Page<RoleVO>> page(
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "10") int pageSize,
@RequestParam(required = false) String roleName,
@RequestParam(required = false) Integer status) {
@Parameter(description = "页码") @RequestParam(defaultValue = "1") int pageNum,
@Parameter(description = "每页条数") @RequestParam(defaultValue = "10") int pageSize,
@Parameter(description = "角色名称(模糊查询)") @RequestParam(required = false) String roleName,
@Parameter(description = "状态0禁用 1启用") @RequestParam(required = false) Integer status) {
Page<RoleVO> page = roleService.pageRoles(pageNum, pageSize, roleName, status);
return Result.success(page);
}
@Operation(summary = "查询所有角色列表")
@GetMapping("/list")
public Result<List<RoleVO>> listAll() {
List<RoleVO> list = roleService.listAllRoles();
return Result.success(list);
}
@Operation(summary = "删除角色")
@DeleteMapping("/{id}")
public Result<Boolean> delete(@PathVariable Long id) {
public Result<Boolean> delete(@Parameter(description = "角色ID") @PathVariable Long id) {
boolean result = roleService.deleteRole(id);
return Result.success(result);
}
@Operation(summary = "更新角色状态")
@PutMapping("/{id}/status")
public Result<Boolean> updateStatus(@PathVariable Long id, @RequestParam Integer status) {
public Result<Boolean> updateStatus(@Parameter(description = "角色ID") @PathVariable Long id, @Parameter(description = "状态0禁用 1启用") @RequestParam Integer status) {
boolean result = roleService.updateStatus(id, status);
return Result.success(result);
}
@ -72,8 +83,9 @@ public class RoleController {
/**
* 获取角色菜单ID列表
*/
@Operation(summary = "获取角色的菜单ID列表")
@GetMapping("/{id}/menus")
public Result<List<Long>> getRoleMenus(@PathVariable Long id) {
public Result<List<Long>> getRoleMenus(@Parameter(description = "角色ID") @PathVariable Long id) {
List<Long> menuIds = roleService.getRoleMenus(id);
return Result.success(menuIds);
}
@ -81,8 +93,9 @@ public class RoleController {
/**
* 分配菜单给角色
*/
@Operation(summary = "为角色分配菜单权限")
@PutMapping("/{id}/menus")
public Result<Boolean> assignMenus(@PathVariable Long id, @RequestBody List<Long> menuIds) {
public Result<Boolean> assignMenus(@Parameter(description = "角色ID") @PathVariable Long id, @RequestBody List<Long> menuIds) {
boolean result = roleService.assignMenus(id, menuIds);
return Result.success(result);
}

View File

@ -5,12 +5,16 @@ import com.fundplatform.common.core.Result;
import com.fundplatform.sys.dto.TenantDTO;
import com.fundplatform.sys.service.TenantService;
import com.fundplatform.sys.vo.TenantVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
/**
* 租户管理Controller
*/
@Tag(name = "租户管理", description = "平台租户的增删改查及状态管理(超级管理员使用)")
@RestController
@RequestMapping("/api/v1/sys/tenant")
public class TenantController {
@ -21,36 +25,42 @@ public class TenantController {
this.tenantService = tenantService;
}
@Operation(summary = "创建租户")
@PostMapping
public Result<Long> create(@Valid @RequestBody TenantDTO dto) {
return Result.success(tenantService.createTenant(dto));
}
@Operation(summary = "更新租户信息")
@PutMapping
public Result<Boolean> update(@Valid @RequestBody TenantDTO dto) {
return Result.success(tenantService.updateTenant(dto));
}
@Operation(summary = "根据ID查询租户")
@GetMapping("/{id}")
public Result<TenantVO> getById(@PathVariable Long id) {
public Result<TenantVO> getById(@Parameter(description = "租户ID") @PathVariable Long id) {
return Result.success(tenantService.getTenantById(id));
}
@Operation(summary = "分页查询租户")
@GetMapping("/page")
public Result<Page<TenantVO>> page(
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "10") int pageSize,
@RequestParam(required = false) String keyword) {
@Parameter(description = "页码") @RequestParam(defaultValue = "1") int pageNum,
@Parameter(description = "每页条数") @RequestParam(defaultValue = "10") int pageSize,
@Parameter(description = "关键词(租户名/编码/联系人模糊查询)") @RequestParam(required = false) String keyword) {
return Result.success(tenantService.pageTenants(pageNum, pageSize, keyword));
}
@Operation(summary = "删除租户")
@DeleteMapping("/{id}")
public Result<Boolean> delete(@PathVariable Long id) {
public Result<Boolean> delete(@Parameter(description = "租户ID") @PathVariable Long id) {
return Result.success(tenantService.deleteTenant(id));
}
@Operation(summary = "更新租户状态")
@PutMapping("/{id}/status")
public Result<Boolean> updateStatus(@PathVariable Long id, @RequestParam Integer status) {
public Result<Boolean> updateStatus(@Parameter(description = "租户ID") @PathVariable Long id, @Parameter(description = "状态0禁用 1启用 2过期") @RequestParam Integer status) {
return Result.success(tenantService.updateStatus(id, status));
}
}

View File

@ -5,12 +5,16 @@ import com.fundplatform.common.core.Result;
import com.fundplatform.sys.dto.UserDTO;
import com.fundplatform.sys.service.UserService;
import com.fundplatform.sys.vo.UserVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
/**
* 用户管理Controller
*/
@Tag(name = "用户管理", description = "用户的增删改查及状态管理")
@RestController
@RequestMapping("/api/v1/sys/user")
public class UserController {
@ -24,6 +28,7 @@ public class UserController {
/**
* 创建用户
*/
@Operation(summary = "创建用户")
@PostMapping
public Result<Long> create(@Valid @RequestBody UserDTO dto) {
Long userId = userService.createUser(dto);
@ -33,6 +38,7 @@ public class UserController {
/**
* 更新用户
*/
@Operation(summary = "更新用户信息")
@PutMapping
public Result<Boolean> update(@Valid @RequestBody UserDTO dto) {
boolean result = userService.updateUser(dto);
@ -42,8 +48,9 @@ public class UserController {
/**
* 根据ID查询用户
*/
@Operation(summary = "根据ID查询用户")
@GetMapping("/{id}")
public Result<UserVO> getById(@PathVariable Long id) {
public Result<UserVO> getById(@Parameter(description = "用户ID") @PathVariable Long id) {
UserVO vo = userService.getUserById(id);
return Result.success(vo);
}
@ -51,13 +58,14 @@ public class UserController {
/**
* 分页查询用户
*/
@Operation(summary = "分页查询用户")
@GetMapping("/page")
public Result<Page<UserVO>> page(
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "10") int pageSize,
@RequestParam(required = false) String username,
@RequestParam(required = false) Integer status,
@RequestParam(required = false) Long deptId) {
@Parameter(description = "页码从1开始") @RequestParam(defaultValue = "1") int pageNum,
@Parameter(description = "每页条数") @RequestParam(defaultValue = "10") int pageSize,
@Parameter(description = "用户名(模糊查询)") @RequestParam(required = false) String username,
@Parameter(description = "状态0禁用 1启用") @RequestParam(required = false) Integer status,
@Parameter(description = "部门ID") @RequestParam(required = false) Long deptId) {
Page<UserVO> page = userService.pageUsers(pageNum, pageSize, username, status, deptId);
return Result.success(page);
}
@ -65,8 +73,9 @@ public class UserController {
/**
* 删除用户
*/
@Operation(summary = "删除用户(逻辑删除)")
@DeleteMapping("/{id}")
public Result<Boolean> delete(@PathVariable Long id) {
public Result<Boolean> delete(@Parameter(description = "用户ID") @PathVariable Long id) {
boolean result = userService.deleteUser(id);
return Result.success(result);
}
@ -74,6 +83,7 @@ public class UserController {
/**
* 批量删除用户
*/
@Operation(summary = "批量删除用户")
@DeleteMapping("/batch")
public Result<Boolean> batchDelete(@RequestBody Long[] ids) {
boolean result = userService.batchDeleteUsers(ids);
@ -83,8 +93,9 @@ public class UserController {
/**
* 重置密码
*/
@Operation(summary = "重置用户密码", description = "将用户密码重置为系统默认密码")
@PutMapping("/{id}/reset-password")
public Result<Boolean> resetPassword(@PathVariable Long id) {
public Result<Boolean> resetPassword(@Parameter(description = "用户ID") @PathVariable Long id) {
boolean result = userService.resetPassword(id);
return Result.success(result);
}
@ -92,8 +103,9 @@ public class UserController {
/**
* 更新用户状态
*/
@Operation(summary = "更新用户状态")
@PutMapping("/{id}/status")
public Result<Boolean> updateStatus(@PathVariable Long id, @RequestParam Integer status) {
public Result<Boolean> updateStatus(@Parameter(description = "用户ID") @PathVariable Long id, @Parameter(description = "状态0禁用 1启用") @RequestParam Integer status) {
boolean result = userService.updateStatus(id, status);
return Result.success(result);
}

View File

@ -1,6 +1,7 @@
package com.fundplatform.sys.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
/**
* 登录请求DTO
@ -13,6 +14,12 @@ public class LoginRequestDTO {
@NotBlank(message = "密码不能为空")
private String password;
/**
* 租户ID登录时必须指定以确保用户名在正确租户范围内唯一匹配防止跨租户登录混乱
*/
@NotNull(message = "租户ID不能为空")
private Long tenantId;
public String getUsername() {
return username;
}
@ -28,4 +35,12 @@ public class LoginRequestDTO {
public void setPassword(String password) {
this.password = password;
}
public Long getTenantId() {
return tenantId;
}
public void setTenantId(Long tenantId) {
this.tenantId = tenantId;
}
}

View File

@ -2,7 +2,7 @@ package com.fundplatform.sys.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fundplatform.common.auth.TokenService;
import com.fundplatform.common.util.Md5Util;
import com.fundplatform.common.mybatis.TenantIgnoreHelper;
import com.fundplatform.sys.data.entity.SysUser;
import com.fundplatform.sys.data.service.SysUserDataService;
import com.fundplatform.sys.dto.LoginRequestDTO;
@ -29,14 +29,20 @@ public class AuthServiceImpl implements AuthService {
@Override
public LoginVO login(LoginRequestDTO request) {
// 查询用户
// 安全修复登录时尚未建立租户上下文白名单路径不经过 TokenAuthFilter
// 必须使用 TenantIgnoreHelper 跳过 MyBatis-Plus 自动租户过滤
// 同时显式在查询条件中加入 tenantId确保只在指定租户范围内匹配用户名
// 防止不同租户的同名用户发生认证混乱
SysUser user = TenantIgnoreHelper.ignore(() -> {
LambdaQueryWrapper<SysUser> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(SysUser::getUsername, request.getUsername());
wrapper.eq(SysUser::getTenantId, request.getTenantId());
wrapper.eq(SysUser::getDeleted, 0);
SysUser user = userDataService.getOne(wrapper);
return userDataService.getOne(wrapper);
});
if (user == null) {
log.error("登录失败 - 用户不存在username={}", request.getUsername());
log.error("登录失败 - 用户不存在username={}, tenantId={}", request.getUsername(), request.getTenantId());
throw new RuntimeException("用户名或密码错误");
}

View File

@ -47,13 +47,10 @@ public class UserServiceImpl implements UserService {
throw new RuntimeException("用户名已存在");
}
// 创建用户密码使用 MD5 加密
// 创建用户密码由前端 MD5 加密后传入直接存储
SysUser user = new SysUser();
String rawPassword = dto.getPassword();
String md5Password = Md5Util.encrypt(rawPassword);
log.info("创建用户 - 原始密码:{}, MD5 加密后:{}", rawPassword, md5Password);
user.setUsername(dto.getUsername());
user.setPassword(md5Password);
user.setPassword(dto.getPassword());
user.setRealName(dto.getRealName());
user.setPhone(dto.getPhone());
user.setEmail(dto.getEmail());
@ -100,10 +97,8 @@ public class UserServiceImpl implements UserService {
user.setUsername(dto.getUsername());
}
if (StringUtils.hasText(dto.getPassword())) {
String rawPassword = dto.getPassword();
String md5Password = Md5Util.encrypt(rawPassword);
log.info("更新用户密码 - 原始密码:{}, MD5 加密后:{}", rawPassword, md5Password);
user.setPassword(md5Password);
// 密码由前端 MD5 加密后传入直接存储
user.setPassword(dto.getPassword());
}
user.setRealName(dto.getRealName());
user.setPhone(dto.getPhone());
@ -243,16 +238,15 @@ public class UserServiceImpl implements UserService {
throw new RuntimeException("用户不存在");
}
// 验证旧密码
if (!Md5Util.matches(dto.getOldPassword(), user.getPassword())) {
// 验证旧密码前端已 MD5直接与数据库存储的 MD5 值比对
if (!dto.getOldPassword().equals(user.getPassword())) {
log.error("修改密码失败 - 旧密码错误userId={}", userId);
throw new RuntimeException("旧密码错误");
}
// 更新密码
String rawNewPassword = dto.getNewPassword();
String md5NewPassword = Md5Util.encrypt(rawNewPassword);
log.info("修改用户密码 - userId={}, 原始新密码:{}, MD5: {}", userId, rawNewPassword, md5NewPassword);
// 更新密码前端已 MD5 加密直接存储
String md5NewPassword = dto.getNewPassword();
log.info("修改用户密码 - userId={}", userId);
LambdaUpdateWrapper<SysUser> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(SysUser::getId, userId);
wrapper.set(SysUser::getPassword, md5NewPassword);

View File

@ -0,0 +1,185 @@
package com.fundplatform.sys.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fundplatform.common.auth.TokenService;
import com.fundplatform.sys.data.entity.SysUser;
import com.fundplatform.sys.data.service.SysUserDataService;
import com.fundplatform.sys.dto.LoginRequestDTO;
import com.fundplatform.sys.vo.LoginVO;
import com.fundplatform.sys.vo.UserVO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentMatchers;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/**
* 认证服务单元测试
*/
@ExtendWith(MockitoExtension.class)
class AuthServiceImplTest {
@Mock
private SysUserDataService userDataService;
@Mock
private TokenService tokenService;
@InjectMocks
private AuthServiceImpl authService;
private SysUser mockUser;
@BeforeEach
void setUp() {
mockUser = new SysUser();
mockUser.setId(1L);
mockUser.setUsername("testuser");
// MD5("password123") 的值直接使用固定的 MD5 字符串避免调用加密工具
mockUser.setPassword("482c811da5d5b4bc6d497ffa98491e38");
mockUser.setStatus(1);
mockUser.setTenantId(100L);
mockUser.setDeleted(0);
}
@Test
@DisplayName("登录成功 - 用户名密码正确")
void login_success() {
LoginRequestDTO request = new LoginRequestDTO();
request.setUsername("testuser");
// 前端传来的 MD5 密码与数据库一致
request.setPassword("482c811da5d5b4bc6d497ffa98491e38");
when(userDataService.getOne(ArgumentMatchers.<LambdaQueryWrapper<SysUser>>any()))
.thenReturn(mockUser);
when(tokenService.generateToken(1L, "testuser", 100L))
.thenReturn("mock-token-uuid");
LoginVO result = authService.login(request);
assertNotNull(result);
assertEquals(1L, result.getUserId());
assertEquals("testuser", result.getUsername());
assertEquals("mock-token-uuid", result.getToken());
assertEquals(100L, result.getTenantId());
verify(tokenService).generateToken(1L, "testuser", 100L);
}
@Test
@DisplayName("登录失败 - 用户不存在")
void login_fail_userNotFound() {
LoginRequestDTO request = new LoginRequestDTO();
request.setUsername("nouser");
request.setPassword("anypassword");
when(userDataService.getOne(ArgumentMatchers.<LambdaQueryWrapper<SysUser>>any()))
.thenReturn(null);
RuntimeException ex = assertThrows(RuntimeException.class, () -> authService.login(request));
assertEquals("用户名或密码错误", ex.getMessage());
verify(tokenService, never()).generateToken(anyLong(), anyString(), anyLong());
}
@Test
@DisplayName("登录失败 - 密码错误")
void login_fail_wrongPassword() {
LoginRequestDTO request = new LoginRequestDTO();
request.setUsername("testuser");
request.setPassword("wrongmd5hash00000000000000000000");
when(userDataService.getOne(ArgumentMatchers.<LambdaQueryWrapper<SysUser>>any()))
.thenReturn(mockUser);
RuntimeException ex = assertThrows(RuntimeException.class, () -> authService.login(request));
assertEquals("用户名或密码错误", ex.getMessage());
verify(tokenService, never()).generateToken(anyLong(), anyString(), anyLong());
}
@Test
@DisplayName("登录失败 - 用户已被禁用")
void login_fail_userDisabled() {
mockUser.setStatus(0);
LoginRequestDTO request = new LoginRequestDTO();
request.setUsername("testuser");
request.setPassword("482c811da5d5b4bc6d497ffa98491e38");
when(userDataService.getOne(ArgumentMatchers.<LambdaQueryWrapper<SysUser>>any()))
.thenReturn(mockUser);
RuntimeException ex = assertThrows(RuntimeException.class, () -> authService.login(request));
assertEquals("用户已被禁用", ex.getMessage());
}
@Test
@DisplayName("登出成功 - 清除所有 Token")
void logout_success() {
doNothing().when(tokenService).deleteAllUserTokens(1L);
authService.logout(1L);
verify(tokenService).deleteAllUserTokens(1L);
}
@Test
@DisplayName("刷新 Token 成功")
void refreshToken_success() {
when(userDataService.getById(1L)).thenReturn(mockUser);
when(tokenService.generateToken(1L, "testuser", 100L)).thenReturn("new-token");
LoginVO result = authService.refreshToken(1L);
assertNotNull(result);
assertEquals("new-token", result.getToken());
}
@Test
@DisplayName("刷新 Token 失败 - 用户不存在")
void refreshToken_fail_userNotFound() {
when(userDataService.getById(99L)).thenReturn(null);
RuntimeException ex = assertThrows(RuntimeException.class, () -> authService.refreshToken(99L));
assertEquals("用户不存在", ex.getMessage());
}
@Test
@DisplayName("刷新 Token 失败 - 用户已被禁用")
void refreshToken_fail_userDisabled() {
mockUser.setStatus(0);
when(userDataService.getById(1L)).thenReturn(mockUser);
RuntimeException ex = assertThrows(RuntimeException.class, () -> authService.refreshToken(1L));
assertEquals("用户已被禁用", ex.getMessage());
}
@Test
@DisplayName("获取用户信息成功")
void getUserInfo_success() {
mockUser.setRealName("测试用户");
mockUser.setPhone("13800138000");
mockUser.setEmail("test@example.com");
when(userDataService.getById(1L)).thenReturn(mockUser);
UserVO result = authService.getUserInfo(1L);
assertNotNull(result);
assertEquals(1L, result.getId());
assertEquals("testuser", result.getUsername());
assertEquals("测试用户", result.getRealName());
}
@Test
@DisplayName("获取用户信息失败 - 用户不存在")
void getUserInfo_fail_userNotFound() {
when(userDataService.getById(99L)).thenReturn(null);
RuntimeException ex = assertThrows(RuntimeException.class, () -> authService.getUserInfo(99L));
assertEquals("用户不存在", ex.getMessage());
}
}

View File

@ -0,0 +1,312 @@
package com.fundplatform.sys.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fundplatform.sys.data.entity.SysRole;
import com.fundplatform.sys.data.service.SysRoleDataService;
import com.fundplatform.sys.dto.RoleDTO;
import com.fundplatform.sys.vo.RoleVO;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentMatchers;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* 角色服务单元测试
*/
@ExtendWith(MockitoExtension.class)
class RoleServiceImplTest {
@Mock
private SysRoleDataService roleDataService;
@InjectMocks
private RoleServiceImpl roleService;
private SysRole mockRole;
@BeforeAll
static void initMybatisPlusLambdaCache() {
MapperBuilderAssistant assistant = new MapperBuilderAssistant(
new org.apache.ibatis.session.Configuration(), "");
TableInfoHelper.initTableInfo(assistant, SysRole.class);
}
@BeforeEach
void setUp() {
mockRole = new SysRole();
mockRole.setId(1L);
mockRole.setRoleCode("ADMIN");
mockRole.setRoleName("管理员");
mockRole.setDataScope(1);
mockRole.setStatus(1);
mockRole.setSortOrder(1);
mockRole.setDeleted(0);
mockRole.setTenantId(100L);
}
// ==================== createRole ====================
@Test
@DisplayName("创建角色成功")
void createRole_success() {
RoleDTO dto = new RoleDTO();
dto.setRoleCode("OPERATOR");
dto.setRoleName("操作员");
dto.setDataScope(2);
dto.setStatus(1);
when(roleDataService.count(ArgumentMatchers.<LambdaQueryWrapper<SysRole>>any())).thenReturn(0L);
when(roleDataService.save(any(SysRole.class))).thenReturn(true);
assertDoesNotThrow(() -> roleService.createRole(dto));
verify(roleDataService).save(any(SysRole.class));
}
@Test
@DisplayName("创建角色失败 - 角色编码已存在")
void createRole_fail_roleCodeExists() {
RoleDTO dto = new RoleDTO();
dto.setRoleCode("ADMIN");
dto.setRoleName("管理员副本");
when(roleDataService.count(ArgumentMatchers.<LambdaQueryWrapper<SysRole>>any())).thenReturn(1L);
RuntimeException ex = assertThrows(RuntimeException.class, () -> roleService.createRole(dto));
assertEquals("角色编码已存在", ex.getMessage());
verify(roleDataService, never()).save(any());
}
@Test
@DisplayName("创建角色 - 默认启用状态为1")
void createRole_defaultStatus() {
RoleDTO dto = new RoleDTO();
dto.setRoleCode("VIEWER");
dto.setRoleName("查看者");
// status dataScope 不传使用默认值
when(roleDataService.count(ArgumentMatchers.<LambdaQueryWrapper<SysRole>>any())).thenReturn(0L);
when(roleDataService.save(any(SysRole.class))).thenReturn(true);
assertDoesNotThrow(() -> roleService.createRole(dto));
}
// ==================== updateRole ====================
@Test
@DisplayName("更新角色成功")
void updateRole_success() {
RoleDTO dto = new RoleDTO();
dto.setId(1L);
dto.setRoleName("超级管理员");
dto.setDataScope(1);
dto.setStatus(1);
when(roleDataService.getById(1L)).thenReturn(mockRole);
when(roleDataService.updateById(any(SysRole.class))).thenReturn(true);
boolean result = roleService.updateRole(dto);
assertTrue(result);
verify(roleDataService).updateById(any(SysRole.class));
}
@Test
@DisplayName("更新角色失败 - ID为空")
void updateRole_fail_idNull() {
RoleDTO dto = new RoleDTO();
dto.setId(null);
RuntimeException ex = assertThrows(RuntimeException.class, () -> roleService.updateRole(dto));
assertEquals("角色ID不能为空", ex.getMessage());
}
@Test
@DisplayName("更新角色失败 - 角色不存在")
void updateRole_fail_notFound() {
RoleDTO dto = new RoleDTO();
dto.setId(99L);
dto.setRoleName("不存在的角色");
when(roleDataService.getById(99L)).thenReturn(null);
RuntimeException ex = assertThrows(RuntimeException.class, () -> roleService.updateRole(dto));
assertEquals("角色不存在", ex.getMessage());
}
@Test
@DisplayName("更新角色失败 - 角色已删除")
void updateRole_fail_deleted() {
mockRole.setDeleted(1);
RoleDTO dto = new RoleDTO();
dto.setId(1L);
dto.setRoleName("已删除角色");
when(roleDataService.getById(1L)).thenReturn(mockRole);
RuntimeException ex = assertThrows(RuntimeException.class, () -> roleService.updateRole(dto));
assertEquals("角色不存在", ex.getMessage());
}
// ==================== getRoleById ====================
@Test
@DisplayName("根据ID查询角色成功")
void getRoleById_success() {
when(roleDataService.getById(1L)).thenReturn(mockRole);
RoleVO vo = roleService.getRoleById(1L);
assertNotNull(vo);
assertEquals(1L, vo.getId());
assertEquals("ADMIN", vo.getRoleCode());
assertEquals("管理员", vo.getRoleName());
assertEquals("全部数据", vo.getDataScopeName());
}
@Test
@DisplayName("根据ID查询角色失败 - 不存在")
void getRoleById_fail_notFound() {
when(roleDataService.getById(99L)).thenReturn(null);
RuntimeException ex = assertThrows(RuntimeException.class, () -> roleService.getRoleById(99L));
assertEquals("角色不存在", ex.getMessage());
}
@Test
@DisplayName("根据ID查询角色失败 - 已删除")
void getRoleById_fail_deleted() {
mockRole.setDeleted(1);
when(roleDataService.getById(1L)).thenReturn(mockRole);
RuntimeException ex = assertThrows(RuntimeException.class, () -> roleService.getRoleById(1L));
assertEquals("角色不存在", ex.getMessage());
}
// ==================== pageRoles ====================
@Test
@DisplayName("分页查询角色成功")
void pageRoles_success() {
Page<SysRole> mockPage = new Page<>(1, 10);
mockPage.setRecords(List.of(mockRole));
mockPage.setTotal(1);
when(roleDataService.page(any(Page.class), ArgumentMatchers.<LambdaQueryWrapper<SysRole>>any()))
.thenReturn(mockPage);
Page<RoleVO> result = roleService.pageRoles(1, 10, null, null);
assertNotNull(result);
assertEquals(1, result.getTotal());
assertEquals(1, result.getRecords().size());
assertEquals("ADMIN", result.getRecords().get(0).getRoleCode());
}
@Test
@DisplayName("分页查询角色 - 带关键词过滤")
void pageRoles_withFilter() {
Page<SysRole> mockPage = new Page<>(1, 10);
mockPage.setRecords(List.of(mockRole));
mockPage.setTotal(1);
when(roleDataService.page(any(Page.class), ArgumentMatchers.<LambdaQueryWrapper<SysRole>>any()))
.thenReturn(mockPage);
Page<RoleVO> result = roleService.pageRoles(1, 10, "管理", 1);
assertEquals(1, result.getTotal());
}
// ==================== listAllRoles ====================
@Test
@DisplayName("获取所有启用角色列表")
void listAllRoles_success() {
when(roleDataService.list(ArgumentMatchers.<LambdaQueryWrapper<SysRole>>any()))
.thenReturn(List.of(mockRole));
List<RoleVO> result = roleService.listAllRoles();
assertNotNull(result);
assertEquals(1, result.size());
}
@Test
@DisplayName("获取所有启用角色列表 - 空列表")
void listAllRoles_empty() {
when(roleDataService.list(ArgumentMatchers.<LambdaQueryWrapper<SysRole>>any()))
.thenReturn(List.of());
List<RoleVO> result = roleService.listAllRoles();
assertNotNull(result);
assertTrue(result.isEmpty());
}
// ==================== deleteRole ====================
@Test
@DisplayName("删除角色成功 - 逻辑删除")
void deleteRole_success() {
when(roleDataService.update(ArgumentMatchers.<LambdaUpdateWrapper<SysRole>>any())).thenReturn(true);
boolean result = roleService.deleteRole(1L);
assertTrue(result);
}
// ==================== updateStatus ====================
@Test
@DisplayName("更新角色状态成功")
void updateStatus_success() {
when(roleDataService.update(ArgumentMatchers.<LambdaUpdateWrapper<SysRole>>any())).thenReturn(true);
boolean result = roleService.updateStatus(1L, 0);
assertTrue(result);
}
// ==================== dataScopeName ====================
@Test
@DisplayName("数据范围名称转换 - 各枚举值正确")
void getRoleById_dataScopeNames() {
int[] scopes = {1, 2, 3, 4};
String[] names = {"全部数据", "本部门数据", "本部门及下级数据", "仅本人数据"};
for (int i = 0; i < scopes.length; i++) {
mockRole.setDataScope(scopes[i]);
mockRole.setDeleted(0);
when(roleDataService.getById((long) (i + 10))).thenReturn(mockRole);
RoleVO vo = roleService.getRoleById((long) (i + 10));
assertEquals(names[i], vo.getDataScopeName(), "dataScope=" + scopes[i] + " 的名称应为 " + names[i]);
}
}
@Test
@DisplayName("数据范围名称转换 - 未知值返回空字符串")
void getRoleById_unknownDataScope() {
mockRole.setDataScope(99);
when(roleDataService.getById(1L)).thenReturn(mockRole);
RoleVO vo = roleService.getRoleById(1L);
assertEquals("", vo.getDataScopeName());
}
}

View File

@ -0,0 +1,348 @@
package com.fundplatform.sys.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fundplatform.common.context.UserContextHolder;
import com.fundplatform.sys.data.entity.SysTenant;
import com.fundplatform.sys.data.service.SysTenantDataService;
import com.fundplatform.sys.dto.TenantDTO;
import com.fundplatform.sys.vo.TenantVO;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentMatchers;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* 租户服务单元测试
*/
@ExtendWith(MockitoExtension.class)
class TenantServiceImplTest {
@Mock
private SysTenantDataService tenantDataService;
@InjectMocks
private TenantServiceImpl tenantService;
private SysTenant mockTenant;
@BeforeAll
static void initMybatisPlusLambdaCache() {
MapperBuilderAssistant assistant = new MapperBuilderAssistant(
new org.apache.ibatis.session.Configuration(), "");
TableInfoHelper.initTableInfo(assistant, SysTenant.class);
}
@BeforeEach
void setUp() {
// 设置 UserContextHolderTenantServiceImpl 调用 UserContextHolder.getUserId()
UserContextHolder.setUserId(1L);
mockTenant = new SysTenant();
mockTenant.setId(1L);
mockTenant.setTenantCode("TENANT001");
mockTenant.setTenantName("测试租户");
mockTenant.setContact("张三");
mockTenant.setPhone("13800138000");
mockTenant.setEmail("tenant@example.com");
mockTenant.setAddress("北京市");
mockTenant.setStatus(1);
mockTenant.setMaxUsers(10);
mockTenant.setDeleted(0);
mockTenant.setExpireTime(LocalDateTime.now().plusYears(1));
}
@AfterEach
void tearDown() {
UserContextHolder.clear();
}
// ==================== createTenant ====================
@Test
@DisplayName("创建租户成功")
void createTenant_success() {
TenantDTO dto = new TenantDTO();
dto.setTenantCode("NEWTENANT");
dto.setTenantName("新租户");
dto.setStatus(1);
dto.setMaxUsers(20);
when(tenantDataService.count(ArgumentMatchers.<LambdaQueryWrapper<SysTenant>>any())).thenReturn(0L);
when(tenantDataService.save(any(SysTenant.class))).thenReturn(true);
assertDoesNotThrow(() -> tenantService.createTenant(dto));
verify(tenantDataService).save(any(SysTenant.class));
}
@Test
@DisplayName("创建租户失败 - 租户编码已存在")
void createTenant_fail_codeExists() {
TenantDTO dto = new TenantDTO();
dto.setTenantCode("TENANT001");
dto.setTenantName("重复租户");
when(tenantDataService.count(ArgumentMatchers.<LambdaQueryWrapper<SysTenant>>any())).thenReturn(1L);
RuntimeException ex = assertThrows(RuntimeException.class, () -> tenantService.createTenant(dto));
assertEquals("租户编码已存在", ex.getMessage());
verify(tenantDataService, never()).save(any());
}
@Test
@DisplayName("创建租户 - 默认 maxUsers 为 10")
void createTenant_defaultMaxUsers() {
TenantDTO dto = new TenantDTO();
dto.setTenantCode("NEWTENANT2");
dto.setTenantName("默认配额租户");
// maxUsers 不传
when(tenantDataService.count(ArgumentMatchers.<LambdaQueryWrapper<SysTenant>>any())).thenReturn(0L);
when(tenantDataService.save(any(SysTenant.class))).thenReturn(true);
assertDoesNotThrow(() -> tenantService.createTenant(dto));
}
// ==================== updateTenant ====================
@Test
@DisplayName("更新租户成功")
void updateTenant_success() {
TenantDTO dto = new TenantDTO();
dto.setId(1L);
dto.setTenantCode("TENANT001"); // 编码不变
dto.setTenantName("更新的租户名");
dto.setContact("李四");
dto.setStatus(1);
when(tenantDataService.getById(1L)).thenReturn(mockTenant);
when(tenantDataService.updateById(any(SysTenant.class))).thenReturn(true);
boolean result = tenantService.updateTenant(dto);
assertTrue(result);
}
@Test
@DisplayName("更新租户失败 - ID为空")
void updateTenant_fail_idNull() {
TenantDTO dto = new TenantDTO();
dto.setId(null);
RuntimeException ex = assertThrows(RuntimeException.class, () -> tenantService.updateTenant(dto));
assertEquals("租户ID不能为空", ex.getMessage());
}
@Test
@DisplayName("更新租户失败 - 租户不存在")
void updateTenant_fail_notFound() {
TenantDTO dto = new TenantDTO();
dto.setId(99L);
dto.setTenantCode("XXXX");
dto.setTenantName("不存在");
when(tenantDataService.getById(99L)).thenReturn(null);
RuntimeException ex = assertThrows(RuntimeException.class, () -> tenantService.updateTenant(dto));
assertEquals("租户不存在", ex.getMessage());
}
@Test
@DisplayName("更新租户失败 - 租户已删除")
void updateTenant_fail_deleted() {
mockTenant.setDeleted(1);
TenantDTO dto = new TenantDTO();
dto.setId(1L);
dto.setTenantCode("TENANT001");
dto.setTenantName("已删除租户");
when(tenantDataService.getById(1L)).thenReturn(mockTenant);
RuntimeException ex = assertThrows(RuntimeException.class, () -> tenantService.updateTenant(dto));
assertEquals("租户不存在", ex.getMessage());
}
@Test
@DisplayName("更新租户失败 - 更换编码时新编码已被占用")
void updateTenant_fail_newCodeExists() {
TenantDTO dto = new TenantDTO();
dto.setId(1L);
dto.setTenantCode("EXISTING_CODE");
dto.setTenantName("修改编码的租户");
when(tenantDataService.getById(1L)).thenReturn(mockTenant);
when(tenantDataService.count(ArgumentMatchers.<LambdaQueryWrapper<SysTenant>>any())).thenReturn(1L);
RuntimeException ex = assertThrows(RuntimeException.class, () -> tenantService.updateTenant(dto));
assertEquals("租户编码已存在", ex.getMessage());
}
// ==================== getTenantById ====================
@Test
@DisplayName("根据ID查询租户成功")
void getTenantById_success() {
when(tenantDataService.getById(1L)).thenReturn(mockTenant);
TenantVO vo = tenantService.getTenantById(1L);
assertNotNull(vo);
assertEquals(1L, vo.getId());
assertEquals("TENANT001", vo.getTenantCode());
assertEquals("测试租户", vo.getTenantName());
assertEquals("启用", vo.getStatusName());
}
@Test
@DisplayName("根据ID查询租户 - 禁用状态名称为\"禁用\"")
void getTenantById_disabled_statusName() {
mockTenant.setStatus(0);
when(tenantDataService.getById(1L)).thenReturn(mockTenant);
TenantVO vo = tenantService.getTenantById(1L);
assertEquals("禁用", vo.getStatusName());
}
@Test
@DisplayName("根据ID查询租户失败 - 不存在")
void getTenantById_fail_notFound() {
when(tenantDataService.getById(99L)).thenReturn(null);
RuntimeException ex = assertThrows(RuntimeException.class, () -> tenantService.getTenantById(99L));
assertEquals("租户不存在", ex.getMessage());
}
@Test
@DisplayName("根据ID查询租户失败 - 已删除")
void getTenantById_fail_deleted() {
mockTenant.setDeleted(1);
when(tenantDataService.getById(1L)).thenReturn(mockTenant);
RuntimeException ex = assertThrows(RuntimeException.class, () -> tenantService.getTenantById(1L));
assertEquals("租户不存在", ex.getMessage());
}
// ==================== pageTenants ====================
@Test
@DisplayName("分页查询租户成功")
void pageTenants_success() {
Page<SysTenant> mockPage = new Page<>(1, 10);
mockPage.setRecords(List.of(mockTenant));
mockPage.setTotal(1);
when(tenantDataService.page(any(Page.class), ArgumentMatchers.<LambdaQueryWrapper<SysTenant>>any()))
.thenReturn(mockPage);
Page<TenantVO> result = tenantService.pageTenants(1, 10, null);
assertNotNull(result);
assertEquals(1, result.getTotal());
}
@Test
@DisplayName("分页查询租户 - 带关键词")
void pageTenants_withKeyword() {
Page<SysTenant> mockPage = new Page<>(1, 10);
mockPage.setRecords(List.of(mockTenant));
mockPage.setTotal(1);
when(tenantDataService.page(any(Page.class), ArgumentMatchers.<LambdaQueryWrapper<SysTenant>>any()))
.thenReturn(mockPage);
Page<TenantVO> result = tenantService.pageTenants(1, 10, "测试");
assertEquals(1, result.getTotal());
}
// ==================== deleteTenant ====================
@Test
@DisplayName("删除租户成功")
void deleteTenant_success() {
when(tenantDataService.getById(1L)).thenReturn(mockTenant);
when(tenantDataService.update(ArgumentMatchers.<LambdaUpdateWrapper<SysTenant>>any())).thenReturn(true);
boolean result = tenantService.deleteTenant(1L);
assertTrue(result);
}
@Test
@DisplayName("删除租户失败 - 租户不存在")
void deleteTenant_fail_notFound() {
when(tenantDataService.getById(99L)).thenReturn(null);
RuntimeException ex = assertThrows(RuntimeException.class, () -> tenantService.deleteTenant(99L));
assertEquals("租户不存在", ex.getMessage());
}
@Test
@DisplayName("删除租户失败 - 不允许删除默认租户")
void deleteTenant_fail_defaultTenant() {
mockTenant.setTenantCode("DEFAULT");
when(tenantDataService.getById(1L)).thenReturn(mockTenant);
RuntimeException ex = assertThrows(RuntimeException.class, () -> tenantService.deleteTenant(1L));
assertEquals("默认租户不能删除", ex.getMessage());
}
@Test
@DisplayName("删除租户失败 - 已删除的租户")
void deleteTenant_fail_alreadyDeleted() {
mockTenant.setDeleted(1);
when(tenantDataService.getById(1L)).thenReturn(mockTenant);
RuntimeException ex = assertThrows(RuntimeException.class, () -> tenantService.deleteTenant(1L));
assertEquals("租户不存在", ex.getMessage());
}
// ==================== updateStatus ====================
@Test
@DisplayName("更新租户状态成功")
void updateStatus_success() {
when(tenantDataService.getById(1L)).thenReturn(mockTenant);
when(tenantDataService.update(ArgumentMatchers.<LambdaUpdateWrapper<SysTenant>>any())).thenReturn(true);
boolean result = tenantService.updateStatus(1L, 0);
assertTrue(result);
}
@Test
@DisplayName("更新租户状态失败 - 租户不存在")
void updateStatus_fail_notFound() {
when(tenantDataService.getById(99L)).thenReturn(null);
RuntimeException ex = assertThrows(RuntimeException.class, () -> tenantService.updateStatus(99L, 0));
assertEquals("租户不存在", ex.getMessage());
}
@Test
@DisplayName("更新租户状态失败 - 已删除的租户")
void updateStatus_fail_deleted() {
mockTenant.setDeleted(1);
when(tenantDataService.getById(1L)).thenReturn(mockTenant);
RuntimeException ex = assertThrows(RuntimeException.class, () -> tenantService.updateStatus(1L, 0));
assertEquals("租户不存在", ex.getMessage());
}
}