From 455a20c1dfaeae000904b95ca0484419134ee7e1 Mon Sep 17 00:00:00 2001 From: zhangjf Date: Sun, 1 Mar 2026 19:06:42 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E9=A1=B9=E7=9B=AE=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E5=92=8C=E6=B5=8B=E8=AF=95=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增内容: 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(前后端一致) - 登录验证流程优化,支持多租户 - 增加日志输出便于调试 - 代码规范性和可维护性提升 --- AGENTS.md | 33 ++ CLAUDE.md | 165 +++++++++ doc/security-fixes.md | 168 +++++++++ doc/sql/fund_sys_init.sql | 6 +- fund-admin/package.json | 1 + fund-admin/src/views/login/index.vue | 26 +- fund-admin/src/views/profile/index.vue | 9 +- .../common/mybatis/TenantLineHandlerImpl.java | 32 +- .../context/TenantContextHolderTest.java | 76 ++++ .../common/context/UserContextHolderTest.java | 107 ++++++ .../common/core/PageResultTest.java | 91 +++++ .../fundplatform/common/core/ResultTest.java | 125 +++++++ fund-cust/pom.xml | 7 + .../cust/controller/ContactController.java | 26 +- .../cust/controller/CustomerController.java | 21 +- .../gateway/filter/TenantGatewayFilter.java | 6 +- .../gateway/filter/TokenAuthFilter.java | 4 +- fund-mobile/package.json | 1 + fund-mobile/src/views/Login.vue | 6 +- fund-mobile/src/views/my/ChangePassword.vue | 9 +- fund-sys/pom.xml | 14 + .../sys/controller/AuthController.java | 14 +- .../sys/controller/DeptController.java | 17 +- .../sys/controller/MenuController.java | 19 +- .../controller/OperationLogController.java | 23 +- .../sys/controller/ProfileController.java | 13 +- .../sys/controller/RoleController.java | 31 +- .../sys/controller/TenantController.java | 22 +- .../sys/controller/UserController.java | 30 +- .../fundplatform/sys/dto/LoginRequestDTO.java | 15 + .../sys/service/impl/AuthServiceImpl.java | 44 ++- .../sys/service/impl/UserServiceImpl.java | 26 +- .../sys/service/impl/AuthServiceImplTest.java | 185 ++++++++++ .../sys/service/impl/RoleServiceImplTest.java | 312 ++++++++++++++++ .../service/impl/TenantServiceImplTest.java | 348 ++++++++++++++++++ 35 files changed, 1888 insertions(+), 144 deletions(-) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 doc/security-fixes.md create mode 100644 fund-common/src/test/java/com/fundplatform/common/context/TenantContextHolderTest.java create mode 100644 fund-common/src/test/java/com/fundplatform/common/context/UserContextHolderTest.java create mode 100644 fund-common/src/test/java/com/fundplatform/common/core/PageResultTest.java create mode 100644 fund-common/src/test/java/com/fundplatform/common/core/ResultTest.java create mode 100644 fund-sys/src/test/java/com/fundplatform/sys/service/impl/AuthServiceImplTest.java create mode 100644 fund-sys/src/test/java/com/fundplatform/sys/service/impl/RoleServiceImplTest.java create mode 100644 fund-sys/src/test/java/com/fundplatform/sys/service/impl/TenantServiceImplTest.java diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..cd671f2 --- /dev/null +++ b/AGENTS.md @@ -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`。 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..477aa21 --- /dev/null +++ b/CLAUDE.md @@ -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 +├── service/ 业务逻辑 +│ └── impl/ +├── data/ +│ ├── entity/ MyBatis-Plus 实体(对应数据库表) +│ ├── mapper/ Mapper 接口 +│ └── service/ 数据层服务(IService) +├── dto/ 请求参数对象 +├── vo/ 响应视图对象 +├── feign/ Feign 客户端(调用其他服务) +├── aop/ 切面(操作日志等) +└── config/ 模块配置 +``` + +### 统一响应 + +所有接口使用 `fund-common` 中的 `Result` 和 `PageResult`: + +```java +Result.success(data) +Result.success(data, "message") +Result.error("message") +``` + +### 基础实体 + +实体类继承 `BaseEntity`(包含 `id`、`tenantId`、`createdBy`、`createdTime`、`updatedBy`、`updatedTime`、`deleted`)。 + +### 认证机制 + +- Token 基于 UUID,存储在 Redis(Key: `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 getUserById(@PathVariable Long id); +} +``` + +## 注意事项 + +- **fund-gateway** 使用 WebFlux,不能引入 `spring-boot-starter-web`,须排除 fund-common 中的 web 自动配置 +- **租户忽略**:特殊场景(如登录、租户管理)需用 `TenantIgnoreHelper` 标记绕过租户过滤 +- **日志**:使用 Logback + Logstash Encoder,日志格式为 JSON,适配 ELK 收集 diff --git a/doc/security-fixes.md b/doc/security-fixes.md new file mode 100644 index 0000000..0c2a8f2 --- /dev/null +++ b/doc/security-fixes.md @@ -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 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 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 中清理 diff --git a/doc/sql/fund_sys_init.sql b/doc/sql/fund_sys_init.sql index 527d8ab..a34f7df 100644 --- a/doc/sql/fund_sys_init.sql +++ b/doc/sql/fund_sys_init.sql @@ -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) -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()) +-- 插入超级管理员用户 (租户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', '0192023a7bbd73250516f069df18b500', '超级管理员', '13800138000', 1, 1, NOW()) ON DUPLICATE KEY UPDATE username=username; -- 插入超级管理员角色 diff --git a/fund-admin/package.json b/fund-admin/package.json index 6cbe851..2704875 100644 --- a/fund-admin/package.json +++ b/fund-admin/package.json @@ -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" diff --git a/fund-admin/src/views/login/index.vue b/fund-admin/src/views/login/index.vue index ced9e34..ccca43a 100644 --- a/fund-admin/src/views/login/index.vue +++ b/fund-admin/src/views/login/index.vue @@ -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,35 +71,16 @@ 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 - + try { await formRef.value.validate() loading.value = true - + // 密码先进行 MD5 加密 const encryptedPassword = md5(form.password) - console.log('登录 - 原始密码:', form.password, 'MD5 加密后:', encryptedPassword) - + await userStore.loginAction(form.username, encryptedPassword) // 获取用户信息 diff --git a/fund-admin/src/views/profile/index.vue b/fund-admin/src/views/profile/index.vue index 1c45c0f..5d56714 100644 --- a/fund-admin/src/views/profile/index.vue +++ b/fund-admin/src/views/profile/index.vue @@ -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' @@ -231,12 +232,16 @@ const handleUpdateProfile = async () => { const handleUpdatePassword = async () => { if (!passwordFormRef.value) return - + await passwordFormRef.value.validate(async (valid) => { 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) { diff --git a/fund-common/src/main/java/com/fundplatform/common/mybatis/TenantLineHandlerImpl.java b/fund-common/src/main/java/com/fundplatform/common/mybatis/TenantLineHandlerImpl.java index f24cd5a..b17c46f 100644 --- a/fund-common/src/main/java/com/fundplatform/common/mybatis/TenantLineHandlerImpl.java +++ b/fund-common/src/main/java/com/fundplatform/common/mybatis/TenantLineHandlerImpl.java @@ -31,34 +31,36 @@ public class TenantLineHandlerImpl implements TenantLineHandler { private static final Logger logger = LoggerFactory.getLogger(TenantLineHandlerImpl.class); /** - * 忽略租户过滤的表(系统表、字典表等公共数据) + * 忽略租户过滤的表(仅限平台级公共数据,所有租户共享) + * + *

安全说明:仅将真正全平台共享的静态配置表列为忽略表。

+ *

sys_user/sys_role/sys_dept 均属于租户私有数据,含 tenant_id 字段, + * 不得加入此列表,否则会导致跨租户数据泄漏。

*/ private static final Set 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 值 - * - *

从 TenantContextHolder 获取当前线程的租户 ID

+ * + *

从 TenantContextHolder 获取当前线程的租户 ID。

+ *

安全修复:不再使用 fallback 默认值 1L,若租户上下文为空则直接报错, + * 防止在缺少认证上下文的情况下误操作租户1的数据。

*/ @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); return new LongValue(tenantId); } diff --git a/fund-common/src/test/java/com/fundplatform/common/context/TenantContextHolderTest.java b/fund-common/src/test/java/com/fundplatform/common/context/TenantContextHolderTest.java new file mode 100644 index 0000000..ab41a39 --- /dev/null +++ b/fund-common/src/test/java/com/fundplatform/common/context/TenantContextHolderTest.java @@ -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()); + } +} diff --git a/fund-common/src/test/java/com/fundplatform/common/context/UserContextHolderTest.java b/fund-common/src/test/java/com/fundplatform/common/context/UserContextHolderTest.java new file mode 100644 index 0000000..72eee58 --- /dev/null +++ b/fund-common/src/test/java/com/fundplatform/common/context/UserContextHolderTest.java @@ -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()); + } +} diff --git a/fund-common/src/test/java/com/fundplatform/common/core/PageResultTest.java b/fund-common/src/test/java/com/fundplatform/common/core/PageResultTest.java new file mode 100644 index 0000000..3ca9b4b --- /dev/null +++ b/fund-common/src/test/java/com/fundplatform/common/core/PageResultTest.java @@ -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 pageResult = new PageResult<>(); + + assertNotNull(pageResult.getRecords()); + assertTrue(pageResult.getRecords().isEmpty()); + } + + @Test + @DisplayName("全参构造函数 - 正确设置所有字段") + void fullConstructor() { + List records = Arrays.asList("a", "b", "c"); + PageResult 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 pageResult = new PageResult<>(1, 10, 0, null); + + assertNotNull(pageResult.getRecords()); + assertTrue(pageResult.getRecords().isEmpty()); + } + + @Test + @DisplayName("setter/getter - 可正常设置和获取所有字段") + void settersAndGetters() { + PageResult 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 pageResult = new PageResult<>(1, 10, 0, Collections.emptyList()); + + assertEquals(0, pageResult.getTotal()); + assertTrue(pageResult.getRecords().isEmpty()); + } + + @Test + @DisplayName("记录数量与 total 可以不一致(当前页可为最后一页不满页)") + void records_lessThanPageSize() { + List records = List.of("x", "y"); + PageResult pageResult = new PageResult<>(5, 10, 42, records); + + assertEquals(42, pageResult.getTotal()); + assertEquals(2, pageResult.getRecords().size()); + } + + @Test + @DisplayName("setRecords - 可覆盖原有记录") + void setRecords_override() { + PageResult 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)); + } +} diff --git a/fund-common/src/test/java/com/fundplatform/common/core/ResultTest.java b/fund-common/src/test/java/com/fundplatform/common/core/ResultTest.java new file mode 100644 index 0000000..cc3c385 --- /dev/null +++ b/fund-common/src/test/java/com/fundplatform/common/core/ResultTest.java @@ -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 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 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 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 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 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 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 result = new Result<>(200, "ok", null); + assertTrue(result.isSuccess()); + } + + @Test + @DisplayName("isSuccess() - code!=200 时返回 false") + void isSuccess_false() { + Result result = new Result<>(500, "error", null); + assertFalse(result.isSuccess()); + } + + @Test + @DisplayName("无参构造函数 + setter - 可正常设置字段") + void defaultConstructor_withSetters() { + Result 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 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); + } +} diff --git a/fund-cust/pom.xml b/fund-cust/pom.xml index b0542a4..1309f87 100644 --- a/fund-cust/pom.xml +++ b/fund-cust/pom.xml @@ -61,6 +61,13 @@ 7.4 + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.3.0 + + com.alibaba.cloud diff --git a/fund-cust/src/main/java/com/fundplatform/cust/controller/ContactController.java b/fund-cust/src/main/java/com/fundplatform/cust/controller/ContactController.java index 2df7c05..2ba9bc3 100644 --- a/fund-cust/src/main/java/com/fundplatform/cust/controller/ContactController.java +++ b/fund-cust/src/main/java/com/fundplatform/cust/controller/ContactController.java @@ -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 create(@Valid @RequestBody ContactDTO dto) { Long id = contactService.createContact(dto); @@ -33,8 +38,9 @@ public class ContactController { /** * 更新联系人 */ + @Operation(summary = "更新联系人信息") @PutMapping("/{id}") - public Result update(@PathVariable Long id, @Valid @RequestBody ContactDTO dto) { + public Result 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 getById(@PathVariable Long id) { + public Result 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( - @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 page = contactService.pageContacts(pageNum, pageSize, customerId); return Result.success(page); } @@ -63,8 +71,9 @@ public class ContactController { /** * 删除联系人 */ + @Operation(summary = "删除联系人") @DeleteMapping("/{id}") - public Result delete(@PathVariable Long id) { + public Result 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 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); } diff --git a/fund-cust/src/main/java/com/fundplatform/cust/controller/CustomerController.java b/fund-cust/src/main/java/com/fundplatform/cust/controller/CustomerController.java index 0f846ad..a91d7f7 100644 --- a/fund-cust/src/main/java/com/fundplatform/cust/controller/CustomerController.java +++ b/fund-cust/src/main/java/com/fundplatform/cust/controller/CustomerController.java @@ -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 createCustomer(@Valid @RequestBody CustomerCreateDTO dto) { Long id = customerService.createCustomer(dto); @@ -35,8 +40,9 @@ public class CustomerController { /** * 更新客户 */ + @Operation(summary = "更新客户信息") @PutMapping("/{id}") - public Result updateCustomer(@PathVariable Long id, @Valid @RequestBody CustomerUpdateDTO dto) { + public Result 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 getCustomer(@PathVariable Long id) { + public Result 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> 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 page = customerService.pageCustomers(pageNum, pageSize, keyword); PageResult pageResult = new PageResult<>( @@ -72,8 +80,9 @@ public class CustomerController { /** * 删除客户 */ + @Operation(summary = "删除客户(逻辑删除)") @DeleteMapping("/{id}") - public Result deleteCustomer(@PathVariable Long id) { + public Result deleteCustomer(@Parameter(description = "客户ID") @PathVariable Long id) { customerService.deleteCustomer(id); return Result.success(); } diff --git a/fund-gateway/src/main/java/com/fundplatform/gateway/filter/TenantGatewayFilter.java b/fund-gateway/src/main/java/com/fundplatform/gateway/filter/TenantGatewayFilter.java index 032e57f..432cf28 100644 --- a/fund-gateway/src/main/java/com/fundplatform/gateway/filter/TenantGatewayFilter.java +++ b/fund-gateway/src/main/java/com/fundplatform/gateway/filter/TenantGatewayFilter.java @@ -66,13 +66,15 @@ public class TenantGatewayFilter implements GlobalFilter, Ordered { return chain.filter(exchange); } - // 检查X-Tenant-Id请求头 + // 安全修复:从请求头中取 X-Tenant-Id(TokenAuthFilter 已用 Token 中的值覆盖客户端传入值) + // 因此此处取到的必为 Token 认证后的租户ID,无需再与客户端值单独比对。 + // 但仍需校验格式合法性,防止异常数据流入下游服务。 String tenantId = request.getHeaders().getFirst(HEADER_TENANT_ID); if (tenantId == null || tenantId.trim().isEmpty()) { logger.warn("[TenantGateway] 缺少X-Tenant-Id请求头,路径: {}", path); return missingTenantId(exchange, "缺少X-Tenant-Id请求头"); } - + // 验证租户ID是否为有效数字 try { Long.parseLong(tenantId); diff --git a/fund-gateway/src/main/java/com/fundplatform/gateway/filter/TokenAuthFilter.java b/fund-gateway/src/main/java/com/fundplatform/gateway/filter/TokenAuthFilter.java index 9bda7ab..4ed7a89 100644 --- a/fund-gateway/src/main/java/com/fundplatform/gateway/filter/TokenAuthFilter.java +++ b/fund-gateway/src/main/java/com/fundplatform/gateway/filter/TokenAuthFilter.java @@ -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())) diff --git a/fund-mobile/package.json b/fund-mobile/package.json index 216dcba..5fa8727 100644 --- a/fund-mobile/package.json +++ b/fund-mobile/package.json @@ -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", diff --git a/fund-mobile/src/views/Login.vue b/fund-mobile/src/views/Login.vue index 2787f08..4c589f5 100644 --- a/fund-mobile/src/views/Login.vue +++ b/fund-mobile/src/views/Login.vue @@ -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({ diff --git a/fund-mobile/src/views/my/ChangePassword.vue b/fund-mobile/src/views/my/ChangePassword.vue index 70fff4b..c22ef6c 100644 --- a/fund-mobile/src/views/my/ChangePassword.vue +++ b/fund-mobile/src/views/my/ChangePassword.vue @@ -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) @@ -92,11 +93,11 @@ 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('密码修改成功') diff --git a/fund-sys/pom.xml b/fund-sys/pom.xml index 543325b..b758e2b 100644 --- a/fund-sys/pom.xml +++ b/fund-sys/pom.xml @@ -112,6 +112,20 @@ logstash-logback-encoder 7.4 + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.3.0 + + + + + org.springframework.boot + spring-boot-starter-test + test + diff --git a/fund-sys/src/main/java/com/fundplatform/sys/controller/AuthController.java b/fund-sys/src/main/java/com/fundplatform/sys/controller/AuthController.java index 70a1878..3fa3e54 100644 --- a/fund-sys/src/main/java/com/fundplatform/sys/controller/AuthController.java +++ b/fund-sys/src/main/java/com/fundplatform/sys/controller/AuthController.java @@ -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 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 logout(@RequestHeader(value = "X-User-Id", required = false) Long userId) { + public Result 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 refreshToken(@RequestHeader("X-User-Id") Long userId) { + public Result 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 getUserInfo(@RequestHeader("X-User-Id") Long userId) { + public Result getUserInfo(@Parameter(description = "当前登录用户ID") @RequestHeader("X-User-Id") Long userId) { UserVO vo = authService.getUserInfo(userId); return Result.success(vo); } diff --git a/fund-sys/src/main/java/com/fundplatform/sys/controller/DeptController.java b/fund-sys/src/main/java/com/fundplatform/sys/controller/DeptController.java index 6396f48..b042729 100644 --- a/fund-sys/src/main/java/com/fundplatform/sys/controller/DeptController.java +++ b/fund-sys/src/main/java/com/fundplatform/sys/controller/DeptController.java @@ -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 create(@Valid @RequestBody DeptDTO dto) { Long deptId = deptService.createDept(dto); return Result.success(deptId); } + @Operation(summary = "更新部门信息") @PutMapping public Result update(@Valid @RequestBody DeptDTO dto) { boolean result = deptService.updateDept(dto); return Result.success(result); } + @Operation(summary = "根据ID查询部门") @GetMapping("/{id}") - public Result getById(@PathVariable Long id) { + public Result getById(@Parameter(description = "部门ID") @PathVariable Long id) { DeptVO vo = deptService.getDeptById(id); return Result.success(vo); } + @Operation(summary = "获取部门树形结构") @GetMapping("/tree") public Result> getTree() { List tree = deptService.getDeptTree(); return Result.success(tree); } + @Operation(summary = "查询所有部门列表(扁平)") @GetMapping("/list") public Result> listAll() { List list = deptService.listAllDepts(); return Result.success(list); } + @Operation(summary = "删除部门") @DeleteMapping("/{id}") - public Result delete(@PathVariable Long id) { + public Result delete(@Parameter(description = "部门ID") @PathVariable Long id) { boolean result = deptService.deleteDept(id); return Result.success(result); } + @Operation(summary = "更新部门状态") @PutMapping("/{id}/status") - public Result updateStatus(@PathVariable Long id, @RequestParam Integer status) { + public Result updateStatus(@Parameter(description = "部门ID") @PathVariable Long id, @Parameter(description = "状态:0禁用 1启用") @RequestParam Integer status) { boolean result = deptService.updateStatus(id, status); return Result.success(result); } diff --git a/fund-sys/src/main/java/com/fundplatform/sys/controller/MenuController.java b/fund-sys/src/main/java/com/fundplatform/sys/controller/MenuController.java index d0990f5..7c1d870 100644 --- a/fund-sys/src/main/java/com/fundplatform/sys/controller/MenuController.java +++ b/fund-sys/src/main/java/com/fundplatform/sys/controller/MenuController.java @@ -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 create(@Valid @RequestBody MenuDTO dto) { Long menuId = menuService.createMenu(dto); return Result.success(menuId); } + @Operation(summary = "更新菜单/权限信息") @PutMapping public Result update(@Valid @RequestBody MenuDTO dto) { boolean result = menuService.updateMenu(dto); return Result.success(result); } + @Operation(summary = "根据ID查询菜单") @GetMapping("/{id}") - public Result getById(@PathVariable Long id) { + public Result getById(@Parameter(description = "菜单ID") @PathVariable Long id) { MenuVO vo = menuService.getMenuById(id); return Result.success(vo); } + @Operation(summary = "获取菜单树(全量)") @GetMapping("/tree") public Result> getTree() { List tree = menuService.getMenuTree(); return Result.success(tree); } + @Operation(summary = "获取用户菜单树", description = "根据用户ID获取其有权访问的菜单树") @GetMapping("/user/{userId}") - public Result> getUserTree(@PathVariable Long userId) { + public Result> getUserTree(@Parameter(description = "用户ID") @PathVariable Long userId) { List tree = menuService.getUserMenuTree(userId); return Result.success(tree); } + @Operation(summary = "删除菜单/权限") @DeleteMapping("/{id}") - public Result delete(@PathVariable Long id) { + public Result 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> getUserPermissions(@PathVariable Long userId) { + public Result> getUserPermissions(@Parameter(description = "用户ID") @PathVariable Long userId) { List permissions = menuService.getUserPermissions(userId); return Result.success(permissions); } diff --git a/fund-sys/src/main/java/com/fundplatform/sys/controller/OperationLogController.java b/fund-sys/src/main/java/com/fundplatform/sys/controller/OperationLogController.java index 7fc078c..4a6e413 100644 --- a/fund-sys/src/main/java/com/fundplatform/sys/controller/OperationLogController.java +++ b/fund-sys/src/main/java/com/fundplatform/sys/controller/OperationLogController.java @@ -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( - @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 getById(@PathVariable Long id) { + public Result getById(@Parameter(description = "日志ID") @PathVariable Long id) { return Result.success(operationLogService.getById(id)); } /** * 清理历史日志 */ + @Operation(summary = "清理历史操作日志", description = "删除N天前的操作日志,默认90天") @DeleteMapping("/clean") - public Result cleanLogs(@RequestParam(defaultValue = "90") int days) { + public Result cleanLogs(@Parameter(description = "保留天数,默认90天") @RequestParam(defaultValue = "90") int days) { return Result.success(operationLogService.cleanLogs(days)); } } diff --git a/fund-sys/src/main/java/com/fundplatform/sys/controller/ProfileController.java b/fund-sys/src/main/java/com/fundplatform/sys/controller/ProfileController.java index 0e4a39a..5ee106c 100644 --- a/fund-sys/src/main/java/com/fundplatform/sys/controller/ProfileController.java +++ b/fund-sys/src/main/java/com/fundplatform/sys/controller/ProfileController.java @@ -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 getProfile(@RequestHeader("X-User-Id") Long userId) { + public Result 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 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 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); diff --git a/fund-sys/src/main/java/com/fundplatform/sys/controller/RoleController.java b/fund-sys/src/main/java/com/fundplatform/sys/controller/RoleController.java index 5fb4f03..e3df57b 100644 --- a/fund-sys/src/main/java/com/fundplatform/sys/controller/RoleController.java +++ b/fund-sys/src/main/java/com/fundplatform/sys/controller/RoleController.java @@ -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 create(@Valid @RequestBody RoleDTO dto) { Long roleId = roleService.createRole(dto); return Result.success(roleId); } + @Operation(summary = "更新角色信息") @PutMapping public Result update(@Valid @RequestBody RoleDTO dto) { boolean result = roleService.updateRole(dto); return Result.success(result); } + @Operation(summary = "根据ID查询角色") @GetMapping("/{id}") - public Result getById(@PathVariable Long id) { + public Result getById(@Parameter(description = "角色ID") @PathVariable Long id) { RoleVO vo = roleService.getRoleById(id); return Result.success(vo); } + @Operation(summary = "分页查询角色") @GetMapping("/page") public Result> 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 page = roleService.pageRoles(pageNum, pageSize, roleName, status); return Result.success(page); } + @Operation(summary = "查询所有角色列表") @GetMapping("/list") public Result> listAll() { List list = roleService.listAllRoles(); return Result.success(list); } + @Operation(summary = "删除角色") @DeleteMapping("/{id}") - public Result delete(@PathVariable Long id) { + public Result delete(@Parameter(description = "角色ID") @PathVariable Long id) { boolean result = roleService.deleteRole(id); return Result.success(result); } + @Operation(summary = "更新角色状态") @PutMapping("/{id}/status") - public Result updateStatus(@PathVariable Long id, @RequestParam Integer status) { + public Result 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> getRoleMenus(@PathVariable Long id) { + public Result> getRoleMenus(@Parameter(description = "角色ID") @PathVariable Long id) { List menuIds = roleService.getRoleMenus(id); return Result.success(menuIds); } @@ -81,8 +93,9 @@ public class RoleController { /** * 分配菜单给角色 */ + @Operation(summary = "为角色分配菜单权限") @PutMapping("/{id}/menus") - public Result assignMenus(@PathVariable Long id, @RequestBody List menuIds) { + public Result assignMenus(@Parameter(description = "角色ID") @PathVariable Long id, @RequestBody List menuIds) { boolean result = roleService.assignMenus(id, menuIds); return Result.success(result); } diff --git a/fund-sys/src/main/java/com/fundplatform/sys/controller/TenantController.java b/fund-sys/src/main/java/com/fundplatform/sys/controller/TenantController.java index 8f18b7f..6bd1eb4 100644 --- a/fund-sys/src/main/java/com/fundplatform/sys/controller/TenantController.java +++ b/fund-sys/src/main/java/com/fundplatform/sys/controller/TenantController.java @@ -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 create(@Valid @RequestBody TenantDTO dto) { return Result.success(tenantService.createTenant(dto)); } + @Operation(summary = "更新租户信息") @PutMapping public Result update(@Valid @RequestBody TenantDTO dto) { return Result.success(tenantService.updateTenant(dto)); } + @Operation(summary = "根据ID查询租户") @GetMapping("/{id}") - public Result getById(@PathVariable Long id) { + public Result getById(@Parameter(description = "租户ID") @PathVariable Long id) { return Result.success(tenantService.getTenantById(id)); } + @Operation(summary = "分页查询租户") @GetMapping("/page") public Result> 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 delete(@PathVariable Long id) { + public Result delete(@Parameter(description = "租户ID") @PathVariable Long id) { return Result.success(tenantService.deleteTenant(id)); } + @Operation(summary = "更新租户状态") @PutMapping("/{id}/status") - public Result updateStatus(@PathVariable Long id, @RequestParam Integer status) { + public Result updateStatus(@Parameter(description = "租户ID") @PathVariable Long id, @Parameter(description = "状态:0禁用 1启用 2过期") @RequestParam Integer status) { return Result.success(tenantService.updateStatus(id, status)); } } diff --git a/fund-sys/src/main/java/com/fundplatform/sys/controller/UserController.java b/fund-sys/src/main/java/com/fundplatform/sys/controller/UserController.java index 39cc545..ceb58d5 100644 --- a/fund-sys/src/main/java/com/fundplatform/sys/controller/UserController.java +++ b/fund-sys/src/main/java/com/fundplatform/sys/controller/UserController.java @@ -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 create(@Valid @RequestBody UserDTO dto) { Long userId = userService.createUser(dto); @@ -33,6 +38,7 @@ public class UserController { /** * 更新用户 */ + @Operation(summary = "更新用户信息") @PutMapping public Result 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 getById(@PathVariable Long id) { + public Result 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( - @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 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 delete(@PathVariable Long id) { + public Result 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 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 resetPassword(@PathVariable Long id) { + public Result 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 updateStatus(@PathVariable Long id, @RequestParam Integer status) { + public Result updateStatus(@Parameter(description = "用户ID") @PathVariable Long id, @Parameter(description = "状态:0禁用 1启用") @RequestParam Integer status) { boolean result = userService.updateStatus(id, status); return Result.success(result); } diff --git a/fund-sys/src/main/java/com/fundplatform/sys/dto/LoginRequestDTO.java b/fund-sys/src/main/java/com/fundplatform/sys/dto/LoginRequestDTO.java index 4598360..800f299 100644 --- a/fund-sys/src/main/java/com/fundplatform/sys/dto/LoginRequestDTO.java +++ b/fund-sys/src/main/java/com/fundplatform/sys/dto/LoginRequestDTO.java @@ -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; + } } diff --git a/fund-sys/src/main/java/com/fundplatform/sys/service/impl/AuthServiceImpl.java b/fund-sys/src/main/java/com/fundplatform/sys/service/impl/AuthServiceImpl.java index 7f13599..27cabe9 100644 --- a/fund-sys/src/main/java/com/fundplatform/sys/service/impl/AuthServiceImpl.java +++ b/fund-sys/src/main/java/com/fundplatform/sys/service/impl/AuthServiceImpl.java @@ -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,36 +29,42 @@ public class AuthServiceImpl implements AuthService { @Override public LoginVO login(LoginRequestDTO request) { - // 查询用户 - LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); - wrapper.eq(SysUser::getUsername, request.getUsername()); - wrapper.eq(SysUser::getDeleted, 0); - SysUser user = userDataService.getOne(wrapper); - + // 安全修复:登录时尚未建立租户上下文(白名单路径不经过 TokenAuthFilter), + // 必须使用 TenantIgnoreHelper 跳过 MyBatis-Plus 自动租户过滤, + // 同时显式在查询条件中加入 tenantId,确保只在指定租户范围内匹配用户名, + // 防止不同租户的同名用户发生认证混乱。 + SysUser user = TenantIgnoreHelper.ignore(() -> { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(SysUser::getUsername, request.getUsername()); + wrapper.eq(SysUser::getTenantId, request.getTenantId()); + wrapper.eq(SysUser::getDeleted, 0); + return userDataService.getOne(wrapper); + }); + if (user == null) { - log.error("登录失败 - 用户不存在:username={}", request.getUsername()); + log.error("登录失败 - 用户不存在:username={}, tenantId={}", request.getUsername(), request.getTenantId()); throw new RuntimeException("用户名或密码错误"); } - + // 打印接收到的密码和数据库存储的密码(用于调试) log.info("登录验证 - 前端传来的 MD5 密码:{}, 数据库存储的 MD5 密码:{}", request.getPassword(), user.getPassword()); - + // 直接比对 MD5 值(前端已加密,数据库也是 MD5,无需再次加密) if (!request.getPassword().equals(user.getPassword())) { log.error("登录失败 - 密码错误:username={}", request.getUsername()); throw new RuntimeException("用户名或密码错误"); } - + // 检查用户状态 if (user.getStatus() != 1) { throw new RuntimeException("用户已被禁用"); } - + // 使用 UUID + Redis 生成 Token String token = tokenService.generateToken(user.getId(), user.getUsername(), user.getTenantId()); - + log.info("登录成功:userId={}, username={}, tenantId={}", user.getId(), user.getUsername(), user.getTenantId()); - + // 返回登录信息 return new LoginVO(user.getId(), user.getUsername(), token, user.getTenantId()); } @@ -76,15 +82,15 @@ public class AuthServiceImpl implements AuthService { if (user == null) { throw new RuntimeException("用户不存在"); } - + // 检查用户状态 if (user.getStatus() != 1) { throw new RuntimeException("用户已被禁用"); } - + // 生成新Token String token = tokenService.generateToken(user.getId(), user.getUsername(), user.getTenantId()); - + return new LoginVO(user.getId(), user.getUsername(), token, user.getTenantId()); } @@ -94,7 +100,7 @@ public class AuthServiceImpl implements AuthService { if (user == null) { throw new RuntimeException("用户不存在"); } - + UserVO vo = new UserVO(); vo.setId(user.getId()); vo.setUsername(user.getUsername()); @@ -107,7 +113,7 @@ public class AuthServiceImpl implements AuthService { vo.setTenantId(user.getTenantId()); vo.setCreatedTime(user.getCreatedTime()); vo.setUpdatedTime(user.getUpdatedTime()); - + return vo; } } diff --git a/fund-sys/src/main/java/com/fundplatform/sys/service/impl/UserServiceImpl.java b/fund-sys/src/main/java/com/fundplatform/sys/service/impl/UserServiceImpl.java index 390ba01..d6e4e62 100644 --- a/fund-sys/src/main/java/com/fundplatform/sys/service/impl/UserServiceImpl.java +++ b/fund-sys/src/main/java/com/fundplatform/sys/service/impl/UserServiceImpl.java @@ -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 wrapper = new LambdaUpdateWrapper<>(); wrapper.eq(SysUser::getId, userId); wrapper.set(SysUser::getPassword, md5NewPassword); diff --git a/fund-sys/src/test/java/com/fundplatform/sys/service/impl/AuthServiceImplTest.java b/fund-sys/src/test/java/com/fundplatform/sys/service/impl/AuthServiceImplTest.java new file mode 100644 index 0000000..8266833 --- /dev/null +++ b/fund-sys/src/test/java/com/fundplatform/sys/service/impl/AuthServiceImplTest.java @@ -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.>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.>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.>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.>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()); + } +} diff --git a/fund-sys/src/test/java/com/fundplatform/sys/service/impl/RoleServiceImplTest.java b/fund-sys/src/test/java/com/fundplatform/sys/service/impl/RoleServiceImplTest.java new file mode 100644 index 0000000..0016d72 --- /dev/null +++ b/fund-sys/src/test/java/com/fundplatform/sys/service/impl/RoleServiceImplTest.java @@ -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.>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.>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.>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 mockPage = new Page<>(1, 10); + mockPage.setRecords(List.of(mockRole)); + mockPage.setTotal(1); + + when(roleDataService.page(any(Page.class), ArgumentMatchers.>any())) + .thenReturn(mockPage); + + Page 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 mockPage = new Page<>(1, 10); + mockPage.setRecords(List.of(mockRole)); + mockPage.setTotal(1); + + when(roleDataService.page(any(Page.class), ArgumentMatchers.>any())) + .thenReturn(mockPage); + + Page result = roleService.pageRoles(1, 10, "管理", 1); + + assertEquals(1, result.getTotal()); + } + + // ==================== listAllRoles ==================== + + @Test + @DisplayName("获取所有启用角色列表") + void listAllRoles_success() { + when(roleDataService.list(ArgumentMatchers.>any())) + .thenReturn(List.of(mockRole)); + + List result = roleService.listAllRoles(); + + assertNotNull(result); + assertEquals(1, result.size()); + } + + @Test + @DisplayName("获取所有启用角色列表 - 空列表") + void listAllRoles_empty() { + when(roleDataService.list(ArgumentMatchers.>any())) + .thenReturn(List.of()); + + List result = roleService.listAllRoles(); + + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + // ==================== deleteRole ==================== + + @Test + @DisplayName("删除角色成功 - 逻辑删除") + void deleteRole_success() { + when(roleDataService.update(ArgumentMatchers.>any())).thenReturn(true); + + boolean result = roleService.deleteRole(1L); + + assertTrue(result); + } + + // ==================== updateStatus ==================== + + @Test + @DisplayName("更新角色状态成功") + void updateStatus_success() { + when(roleDataService.update(ArgumentMatchers.>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()); + } +} diff --git a/fund-sys/src/test/java/com/fundplatform/sys/service/impl/TenantServiceImplTest.java b/fund-sys/src/test/java/com/fundplatform/sys/service/impl/TenantServiceImplTest.java new file mode 100644 index 0000000..a0eef40 --- /dev/null +++ b/fund-sys/src/test/java/com/fundplatform/sys/service/impl/TenantServiceImplTest.java @@ -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() { + // 设置 UserContextHolder(TenantServiceImpl 调用 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.>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.>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.>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.>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 mockPage = new Page<>(1, 10); + mockPage.setRecords(List.of(mockTenant)); + mockPage.setTotal(1); + + when(tenantDataService.page(any(Page.class), ArgumentMatchers.>any())) + .thenReturn(mockPage); + + Page result = tenantService.pageTenants(1, 10, null); + + assertNotNull(result); + assertEquals(1, result.getTotal()); + } + + @Test + @DisplayName("分页查询租户 - 带关键词") + void pageTenants_withKeyword() { + Page mockPage = new Page<>(1, 10); + mockPage.setRecords(List.of(mockTenant)); + mockPage.setTotal(1); + + when(tenantDataService.page(any(Page.class), ArgumentMatchers.>any())) + .thenReturn(mockPage); + + Page 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.>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.>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()); + } +}