24 Commits

Author SHA1 Message Date
wkc
1245849f56 统一后端端口为63310 2026-03-30 17:36:49 +08:00
wkc
396a5e6c4c 补充AGENTS协作与测试规范文档 2026-03-30 14:03:14 +08:00
wkc
e89da2cd21 补充模型输出基本信息脱敏 2026-03-30 13:58:10 +08:00
wkc
2eb1e6b8ee 修正贷款定价敏感信息脱敏设计与计划 2026-03-30 13:39:12 +08:00
wkc
51890506b9 补充贷款定价敏感字段前端实施记录 2026-03-30 11:07:09 +08:00
wkc
44f48d5625 调整流程列表按客户内码查询 2026-03-30 11:07:05 +08:00
wkc
f37f2981f9 补充贷款定价敏感字段后端实施记录 2026-03-30 11:04:16 +08:00
wkc
85871e1380 接入流程详情脱敏与模型调用解密 2026-03-30 10:56:02 +08:00
wkc
a1db88e4c7 接入流程敏感字段加密与列表脱敏 2026-03-30 10:54:23 +08:00
wkc
b16a08eb1a 新增贷款定价敏感字段加解密服务 2026-03-30 10:51:44 +08:00
wkc
d7c305b26c 完成密码加密传输前端实现 2026-03-30 10:49:28 +08:00
wkc
8552514dcb 接入用户密码接口前端加密 2026-03-30 10:48:40 +08:00
wkc
b276654ac2 接入个人修改密码加密 2026-03-30 10:47:58 +08:00
wkc
2854c0bb38 新增贷款定价敏感信息加密实施计划 2026-03-30 10:47:25 +08:00
wkc
a7d5661275 接入登录注册密码加密 2026-03-30 10:47:18 +08:00
wkc
fdd1ce5525 新增前端密码加密工具 2026-03-30 10:46:33 +08:00
wkc
21208d8965 完成密码加密传输后端实现 2026-03-30 10:44:04 +08:00
wkc
717defc06e 新增贷款定价敏感信息加密设计文档 2026-03-30 10:43:15 +08:00
wkc
e8959805e5 接入用户密码接口解密 2026-03-30 10:43:13 +08:00
wkc
92bdd58d27 接入个人修改密码解密 2026-03-30 10:41:56 +08:00
wkc
68f823f0bc 接入登录注册密码解密 2026-03-30 10:40:24 +08:00
wkc
1623b77b4a 新增密码传输解密服务 2026-03-30 10:39:19 +08:00
wkc
92b67b6697 补充密码加密传输前后端实施计划 2026-03-30 10:36:31 +08:00
wkc
bcfda34009 补充密码加密传输设计文档 2026-03-30 10:32:33 +08:00
57 changed files with 21850 additions and 49 deletions

View File

@@ -0,0 +1,30 @@
# AGENTS.md - AI Coding Assistant Guide
## GIT
- git提交时使用中文添加描述
- 无视`.DS_Store`
## AGENT
- 不开启subagent
## 文档
- 根据设计文档产出前后端项目的实施计划时,输出两份执行文档,一份为后端的实施计划,一份为前端的实施计划
- 每一次改动都需要留下实施文档,记录修改的内容
- 每次写设计文档的时候,都要检查一下保存路径是否正确
## 测试
- 开发完成后必须执行与本次改动直接对应的验证步骤,完成验证后才能结束本次任务
- 如果是接口开发完成,先重启后端进程,确保最新代码已经生效,再调用接口进行测试
- 接口测试时必须覆盖多种情况,至少包含正常场景、必填/参数错误场景、分支场景;如接口逻辑包含状态、类型、金额、期限等关键条件,需要分别验证对应分支
- 如果是前端页面开发完成,必须启动前端页面并调用浏览器检查功能是否正常,确认页面展示、交互流程、接口联动和关键提示信息符合预期
- 测试结束后,自动结束测试时开启的前后端进程
## 开发
- 在开发前端的时候不需要使用git worktree直接在当前分支进行开发
## 方案规范
- 当需要你给出方案时,必须符合以下规范
- 不允许给出兼容性或补丁性的方案
- 不允许过度设计,保持最短路径实现,且不能违反上一条要求
- 不允许自行给出我提供的需求以外的方案,例如一些兜底和降级方案,这可能导致业务逻辑偏移问题
- 必须确保方案的逻辑正确,必须经过全链路的逻辑验证

View File

@@ -0,0 +1,341 @@
# 贷款定价流程客户敏感信息加密改造设计文档
## 1. 背景
贷款定价流程当前对客户名称 `custName`、证件号码 `idNum` 采用明文传输、明文存储、明文展示的方式处理,不满足客户敏感信息安全要求。
本次需求已经明确限定为:
- 仅覆盖贷款定价流程主链
- 敏感字段仅包含 `custName``idNum`
- `custIsn` 不属于本次敏感字段范围
- 不改造传输层字段加密
- 仅要求存储加密、展示脱敏
- 页面不提供任何明文查看入口
- 流程查询改为仅允许通过客户内码 `custIsn` 查询
- 现有存量数据不迁移,直接清空历史流程数据
- 加密密钥从后端配置文件读取,按当前项目最短路径落地
## 2. 已确认约束
- 仅修改贷款定价流程相关前后端与数据库脚本,不扩散到系统其他模块
- 不覆盖模型输出表的存储加密治理
- 详情页模型输出“基本信息”中的 `custName``idNum` 必须纳入展示脱敏范围
- 不新增前端字段级加密协议
- 不引入外部密钥管理系统
- 不新增兼容性字段、补丁式逻辑或降级分支
- 必须保证新流程数据落库为密文
- 必须保证列表页、详情页始终只展示脱敏值
- 必须保证模型调用链路仍能拿到业务所需明文
## 3. 现状分析
当前贷款定价流程主链如下:
1. 前端个人/企业建单弹窗提交明文 `custName``idNum`
2. 后端 `LoanPricingWorkflowServiceImpl` 通过 `LoanPricingConverter` 将 DTO 转为 `LoanPricingWorkflow`
3. `loan_pricing_workflow.cust_name``loan_pricing_workflow.id_num` 直接保存明文
4. 列表页查询 SQL 直接查询 `lpw.cust_name`
5. 详情页接口直接返回流程实体中的 `custName``idNum`
6. `LoanPricingModelService` 从流程表读取数据后直接组装模型调用 DTO
现状问题有三类:
1. 存储层风险:数据库中存在明文客户名称和证件号码
2. 展示层风险:前端列表页、详情页直接展示敏感明文
3. 查询链路风险:当前列表页仍允许按客户名称查询,与密文存储目标冲突
## 4. 方案对比
### 方案一:应用层统一 AES 加解密,返回前统一脱敏
做法:
- 在后端新增贷款定价专用敏感字段加解密组件
- 创建流程时对 `custName``idNum` 加密后再落库
- 查询详情、模型调用前在服务内部解密
- 返回前端前统一转成脱敏值
- 前端仅负责调整查询条件和展示,不做加解密
优点:
- 改动集中,符合最短路径实现
- 与数据库实现解耦,不绑定数据库方言
- 业务边界清晰,易于控制哪些链路允许拿明文
- 便于测试和排查
缺点:
- 需要明确服务内部解密和对外脱敏的边界,避免遗漏
### 方案二MyBatis TypeHandler 自动加解密
做法:
- 为实体敏感字段挂载统一的 TypeHandler
- 插入自动加密、查询自动解密
- 返回前再补充脱敏
优点:
- 业务代码表面改动更少
缺点:
- 链路不直观,联表 SQL、VO 查询、模型调用等位置容易出现加解密边界不清
- 调试复杂度高,不符合本次最短路径目标
### 方案三:数据库函数处理加解密
做法:
- 在 SQL 中直接调用数据库加解密函数
- 应用层只负责脱敏
优点:
- 应用层代码改动相对少
缺点:
- 强依赖数据库能力
- 维护成本高
- 联表与分页查询复杂度上升
- 不符合本次直接、清晰的实现要求
## 5. 设计结论
采用方案一:应用层统一 AES 加解密,返回前统一脱敏。
最终设计原则如下:
- `loan_pricing_workflow.cust_name``loan_pricing_workflow.id_num` 仅保存密文
- 服务内部按需短暂解密,仅供业务处理和模型调用使用
- 面向前端返回时,`custName``idNum` 永远转换为脱敏值
- 列表查询去掉客户名称条件,只保留客户内码等非敏感查询项
- 存量流程数据通过清空历史数据处理,不做迁移兼容
## 6. 架构设计
本次在贷款定价流程模块内新增两类职责:
### 6.1 敏感字段加解密服务
新增贷款定价专用组件 `SensitiveFieldCryptoService`,负责:
- 从后端配置文件读取 AES 密钥
-`custName``idNum` 执行加密
- 对已落库密文执行解密
- 在密钥缺失或解密失败时抛出明确异常
该组件只处理加密和解密,不参与脱敏展示逻辑。
### 6.2 敏感字段展示服务
新增贷款定价专用组件 `LoanPricingSensitiveDisplayService`,负责:
- 对客户名称进行脱敏
- 对证件号码进行脱敏
- 对流程详情对象和列表对象中的敏感字段做统一替换
该组件不依赖当前系统管理员免脱敏逻辑,严格执行全员脱敏规则。
## 7. 数据链路设计
### 7.1 创建流程链路
1. 前端建单弹窗提交明文 `custName``idNum`
2. 后端 DTO 转实体后,在 `createLoanPricing` 入库前统一加密
3. `loan_pricing_workflow` 表保存密文
4. 创建成功后后续流程继续使用同一主记录
### 7.2 列表查询链路
1. 前端列表页移除客户名称搜索项,新增或保留客户内码查询
2. 后端分页查询不再按 `custName` 过滤
3. 列表 SQL 仍查询 `cust_name` 字段,但查询结果为密文
4. 服务返回前将列表 VO 中 `custName` 转为脱敏值
5. 前端直接展示后端返回的脱敏结果
### 7.3 详情查询链路
1. 后端根据流水号查询流程记录,拿到密文 `custName``idNum`
2. 服务内部先解密得到业务所需明文
3. 若需要组装详情对象或进行后续处理,使用解密后的值
4. 返回前调用展示服务,将 `custName``idNum` 替换为脱敏值
5. 前端详情页只展示脱敏内容
### 7.4 模型调用链路
1. `LoanPricingModelService` 根据流程主键读取流程记录
2. 读取到的 `custName``idNum` 为密文
3. 调用模型前先在服务内部解密
4. 将解密后的明文复制到 `ModelInvokeDTO`
5. 模型调用完成后,模型输出链路保持现状,不纳入本次改造
### 7.5 模型输出展示链路
1. 详情接口查询到 `ModelRetailOutputFields``ModelCorpOutputFields`
2. 模型输出实体中的 `custName``idNum` 保持当前存储方式,不做表级加密改造
3. 在详情接口返回前,对模型输出“基本信息”中的 `custName``idNum` 做统一脱敏
4. 前端 `ModelOutputDisplay` 组件直接展示后端返回的脱敏值
5. 不提供模型输出基本信息的明文查看入口
## 8. 后端改造设计
### 8.1 配置项
后端新增贷款定价敏感字段加密配置项,例如:
- 是否启用敏感字段加解密
- AES 密钥
本次仅要求配置文件读取固定密钥,不扩展到数据库参数表或外部密钥系统。
### 8.2 服务层改造
需要修改以下关键点:
1. `LoanPricingWorkflowServiceImpl#createLoanPricing`
`loanPricingWorkflowMapper.insert` 前统一加密 `custName``idNum`
2. `LoanPricingWorkflowServiceImpl#selectLoanPricingBySerialNum`
查询详情后先解密,再在返回前脱敏
3. `LoanPricingWorkflowServiceImpl#selectLoanPricingPage`
分页结果中的 `custName` 统一转为脱敏值
4. `LoanPricingWorkflowServiceImpl#buildQueryWrapper`
删除 `custName` 查询条件,改为仅支持 `custIsn`、创建者、机构号等非敏感字段
5. `LoanPricingModelService#invokeModelAsync`
模型调用前解密 `custName``idNum`,确保模型收到明文业务数据
6. `LoanPricingWorkflowServiceImpl#selectLoanPricingBySerialNum`
在组装 `ModelRetailOutputFields``ModelCorpOutputFields` 返回值时,对其 `custName``idNum` 进行脱敏替换
### 8.3 对象边界
本次不新增明文返回字段,也不保留“密文字段 + 展示字段”双轨结构,避免对象语义膨胀。
返回前对象中的敏感字段直接替换为脱敏值,确保控制器和前端都不会拿到明文。
## 9. 前端改造设计
### 9.1 列表页
修改 `ruoyi-ui/src/views/loanPricing/workflow/index.vue`
- 移除“客户名称”查询项
- 改为支持客户内码查询
- 表格中 `custName` 继续展示,但其值来自后端脱敏结果
### 9.2 详情页
修改个人与企业详情组件:
- 保持字段布局不变
- `custName``idNum` 直接展示后端返回的脱敏值
- 不新增“查看明文”“复制原值”等交互入口
- 模型输出组件 `ModelOutputDisplay.vue` 的“基本信息”页签继续直接消费后端字段,但要求后端返回值已完成脱敏
### 9.3 建单页
个人和企业建单弹窗仍然录入明文 `custName``idNum`,不新增前端字段加密逻辑。
## 10. 数据库处理设计
本次数据库处理遵循最短路径:
1. 不修改 `loan_pricing_workflow` 表结构
2. 不新增密文字段、副本字段或检索字段
3. 实施前执行历史流程数据清空脚本
4. 清空范围仅限贷款定价流程相关存量数据
因为 `custName``idNum` 不再承担查询职责,所以现有字段直接存密文即可。
## 11. 错误处理设计
本次不做兼容性补丁逻辑,错误直接失败:
1. 加密配置缺失
创建流程直接失败,不允许明文落库
2. 解密失败
详情查询失败,模型调用失败,并记录明确错误日志
3. 历史脏数据
通过清空存量数据消除,不增加“密文/明文混读”兼容判断
4. 前端展示
前端只消费后端结果,不承担兜底脱敏职责
## 12. 测试与验收设计
### 12.1 后端验收
1. 创建个人贷款定价流程,校验数据库 `cust_name``id_num` 为密文
2. 创建企业贷款定价流程,校验数据库 `cust_name``id_num` 为密文
3. 列表查询仅支持通过 `custIsn` 命中
4. 列表返回中的 `custName` 为脱敏值
5. 详情返回中的 `custName``idNum` 为脱敏值
6. 模型输出“基本信息”中的 `custName``idNum` 返回为脱敏值
7. 模型调用链路成功,证明服务内部解密逻辑成立
8. 配置缺失时创建流程失败,确认不会明文入库
### 12.2 前端验收
1. 列表页查询项已移除客户名称,改为客户内码
2. 列表页客户名称展示为脱敏值
3. 个人详情页客户名称、证件号码展示为脱敏值
4. 企业详情页客户名称、证件号码展示为脱敏值
5. 个人模型输出“基本信息”页签中的客户名称、证件号码展示为脱敏值
6. 企业模型输出“基本信息”页签中的客户名称、证件号码展示为脱敏值
7. 创建流程、查看详情、设定执行利率等既有功能不受影响
## 13. 风险与控制
### 风险一:模型调用读取到密文
控制方式:
-`LoanPricingModelService` 调用模型前显式解密
- 用测试覆盖模型调用前数据组装逻辑
### 风险二:返回链路遗漏脱敏
控制方式:
- 统一在服务层返回前调用展示服务
- 列表 VO 与详情 VO 都纳入测试覆盖
### 风险三:历史明文和新密文混杂
控制方式:
- 实施前清空贷款定价流程历史数据
- 不保留兼容读取分支
## 14. 范围与非目标
本次包含:
- 贷款定价流程建单入库加密
- 贷款定价流程列表和详情展示脱敏
- 贷款定价流程详情页中的模型输出“基本信息”展示脱敏
- 贷款定价流程查询条件收口为客户内码
- 贷款定价流程存量数据清空处理
本次不包含:
- 模型输出表存储加密改造
- 系统其他模块的敏感字段改造
- 前后端传输层字段加密
- 密钥托管平台接入
- 基于角色的明文查看权限
## 15. 设计结论
本次采用“应用层统一 AES 加解密 + 返回前统一脱敏”的方式,对贷款定价流程中的 `custName``idNum` 完成存储加密和展示脱敏改造。
该方案满足当前客户安全要求,并保持实现路径最短、责任边界清晰、业务链路闭环完整。

View File

@@ -0,0 +1,21 @@
# AGENTS 测试步骤规范补充实施记录
## 实施时间
- 2026-03-30
## 修改内容
- 新增仓库级 `AGENTS.md` 协作规范内容
- 明确开发完成后必须执行与改动对应的验证步骤
- 明确接口开发完成后需要先重启后端进程,再进行接口调用验证
- 明确接口测试必须覆盖正常场景、参数错误场景和关键业务分支场景
- 明确前端页面开发完成后需要通过浏览器检查页面功能、交互与接口联动
- 保留测试结束后自动关闭测试进程的要求
## 文档路径
- `AGENTS.md`
- `doc/implementation-report-2026-03-30-agents-test-rules.md`
## 结论
- 已将“开发完成后的测试步骤”写入仓库级协作规范
- 新增要求能够直接约束接口开发和前端页面开发的验收动作
- 本次修改仅涉及文档规范,不涉及业务代码与接口实现

View File

@@ -0,0 +1,13 @@
# 后端端口调整为 63310 实施记录
## 修改内容
-`ruoyi-admin``dev``uat``pro` 环境 `server.port` 统一调整为 `63310`
- 将后端模型调用配置 `model.url` 同步改为 `http://localhost:63310/rate/pricing/mock/invokeModel`,避免应用内部仍回调旧端口。
- 将前端本地开发代理 `ruoyi-ui/vue.config.js` 的后端目标端口同步改为 `63310`
-`test_api` 下的 Shell 脚本与 `.http` 示例请求地址统一改为 `http://localhost:63310`
## 验证结果
- 执行 `./bin/restart_java_backend.sh restart`,构建与重启成功,启动日志显示开发环境实际监听端口为 `http-nio-63310`
- 执行 `lsof -iTCP:63310 -sTCP:LISTEN`,确认 Java 进程已监听 `63310` 端口。
- 执行 `curl -sS -X POST http://localhost:63310/login/test -H 'Content-Type: application/json' -d '{"username":"admin","password":"admin123"}'`,返回 `{"code":200,...}`,确认新端口可正常访问登录测试接口。
- 执行 `./bin/restart_java_backend.sh stop` 后再次检查 `./bin/restart_java_backend.sh status``lsof -iTCP:63310 -sTCP:LISTEN`,确认本次验证启动的后端进程已停止。

View File

@@ -0,0 +1,33 @@
# 贷款定价敏感字段加密后端实施记录
## 修改内容
-`ruoyi-loan-pricing` 新增 `SensitiveFieldCryptoService`,统一处理 `custName``idNum` 的 AES/ECB/PKCS5Padding + Base64 加解密。
-`ruoyi-loan-pricing` 新增 `LoanPricingSensitiveDisplayService`,统一处理个人姓名、企业名称、身份证号、统一社会信用代码的脱敏展示。
-`LoanPricingWorkflowServiceImpl` 的创建链路对 `custName``idNum` 加密后入库,并在列表、详情链路解密后做脱敏返回。
-`LoanPricingWorkflowServiceImpl` 的详情链路补充对 `ModelRetailOutputFields``ModelCorpOutputFields` 基本信息中的 `custName``idNum` 进行脱敏,避免模型输出区域继续暴露明文。
-`LoanPricingModelService` 调用模型前显式解密 `custName``idNum`,保证模型入参不接收密文;同时补齐 `ModelInvokeDTO.idNum` 字段。
- 修复模型调用后更新 `modelOutputId` 时把解密后的 `custName``idNum` 明文回写数据库的问题,改为仅更新 `modelOutputId`
-`LoanPricingWorkflowMapper.xml` 和服务查询条件中移除按 `custName` 查询,改为按 `custIsn` 查询。
- 新增 `sql/clear_loan_pricing_workflow_history.sql`,用于清理贷款定价流程及模型输出历史数据。
## 新增测试
- `SensitiveFieldCryptoServiceTest`
- `LoanPricingSensitiveDisplayServiceTest`
- `LoanPricingWorkflowServiceImplTest`
- `LoanPricingModelServiceTest`
## 验证结果
- 执行 `mvn -pl ruoyi-loan-pricing -am -Dtest=SensitiveFieldCryptoServiceTest,LoanPricingSensitiveDisplayServiceTest -Dsurefire.failIfNoSpecifiedTests=false test`,结果通过。
- 执行 `mvn -pl ruoyi-loan-pricing -am -Dtest=LoanPricingWorkflowServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test`,结果通过。
- 执行 `mvn -pl ruoyi-loan-pricing -am -Dtest=LoanPricingWorkflowServiceImplTest,LoanPricingModelServiceTest -Dsurefire.failIfNoSpecifiedTests=false test`,结果通过。
- 执行 `mvn -pl ruoyi-loan-pricing -am -Dtest=SensitiveFieldCryptoServiceTest,LoanPricingSensitiveDisplayServiceTest,LoanPricingWorkflowServiceImplTest,LoanPricingModelServiceTest -Dsurefire.failIfNoSpecifiedTests=false test`,结果通过。
- 执行 `mvn -pl ruoyi-loan-pricing -am -Dsurefire.failIfNoSpecifiedTests=false test`,结果通过。
- 从根工程重新打包 `ruoyi-admin.jar` 后,以 `18080` 端口启动临时后端实例,并将 `model.url` 指向 `http://localhost:18080/rate/pricing/mock/invokeModel` 完成联调。
- 个人流程与企业流程创建成功,接口即时返回的 `custName``idNum` 为密文。
- 调用参数错误场景 `/loanPricing/workflow/create/personal` 且缺少 `custIsn`,接口返回 `500`,错误信息为“客户内码不能为空”。
- 调用列表接口按 `custIsn` 查询,确认个人返回 `custName``张*`,企业返回 `custName``测试****公司`
- 调用详情接口,确认流程主信息中个人返回 `张* / 1101********1234`,企业返回 `测试****公司 / 91*************00X`
- 调用详情接口,确认模型输出“基本信息”中个人返回 `张* / 3301********1234`,企业返回 `北京******公司 / 91*************XXX`
## 备注
- 联调过程中发现 `serialNum` 仍使用毫秒时间戳生成,并发创建可能触发 `uk_serial_num` 冲突;该问题为本次验证中暴露的既有风险,本次未纳入敏感字段加密方案范围内处理。

View File

@@ -0,0 +1,34 @@
# 贷款定价流程客户敏感信息加密设计实施记录
## 实施时间
- 2026-03-30
## 修改内容
- 新增贷款定价流程客户敏感信息加密改造设计文档
- 明确本次范围仅覆盖贷款定价流程主链
- 明确敏感字段限定为 `custName``idNum`
- 明确采用应用层 AES 加解密与返回前统一脱敏方案
- 明确列表查询改为仅支持客户内码 `custIsn`
- 明确存量数据处理方式为直接清空,不做迁移
- 补充模型输出“基本信息”中的 `custName``idNum` 也需纳入展示脱敏范围
## 文档路径
- `doc/2026-03-30-loan-pricing-sensitive-data-encryption-design.md`
- `doc/implementation-report-2026-03-30-loan-pricing-sensitive-data-encryption-design.md`
## 设计结论
- `loan_pricing_workflow.cust_name``loan_pricing_workflow.id_num` 改为密文存储
- 贷款定价流程列表页、详情页仅展示脱敏值
- 贷款定价流程详情页中的模型输出“基本信息”也仅展示脱敏值
- 前端不承担加解密职责
- 模型调用前由后端服务内部解密敏感字段
## 说明
- 设计文档已按当前仓库习惯保存到 `doc/` 目录
- 仓库约束禁止启用 subagent因此本次未执行基于 subagent 的设计文档复审流程,改为人工复审
- 本次仅完成设计,不包含实施代码修改

View File

@@ -0,0 +1,15 @@
# 贷款定价敏感字段加密前端实施记录
## 修改内容
- 流程列表页查询项已从“客户名称”切换为“客户内码”,查询参数从 `queryParams.custName` 改为 `queryParams.custIsn`
- `ruoyi-ui/src/api/loanPricing/workflow.js` 保持 `params: query` 透传,不新增任何前端字段映射或加解密逻辑。
- 列表页继续直接展示后端返回的 `custName`,详情页继续直接展示后端返回的 `custName``idNum`,前端不承担脱敏算法和明文查看能力。
## 验证结果
- 执行 `rg -n 'custName|custIsn|客户名称|客户内码' ruoyi-ui/src/views/loanPricing/workflow/index.vue ruoyi-ui/src/api/loanPricing/workflow.js`,确认列表页查询区已改为 `custIsn`,不再使用 `queryParams.custName`
- 执行 `npm --prefix ruoyi-ui run build:prod`,结果通过,最终输出包含 `Build complete.`;构建过程中仅有原有包体积告警,无新增编译错误。
- 核对 `detail.vue``PersonalWorkflowDetail.vue``CorporateWorkflowDetail.vue`,确认详情页仍直接渲染 `detailData.custName``detailData.idNum`,未新增任何前端二次脱敏或明文查看逻辑。
- 结合后端联调结果确认:后端列表接口已返回 `张*`,详情接口已返回 `张* / 1101********1234 / 测试****公司 / 91*************00X`,前端现有展示代码会直接消费这些脱敏值。
## 备注
- 浏览器侧尝试通过现有 `9527` 前端服务进入贷款定价页面时,受其固定代理目标 `http://localhost:8080` 上现有后端接口超时影响,未完成一次独立的前端页面点击链路;本次前端展示结论基于源码直渲染核对、生产构建通过以及后端真实接口返回值联调共同确认。

View File

@@ -0,0 +1,29 @@
# 贷款定价流程客户敏感信息加密计划实施记录
## 实施时间
- 2026-03-30
## 修改内容
- 新增贷款定价敏感信息加密后端实施计划
- 新增贷款定价敏感信息加密前端实施计划
- 明确后端采用统一加解密服务 + 统一展示脱敏服务的实施路径
- 明确前端仅做查询项收口和脱敏值消费,不承担加解密
- 明确测试命令、数据库清理脚本、实施记录与提交节点
- 补充模型输出“基本信息”页签中的 `custName``idNum` 也纳入脱敏验证范围
## 文档路径
- `docs/superpowers/plans/2026-03-30-loan-pricing-sensitive-data-encryption-backend-plan.md`
- `docs/superpowers/plans/2026-03-30-loan-pricing-sensitive-data-encryption-frontend-plan.md`
- `doc/implementation-report-2026-03-30-loan-pricing-sensitive-data-encryption-plans.md`
## 计划结论
- 计划已按仓库要求拆分为后端执行文档和前端执行文档
- 后端计划覆盖密钥配置、敏感字段加解密、列表/详情脱敏、模型调用前解密和历史数据清理
- 后端计划补充模型输出“基本信息”返回值脱敏
- 前端计划覆盖查询项切换为客户内码、脱敏值展示消费、构建验证和联调验证,并显式检查模型输出“基本信息”页签
- 两份计划都采用最短路径实现,不引入明密文兼容分支
## 说明
- 已检查计划文档保存路径,执行计划保存至 `docs/superpowers/plans`
- 仓库约束禁止启用 subagent本次计划复审采用本地自检方式处理
- 当前仅完成计划文档,不包含代码实现

View File

@@ -0,0 +1,18 @@
# 密码加密传输后端实施记录
## 修改内容
-`ruoyi-framework` 新增 `PasswordTransferCryptoService`,统一处理 AES/ECB/PKCS5Padding + Base64 的密码传输解密。
-`ruoyi-admin``application.yml``application-dev.yml` 增加 `security.password-transfer.key` 配置。
-`/login``/register``/system/user/profile/updatePwd``/system/user``/system/user/resetPwd` 入口增加密码字段解密,随后继续复用原有认证、校验和 BCrypt 入库逻辑。
- 保持 `/login/test` 未改动。
## 新增测试
- `PasswordTransferCryptoServiceTest`
- `SysLoginControllerPasswordTransferTest`
- `SysRegisterControllerPasswordTransferTest`
- `SysProfileControllerPasswordTransferTest`
- `SysUserControllerPasswordTransferTest`
## 验证结果
- 执行 `mvn -pl ruoyi-admin,ruoyi-framework -am -Dtest=PasswordTransferCryptoServiceTest,SysLoginControllerPasswordTransferTest,SysRegisterControllerPasswordTransferTest,SysProfileControllerPasswordTransferTest,SysUserControllerPasswordTransferTest -Dsurefire.failIfNoSpecifiedTests=false test`,结果通过。
- 检查 `SysLoginController` 引入解密的提交 diff确认仅 `/login` 增加了解密调用,`/login/test` 无行为变化。

View File

@@ -0,0 +1,26 @@
# 系统登录与密码类接口加密传输设计实施记录
## 实施时间
- 2026-03-30
## 修改内容
- 新增系统登录与密码类接口加密传输设计文档
- 明确采用固定密钥的对称加密方案
- 明确覆盖正式密码提交接口并排除 `/login/test`
- 明确前端在 API 提交前加密、后端在控制器入口前统一解密
- 明确不采用明密文兼容处理
## 文档路径
- `docs/superpowers/specs/2026-03-30-login-password-encryption-design.md`
- `doc/implementation-report-2026-03-30-login-password-encryption-design.md`
## 设计结论
- 对正式密码提交接口启用固定密钥对称加密传输
- 保持原有请求字段名不变,仅对密码字段做加密与解密
- 解密成功后继续复用现有认证、校验与 BCrypt 入库逻辑
- `/login/test` 保持现状,不接入本次改动
## 说明
- 已按要求检查设计文档保存路径,正式设计文档保存至 `docs/superpowers/specs`
- 仓库约束禁止启用 subagent本次设计文档复审采用本地自检方式处理
- 设计确认后,下一步需要分别产出后端实施计划和前端实施计划

View File

@@ -0,0 +1,16 @@
# 密码加密传输前端实施记录
## 修改内容
- 新增 `ruoyi-ui/src/utils/passwordTransfer.js`,统一处理密码字段 AES/ECB/PKCS7 加密。
-`ruoyi-ui/package.json` 增加 `test:password-transfer` 脚本,并引入 `crypto-js` 依赖。
-`ruoyi-ui/src/api/login.js` 中为登录、注册请求只加密 `password` 字段。
-`ruoyi-ui/src/api/system/user.js` 中为个人修改密码、管理员新增用户、管理员重置密码请求加密受控密码字段。
-`ruoyi-ui/.env.development``ruoyi-ui/.env.staging``ruoyi-ui/.env.production` 增加 `VUE_APP_PASSWORD_TRANSFER_KEY` 配置。
- 页面组件保持明文表单值和原有校验逻辑不变。
## 新增验证
- 新增 `ruoyi-ui/tests/password-transfer-api.test.js`,覆盖密码加密工具、登录、注册、个人修改密码、管理员新增用户、管理员重置密码 API。
## 验证结果
- 执行 `npm run test:password-transfer`,结果通过。
- 执行 `npm run build:stage`,结果通过;仅存在既有的打包体积 warning无新增构建错误。

View File

@@ -0,0 +1,27 @@
# 系统登录与密码类接口加密传输计划实施记录
## 实施时间
- 2026-03-30
## 修改内容
- 新增密码加密传输后端实施计划
- 新增密码加密传输前端实施计划
- 明确后端以统一解密服务 + 控制器显式接入的方式实施
- 明确前端以统一加密工具 + API 层字段映射的方式实施
- 明确测试命令、实施记录与提交节点
## 文档路径
- `docs/superpowers/plans/2026-03-30-login-password-encryption-backend-plan.md`
- `docs/superpowers/plans/2026-03-30-login-password-encryption-frontend-plan.md`
- `doc/implementation-report-2026-03-30-login-password-encryption-plans.md`
## 计划结论
- 计划已按仓库要求拆分为后端执行文档和前端执行文档
- 两份计划都采用最短路径实现,不引入明密文兼容分支
- 后端计划覆盖统一解密、控制器接入和 MockMvc/单测验证
- 前端计划覆盖统一加密、API 接入、环境配置和 Node 脚本验证
## 说明
- 已按要求检查计划文档保存路径,计划保存至 `docs/superpowers/plans`
- 仓库约束禁止启用 subagent本次计划复审采用本地自检方式处理
- 当前仅完成计划文档,不包含代码实现

View File

@@ -0,0 +1,344 @@
# Loan Pricing Sensitive Data Encryption Backend Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:executing-plans to implement this plan in this repository. Do not use subagents. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 让贷款定价流程在后端对 `custName``idNum` 实现密文存储,并保证列表、详情、模型输出基本信息、模型调用链路在各自边界内完成脱敏或解密。
**Architecture:** 后端在 `ruoyi-loan-pricing` 模块内新增贷款定价专用敏感字段加解密服务和展示脱敏服务,固定密钥从配置读取。`LoanPricingWorkflowServiceImpl` 在创建、列表、详情和模型输出展示链路显式接入这些服务,`LoanPricingModelService` 在调模型前显式解密,避免把密文错误透传给模型。
**Tech Stack:** Spring Boot、MyBatis Plus、JUnit 5、Mockito、Maven、JDK `javax.crypto`
---
### Task 1: 搭建敏感字段加解密与脱敏基础设施
**Files:**
- Create: `ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/service/SensitiveFieldCryptoService.java`
- Create: `ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/service/LoanPricingSensitiveDisplayService.java`
- Create: `ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/SensitiveFieldCryptoServiceTest.java`
- Create: `ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/LoanPricingSensitiveDisplayServiceTest.java`
- Modify: `ruoyi-admin/src/main/resources/application.yml`
- [ ] **Step 1: 写加解密与脱敏失败测试**
新增两个测试文件,至少覆盖以下场景:
```java
@Test
void shouldEncryptAndDecryptCustNameAndIdNum()
{
String cipher = service.encrypt("张三");
assertNotEquals("张三", cipher);
assertEquals("张三", service.decrypt(cipher));
}
@Test
void shouldRejectBlankKeyConfiguration()
{
SensitiveFieldCryptoService service = new SensitiveFieldCryptoService("");
assertThrows(IllegalStateException.class, () -> service.encrypt("张三"));
}
```
```java
@Test
void shouldMaskPersonalNameAndIdNum()
{
assertEquals("张*", displayService.maskCustName("张三"));
assertEquals("1101********1234", displayService.maskIdNum("110101199001011234"));
}
@Test
void shouldMaskCorporateNameAndCreditCode()
{
assertEquals("测试****公司", displayService.maskCustName("测试科技有限公司"));
assertEquals("91*************00X", displayService.maskIdNum("91110000100000000X"));
}
```
- [ ] **Step 2: 运行基础测试确认当前失败**
Run: `mvn -pl ruoyi-loan-pricing -am -Dtest=SensitiveFieldCryptoServiceTest,LoanPricingSensitiveDisplayServiceTest -Dsurefire.failIfNoSpecifiedTests=false test`
Expected: FAIL提示测试类或对应服务不存在。
- [ ] **Step 3: 增加配置项和最小实现**
`application.yml` 增加贷款定价敏感字段配置,例如:
```yaml
loan-pricing:
sensitive:
key: "1234567890abcdef"
```
创建加解密服务与脱敏服务,最小实现至少包含:
```java
@Service
public class SensitiveFieldCryptoService
{
public String encrypt(String plainText) { ... }
public String decrypt(String cipherText) { ... }
}
```
```java
@Service
public class LoanPricingSensitiveDisplayService
{
public String maskCustName(String custName) { ... }
public String maskIdNum(String idNum) { ... }
}
```
- [ ] **Step 4: 重新运行基础测试**
Run: `mvn -pl ruoyi-loan-pricing -am -Dtest=SensitiveFieldCryptoServiceTest,LoanPricingSensitiveDisplayServiceTest -Dsurefire.failIfNoSpecifiedTests=false test`
Expected: PASS
- [ ] **Step 5: 提交本任务**
```bash
git add ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/service/SensitiveFieldCryptoService.java ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/service/LoanPricingSensitiveDisplayService.java ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/SensitiveFieldCryptoServiceTest.java ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/LoanPricingSensitiveDisplayServiceTest.java ruoyi-admin/src/main/resources/application.yml
git commit -m "新增贷款定价敏感字段加解密服务"
```
### Task 2: 接入流程创建与列表查询链路
**Files:**
- Modify: `ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/service/impl/LoanPricingWorkflowServiceImpl.java`
- Modify: `ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/entity/LoanPricingWorkflow.java`
- Modify: `ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/vo/LoanPricingWorkflowListVO.java`
- Modify: `ruoyi-loan-pricing/src/main/resources/mapper/loanpricing/LoanPricingWorkflowMapper.xml`
- Modify: `ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/impl/LoanPricingWorkflowServiceImplTest.java`
- [ ] **Step 1: 写服务层失败测试,约束创建时入库加密、列表返回脱敏、查询按客户内码**
`LoanPricingWorkflowServiceImplTest` 增加至少 3 个用例:
```java
@Test
void shouldEncryptCustNameAndIdNumBeforeInsert() { ... }
@Test
void shouldMaskCustNameWhenReturningPagedWorkflowList() { ... }
@Test
void shouldUseCustIsnInsteadOfCustNameAsQueryCondition() { ... }
```
关键断言至少包括:
```java
verify(loanPricingWorkflowMapper).insert(argThat(entity ->
!Objects.equals(entity.getCustName(), "张三")
&& !Objects.equals(entity.getIdNum(), "110101199001011234")));
assertEquals("张*", result.getRecords().get(0).getCustName());
```
- [ ] **Step 2: 运行服务测试确认失败**
Run: `mvn -pl ruoyi-loan-pricing -am -Dtest=LoanPricingWorkflowServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test`
Expected: FAIL当前创建逻辑尚未加密列表链路尚未脱敏查询条件仍包含 `custName`
- [ ] **Step 3: 在创建链路显式加密敏感字段**
`LoanPricingWorkflowServiceImpl#createLoanPricing` 中补最小实现:
```java
loanPricingWorkflow.setCustName(sensitiveFieldCryptoService.encrypt(loanPricingWorkflow.getCustName()));
loanPricingWorkflow.setIdNum(sensitiveFieldCryptoService.encrypt(loanPricingWorkflow.getIdNum()));
loanPricingWorkflowMapper.insert(loanPricingWorkflow);
```
要求:
- 只加密 `custName``idNum`
- `custIsn` 保持原样
- 配置缺失时直接失败,不增加明文兼容分支
- [ ] **Step 4: 收口列表查询条件并补脱敏返回**
修改点至少包含:
```java
if (StringUtils.hasText(loanPricingWorkflow.getCustIsn()))
{
wrapper.like(LoanPricingWorkflow::getCustIsn, loanPricingWorkflow.getCustIsn());
}
```
```java
pageResult.getRecords().forEach(row ->
row.setCustName(loanPricingSensitiveDisplayService.maskCustName(
sensitiveFieldCryptoService.decrypt(row.getCustName()))));
```
同步从 `buildQueryWrapper``LoanPricingWorkflowMapper.xml` 删除 `custName` 过滤条件。
- [ ] **Step 5: 重新运行服务测试**
Run: `mvn -pl ruoyi-loan-pricing -am -Dtest=LoanPricingWorkflowServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test`
Expected: PASS
- [ ] **Step 6: 补充实体与 VO 边界说明性调整**
若测试或编译需要,在 `LoanPricingWorkflow``LoanPricingWorkflowListVO` 中补充本次链路使用的字段,并保持对象语义清晰;不要新增明文副本字段。
- [ ] **Step 7: 提交本任务**
```bash
git add ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/service/impl/LoanPricingWorkflowServiceImpl.java ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/entity/LoanPricingWorkflow.java ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/vo/LoanPricingWorkflowListVO.java ruoyi-loan-pricing/src/main/resources/mapper/loanpricing/LoanPricingWorkflowMapper.xml ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/impl/LoanPricingWorkflowServiceImplTest.java
git commit -m "接入流程敏感字段加密与列表脱敏"
```
### Task 3: 接入详情返回、模型输出展示与模型调用链路
**Files:**
- Modify: `ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/service/impl/LoanPricingWorkflowServiceImpl.java`
- Modify: `ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/service/LoanPricingModelService.java`
- Modify: `ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/vo/LoanPricingWorkflowVO.java`
- Create: `ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/LoanPricingModelServiceTest.java`
- Modify: `ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/impl/LoanPricingWorkflowServiceImplTest.java`
- [ ] **Step 1: 写详情、模型输出展示与模型调用失败测试**
新增或补充以下测试场景:
```java
@Test
void shouldMaskCustNameAndIdNumWhenReturningWorkflowDetail() { ... }
```
```java
@Test
void shouldMaskCustNameAndIdNumInRetailModelOutputBasicInfo() { ... }
```
```java
@Test
void shouldMaskCustNameAndIdNumInCorporateModelOutputBasicInfo() { ... }
```
```java
@Test
void shouldDecryptCustNameAndIdNumBeforeInvokeModel() { ... }
```
关键断言至少包括:
```java
assertEquals("张*", result.getLoanPricingWorkflow().getCustName());
assertEquals("1101********1234", result.getLoanPricingWorkflow().getIdNum());
assertEquals("张*", result.getModelRetailOutputFields().getCustName());
assertEquals("1101********1234", result.getModelRetailOutputFields().getIdNum());
verify(modelService).invokeModel(argThat(dto ->
Objects.equals("张三", dto.getCustName())
&& Objects.equals("110101199001011234", dto.getIdNum())));
```
- [ ] **Step 2: 运行详情与模型测试确认失败**
Run: `mvn -pl ruoyi-loan-pricing -am -Dtest=LoanPricingWorkflowServiceImplTest,LoanPricingModelServiceTest -Dsurefire.failIfNoSpecifiedTests=false test`
Expected: FAIL当前详情返回未完整脱敏模型输出“基本信息”仍会返回明文模型调用前也未解密。
- [ ] **Step 3: 在详情返回前显式解密再脱敏**
`selectLoanPricingBySerialNum` 中加入类似处理:
```java
String plainCustName = sensitiveFieldCryptoService.decrypt(loanPricingWorkflow.getCustName());
String plainIdNum = sensitiveFieldCryptoService.decrypt(loanPricingWorkflow.getIdNum());
loanPricingWorkflow.setCustName(loanPricingSensitiveDisplayService.maskCustName(plainCustName));
loanPricingWorkflow.setIdNum(loanPricingSensitiveDisplayService.maskIdNum(plainIdNum));
```
要求:
- 对外返回对象中不保留明文
- 保持既有测算利率与执行利率逻辑不变
-`modelRetailOutputFields``modelCorpOutputFields` 非空,同样对其 `custName``idNum` 做脱敏替换
- [ ] **Step 4: 在模型调用前显式解密**
`LoanPricingModelService#invokeModelAsync` 中补最小实现:
```java
loanPricingWorkflow.setCustName(sensitiveFieldCryptoService.decrypt(loanPricingWorkflow.getCustName()));
loanPricingWorkflow.setIdNum(sensitiveFieldCryptoService.decrypt(loanPricingWorkflow.getIdNum()));
BeanUtils.copyProperties(loanPricingWorkflow, modelInvokeDTO);
```
要求:
- 只解密 `custName``idNum`
- 解密失败直接中断模型调用并记录错误
- 不修改模型输出表处理逻辑
- [ ] **Step 5: 重新运行详情与模型测试**
Run: `mvn -pl ruoyi-loan-pricing -am -Dtest=LoanPricingWorkflowServiceImplTest,LoanPricingModelServiceTest -Dsurefire.failIfNoSpecifiedTests=false test`
Expected: PASS
- [ ] **Step 6: 运行贷款定价模块回归测试**
Run: `mvn -pl ruoyi-loan-pricing -am -Dsurefire.failIfNoSpecifiedTests=false test`
Expected: PASS若有失败先区分是否为既有问题再决定是否继续扩测。
- [ ] **Step 7: 提交本任务**
```bash
git add ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/service/impl/LoanPricingWorkflowServiceImpl.java ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/service/LoanPricingModelService.java ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/vo/LoanPricingWorkflowVO.java ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/LoanPricingModelServiceTest.java ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/impl/LoanPricingWorkflowServiceImplTest.java
git commit -m "接入流程详情脱敏与模型调用解密"
```
### Task 4: 补数据库脚本与后端实施记录
**Files:**
- Create: `sql/clear_loan_pricing_workflow_history.sql`
- Create: `doc/implementation-report-2026-03-30-loan-pricing-sensitive-data-encryption-backend.md`
- [ ] **Step 1: 新增历史数据清理脚本**
创建脚本,至少包含:
```sql
DELETE FROM model_retail_output_fields;
DELETE FROM model_corp_output_fields;
DELETE FROM loan_pricing_workflow;
```
要求:
- 删除顺序满足外键或业务依赖关系
- 只清理贷款定价流程相关数据
- [ ] **Step 2: 写后端实施记录**
实施记录至少写明:
```markdown
- 已新增贷款定价敏感字段加解密服务与展示脱敏服务
- 已在流程创建链路对 `custName``idNum` 加密后入库
- 已在详情返回与列表返回链路统一脱敏
- 已在模型调用前显式解密敏感字段
- 已新增历史数据清理脚本
```
- [ ] **Step 3: 手工验证数据库落库与返回链路**
Run: 按项目现有方式启动后端,创建一条个人流程和一条企业流程,再查询列表与详情。
Expected:
- 数据库中的 `cust_name``id_num` 不等于前端提交明文
- 列表和详情返回的 `custName``idNum` 为脱敏值
- 模型输出“基本信息”页签中的 `custName``idNum` 也为脱敏值
- [ ] **Step 4: 如果为验证启动了后端进程,结束对应进程**
Run: `ps -ef | rg 'RuoYiApplication|loan-pricing|java'`
Expected: 仅停止本次验证启动的后端进程;对非本次启动进程不做处理。
- [ ] **Step 5: 提交本任务**
```bash
git add sql/clear_loan_pricing_workflow_history.sql doc/implementation-report-2026-03-30-loan-pricing-sensitive-data-encryption-backend.md
git commit -m "补充贷款定价敏感字段后端实施记录"
```

View File

@@ -0,0 +1,150 @@
# Loan Pricing Sensitive Data Encryption Frontend Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:executing-plans to implement this plan in this repository. Do not use subagents. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 让贷款定价流程前端只按客户内码查询,并在列表页、详情页、模型输出“基本信息”页签稳定展示后端返回的脱敏 `custName``idNum`
**Architecture:** 前端不承担任何加解密逻辑,只做查询项收口与脱敏值展示消费。列表页从按 `custName` 查询切换为按 `custIsn` 查询,详情页与 `ModelOutputDisplay.vue` 保持现有结构,继续直接渲染后端返回字段,但要联调确认模型输出“基本信息”页签不再出现敏感明文。
**Tech Stack:** Vue 2、Element UI、RuoYi 前端工程、npm
---
### Task 1: 收口流程列表页查询条件为客户内码
**Files:**
- Modify: `ruoyi-ui/src/views/loanPricing/workflow/index.vue`
- Modify: `ruoyi-ui/src/api/loanPricing/workflow.js`
- [ ] **Step 1: 核对当前查询项与请求参数**
Run: `sed -n '1,140p' ruoyi-ui/src/views/loanPricing/workflow/index.vue`
Expected: 能看到当前页面仍存在“客户名称”查询项和 `queryParams.custName`
- [ ] **Step 2: 将查询项改为客户内码**
把列表页查询区调整为类似结构:
```vue
<el-form-item label="客户内码" prop="custIsn">
<el-input
v-model="queryParams.custIsn"
placeholder="请输入客户内码"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
```
同步把查询参数从:
```js
custName: undefined
```
改为:
```js
custIsn: undefined
```
- [ ] **Step 3: 核对 API 层无需额外字段转换**
检查 `ruoyi-ui/src/api/loanPricing/workflow.js`,确认 `listWorkflow(query)` 继续透传 `params: query` 即可;若代码风格需要,仅补充注释说明,不新增映射逻辑。
- [ ] **Step 4: 重新检查源码确认客户名称查询已移除**
Run: `rg -n 'custName|custIsn|客户名称|客户内码' ruoyi-ui/src/views/loanPricing/workflow/index.vue ruoyi-ui/src/api/loanPricing/workflow.js`
Expected: 列表页查询区不再出现 `queryParams.custName`,而是使用 `custIsn`
- [ ] **Step 5: 提交本任务**
```bash
git add ruoyi-ui/src/views/loanPricing/workflow/index.vue ruoyi-ui/src/api/loanPricing/workflow.js
git commit -m "调整流程列表按客户内码查询"
```
### Task 2: 固化列表页、详情页与模型输出基本信息的脱敏展示消费
**Files:**
- Modify: `ruoyi-ui/src/views/loanPricing/workflow/index.vue`
- Modify: `ruoyi-ui/src/views/loanPricing/workflow/detail.vue`
- Modify: `ruoyi-ui/src/views/loanPricing/workflow/components/PersonalWorkflowDetail.vue`
- Modify: `ruoyi-ui/src/views/loanPricing/workflow/components/CorporateWorkflowDetail.vue`
- Inspect: `ruoyi-ui/src/views/loanPricing/workflow/components/ModelOutputDisplay.vue`
- [ ] **Step 1: 核对当前页面直接消费后端字段的位置**
Run: `rg -n 'custName|idNum' ruoyi-ui/src/views/loanPricing/workflow/index.vue ruoyi-ui/src/views/loanPricing/workflow/detail.vue ruoyi-ui/src/views/loanPricing/workflow/components/PersonalWorkflowDetail.vue ruoyi-ui/src/views/loanPricing/workflow/components/CorporateWorkflowDetail.vue ruoyi-ui/src/views/loanPricing/workflow/components/ModelOutputDisplay.vue`
Expected: 能定位列表、详情以及模型输出“基本信息”页签中所有 `custName``idNum` 的展示位置。
- [ ] **Step 2: 去掉任何可能的前端二次处理设想,只保留直接展示**
如果页面中需要加说明性注释,保持最小实现,例如:
```vue
<el-descriptions-item label="客户名称">{{ detailData.custName }}</el-descriptions-item>
<el-descriptions-item label="证件号码">{{ detailData.idNum }}</el-descriptions-item>
```
要求:
- 不新增“查看明文”按钮
- 不新增复制原值功能
- 不在前端自行做脱敏算法
- `ModelOutputDisplay.vue` 继续直接消费后端字段,不新增本地脱敏逻辑
- [ ] **Step 3: 执行前端构建验证**
Run: `npm --prefix ruoyi-ui run build:prod`
Expected: PASS输出包含 `Build complete.`
- [ ] **Step 4: 页面联调确认脱敏展示**
Run: 按项目现有方式启动前端并进入贷款定价流程列表页、详情页。
Expected:
- 列表页客户名称显示为脱敏值
- 个人详情页客户名称、证件号码显示为脱敏值
- 企业详情页客户名称、证件号码显示为脱敏值
- 个人模型输出“基本信息”页签中的客户名称、证件号码显示为脱敏值
- 企业模型输出“基本信息”页签中的客户名称、证件号码显示为脱敏值
- [ ] **Step 5: 如果为验证启动了前端进程,结束对应进程**
Run: `ps -ef | rg 'ruoyi-ui|vue-cli-service|npm'`
Expected: 仅停止本次联调启动的前端进程;对非本次启动进程不做处理。
- [ ] **Step 6: 提交本任务**
```bash
git add ruoyi-ui/src/views/loanPricing/workflow/index.vue ruoyi-ui/src/views/loanPricing/workflow/detail.vue ruoyi-ui/src/views/loanPricing/workflow/components/PersonalWorkflowDetail.vue ruoyi-ui/src/views/loanPricing/workflow/components/CorporateWorkflowDetail.vue
git commit -m "接入流程敏感字段前端脱敏展示"
```
### Task 3: 补前端实施记录
**Files:**
- Create: `doc/implementation-report-2026-03-30-loan-pricing-sensitive-data-encryption-frontend.md`
- [ ] **Step 1: 编写前端实施记录**
实施记录至少写明:
```markdown
- 流程列表页查询项已从客户名称切换为客户内码
- 前端不承担 `custName``idNum` 加解密逻辑
- 列表页与详情页均直接展示后端返回的脱敏值
- 模型输出“基本信息”页签也直接展示后端返回的脱敏值
- 已完成前端构建验证与页面联调
```
- [ ] **Step 2: 核对文档路径**
Run: `ls doc/implementation-report-2026-03-30-loan-pricing-sensitive-data-encryption-frontend.md`
Expected: 文件位于仓库 `doc/` 目录,不写错到其他文档路径。
- [ ] **Step 3: 提交本任务**
```bash
git add doc/implementation-report-2026-03-30-loan-pricing-sensitive-data-encryption-frontend.md
git commit -m "补充贷款定价敏感字段前端实施记录"
```

View File

@@ -0,0 +1,279 @@
# Backend Password Transfer Encryption Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 为正式密码提交接口补上后端解密链路,让 `/login``/register``/system/user/profile/updatePwd``/system/user/resetPwd``/system/user` 在收到密文密码后先解密,再复用现有认证与 BCrypt 逻辑。
**Architecture:**`ruoyi-framework` 新增统一的密码传输解密服务,固定密钥从配置读取,负责 AES/Base64 解密和失败抛错。`ruoyi-admin` 各控制器在进入现有业务逻辑前显式调用该服务对密码字段解密,`/login/test` 保持不变。
**Tech Stack:** Spring Boot 3.5、JUnit 5、MockMvc、JDK `javax.crypto`、Maven Surefire
---
### Task 1: 搭建后端密码解密基础设施
**Files:**
- Create: `ruoyi-framework/src/main/java/com/ruoyi/framework/web/service/PasswordTransferCryptoService.java`
- Create: `ruoyi-framework/src/test/java/com/ruoyi/framework/web/service/PasswordTransferCryptoServiceTest.java`
- Modify: `ruoyi-admin/src/main/resources/application.yml`
- Modify: `ruoyi-admin/src/main/resources/application-dev.yml`
- [ ] **Step 1: 写解密服务失败用例**
```java
class PasswordTransferCryptoServiceTest
{
@Test
void shouldDecryptValidCipherText()
{
String plain = service.decrypt("Base64密文");
assertEquals("admin123", plain);
}
@Test
void shouldRejectInvalidCipherText()
{
assertThrows(ServiceException.class, () -> service.decrypt("not-base64"));
}
}
```
- [ ] **Step 2: 运行测试确认当前失败**
Run: `mvn -pl ruoyi-framework -am -Dtest=PasswordTransferCryptoServiceTest -Dsurefire.failIfNoSpecifiedTests=false test`
Expected: FAIL提示测试类或 `PasswordTransferCryptoService` 不存在
- [ ] **Step 3: 补配置与最小实现**
```java
@Service
public class PasswordTransferCryptoService
{
@Value("${security.password-transfer.key}")
private String key;
public String decrypt(String cipherText)
{
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "AES"));
return new String(cipher.doFinal(Base64.getDecoder().decode(cipherText)), StandardCharsets.UTF_8);
}
}
```
```yaml
security:
password-transfer:
key: "请替换为16位固定密钥"
```
- [ ] **Step 4: 重新运行基础测试**
Run: `mvn -pl ruoyi-framework -am -Dtest=PasswordTransferCryptoServiceTest -Dsurefire.failIfNoSpecifiedTests=false test`
Expected: PASS
- [ ] **Step 5: 提交本任务**
```bash
git add ruoyi-framework/src/main/java/com/ruoyi/framework/web/service/PasswordTransferCryptoService.java ruoyi-framework/src/test/java/com/ruoyi/framework/web/service/PasswordTransferCryptoServiceTest.java ruoyi-admin/src/main/resources/application.yml ruoyi-admin/src/main/resources/application-dev.yml
git commit -m "新增密码传输解密服务"
```
### Task 2: 接入登录与注册接口解密
**Files:**
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysLoginController.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysRegisterController.java`
- Create: `ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/SysLoginControllerPasswordTransferTest.java`
- Create: `ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/SysRegisterControllerPasswordTransferTest.java`
- [ ] **Step 1: 写登录与注册控制器失败测试**
```java
mockMvc.perform(post("/login")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"admin\",\"password\":\"cipher\",\"code\":\"1\",\"uuid\":\"u\"}"))
.andExpect(status().isOk());
verify(passwordTransferCryptoService).decrypt("cipher");
verify(loginService).login("admin", "admin123", "1", "u");
```
```java
mockMvc.perform(post("/register")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"u1\",\"password\":\"cipher\",\"code\":\"1\",\"uuid\":\"u\"}"))
.andExpect(status().isOk());
verify(passwordTransferCryptoService).decrypt("cipher");
verify(registerService).register(any(RegisterBody.class));
```
- [ ] **Step 2: 运行登录/注册测试确认失败**
Run: `mvn -pl ruoyi-admin -am -Dtest=SysLoginControllerPasswordTransferTest,SysRegisterControllerPasswordTransferTest -Dsurefire.failIfNoSpecifiedTests=false test`
Expected: FAIL控制器尚未调用解密服务
- [ ] **Step 3: 在正式接口入口补解密**
```java
loginBody.setPassword(passwordTransferCryptoService.decrypt(loginBody.getPassword()));
```
```java
user.setPassword(passwordTransferCryptoService.decrypt(user.getPassword()));
```
要求:
- 只改 `/login`
- 不改 `loginWithoutCaptcha`
- 解密失败直接抛错,不追加明文兼容分支
- [ ] **Step 4: 重新运行登录/注册测试**
Run: `mvn -pl ruoyi-admin -am -Dtest=SysLoginControllerPasswordTransferTest,SysRegisterControllerPasswordTransferTest -Dsurefire.failIfNoSpecifiedTests=false test`
Expected: PASS
- [ ] **Step 5: 提交本任务**
```bash
git add ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysLoginController.java ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysRegisterController.java ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/SysLoginControllerPasswordTransferTest.java ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/SysRegisterControllerPasswordTransferTest.java
git commit -m "接入登录注册密码解密"
```
### Task 3: 接入个人修改密码接口解密
**Files:**
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysProfileController.java`
- Create: `ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/SysProfileControllerPasswordTransferTest.java`
- [ ] **Step 1: 写修改密码失败测试**
```java
mockMvc.perform(put("/system/user/profile/updatePwd")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"oldPassword\":\"oldCipher\",\"newPassword\":\"newCipher\"}"))
.andExpect(status().isOk());
verify(passwordTransferCryptoService).decrypt("oldCipher");
verify(passwordTransferCryptoService).decrypt("newCipher");
```
- [ ] **Step 2: 运行测试确认失败**
Run: `mvn -pl ruoyi-admin -am -Dtest=SysProfileControllerPasswordTransferTest -Dsurefire.failIfNoSpecifiedTests=false test`
Expected: FAIL`updatePwd` 尚未解密 `oldPassword``newPassword`
- [ ] **Step 3: 在 `updatePwd` 开头显式解密两个字段**
```java
String oldPassword = passwordTransferCryptoService.decrypt(params.get("oldPassword"));
String newPassword = passwordTransferCryptoService.decrypt(params.get("newPassword"));
```
要求:
- 仅在解密成功后继续旧密码校验
- 不处理 `confirmPassword`
- 保持原有报错文案和 BCrypt 入库逻辑
- [ ] **Step 4: 重新运行测试**
Run: `mvn -pl ruoyi-admin -am -Dtest=SysProfileControllerPasswordTransferTest -Dsurefire.failIfNoSpecifiedTests=false test`
Expected: PASS
- [ ] **Step 5: 提交本任务**
```bash
git add ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysProfileController.java ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/SysProfileControllerPasswordTransferTest.java
git commit -m "接入个人修改密码解密"
```
### Task 4: 接入管理员新增用户与重置密码接口解密
**Files:**
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysUserController.java`
- Create: `ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/SysUserControllerPasswordTransferTest.java`
- [ ] **Step 1: 写新增用户与重置密码失败测试**
```java
mockMvc.perform(post("/system/user")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"userName\":\"u1\",\"nickName\":\"n1\",\"deptId\":1,\"password\":\"cipher\"}"));
verify(passwordTransferCryptoService).decrypt("cipher");
```
```java
mockMvc.perform(put("/system/user/resetPwd")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"userId\":2,\"password\":\"cipher\"}"));
verify(passwordTransferCryptoService).decrypt("cipher");
```
- [ ] **Step 2: 运行测试确认失败**
Run: `mvn -pl ruoyi-admin -am -Dtest=SysUserControllerPasswordTransferTest -Dsurefire.failIfNoSpecifiedTests=false test`
Expected: FAIL新增用户和重置密码入口尚未调用解密
- [ ] **Step 3: 在 `add` 与 `resetPwd` 中先解密后继续原逻辑**
```java
user.setPassword(passwordTransferCryptoService.decrypt(user.getPassword()));
user.setPassword(SecurityUtils.encryptPassword(user.getPassword()));
```
要求:
- 仅对 `add``resetPwd` 补解密
- `edit` 不动
- 仍保留现有权限校验与数据范围校验
- [ ] **Step 4: 重新运行测试**
Run: `mvn -pl ruoyi-admin -am -Dtest=SysUserControllerPasswordTransferTest -Dsurefire.failIfNoSpecifiedTests=false test`
Expected: PASS
- [ ] **Step 5: 提交本任务**
```bash
git add ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysUserController.java ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/SysUserControllerPasswordTransferTest.java
git commit -m "接入用户密码接口解密"
```
### Task 5: 汇总验证与后端实施记录
**Files:**
- Create: `doc/implementation-report-2026-03-30-login-password-encryption-backend.md`
- [ ] **Step 1: 运行后端全部目标测试**
Run: `mvn -pl ruoyi-admin,ruoyi-framework -am -Dtest=PasswordTransferCryptoServiceTest,SysLoginControllerPasswordTransferTest,SysRegisterControllerPasswordTransferTest,SysProfileControllerPasswordTransferTest,SysUserControllerPasswordTransferTest -Dsurefire.failIfNoSpecifiedTests=false test`
Expected: PASS所有新增后端测试通过
- [ ] **Step 2: 手工核对 `/login/test` 未被改动**
Run: `git diff -- ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysLoginController.java`
Expected: 仅 `/login` 增加解密调用,`loginWithoutCaptcha` 无行为变化
- [ ] **Step 3: 写后端实施记录**
```markdown
# 密码加密传输后端实施记录
- 新增密码解密服务
- 接入 5 个正式接口中的后端入口
- 保持 `/login/test` 不变
- 补充控制器与服务测试
```
- [ ] **Step 4: 再次检查文档路径与 git 状态**
Run: `git status --short`
Expected: 仅包含后端实现文件与 `doc/implementation-report-2026-03-30-login-password-encryption-backend.md`
- [ ] **Step 5: 提交本任务**
```bash
git add doc/implementation-report-2026-03-30-login-password-encryption-backend.md
git commit -m "完成密码加密传输后端实现"
```

View File

@@ -0,0 +1,258 @@
# Frontend Password Transfer Encryption Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 为正式密码提交接口补上前端加密发送能力,让登录、注册、个人修改密码、管理员重置密码、管理员新增用户在请求发出前只对密码字段做 AES 加密。
**Architecture:**`ruoyi-ui` 新增统一的密码传输加密工具和字段映射辅助方法,由 API 层在提交请求前克隆并加密受控字段。页面组件继续持有明文表单值,现有表单校验和交互文案保持不变。
**Tech Stack:** Vue 2、Axios、`crypto-js`、Node 脚本测试、Vue CLI 4
---
### Task 1: 搭建前端加密工具与测试基线
**Files:**
- Modify: `ruoyi-ui/package.json`
- Modify: `ruoyi-ui/package-lock.json`
- Create: `ruoyi-ui/src/utils/passwordTransfer.js`
- Create: `ruoyi-ui/tests/password-transfer-api.test.js`
- [ ] **Step 1: 写前端失败测试脚本**
```js
const encrypted = encryptPasswordFields(
{ password: 'admin123', code: '8888' },
['password'],
'1234567890abcdef'
)
assert.notStrictEqual(encrypted.password, 'admin123')
assert.strictEqual(encrypted.code, '8888')
```
```js
const requestConfig = login('admin', 'admin123', '8888', 'uuid-1')
assert.strictEqual(requestConfig.data.password !== 'admin123', true)
```
- [ ] **Step 2: 运行测试确认当前失败**
Run: `cd ruoyi-ui && node tests/password-transfer-api.test.js`
Expected: FAIL工具文件或 API 加密行为尚不存在
- [ ] **Step 3: 新增依赖、脚本和最小工具实现**
```js
import CryptoJS from 'crypto-js'
export function encryptPasswordFields(payload, fields, key) {
const next = { ...payload }
fields.forEach((field) => {
if (next[field]) {
next[field] = CryptoJS.AES.encrypt(next[field], CryptoJS.enc.Utf8.parse(key), {
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7
}).toString()
}
})
return next
}
```
```json
"scripts": {
"test:password-transfer": "node tests/password-transfer-api.test.js"
}
```
- [ ] **Step 4: 重新运行前端测试**
Run: `cd ruoyi-ui && npm run test:password-transfer`
Expected: PASS
- [ ] **Step 5: 提交本任务**
```bash
git add ruoyi-ui/package.json ruoyi-ui/package-lock.json ruoyi-ui/src/utils/passwordTransfer.js ruoyi-ui/tests/password-transfer-api.test.js
git commit -m "新增前端密码加密工具"
```
### Task 2: 接入登录与注册接口加密
**Files:**
- Modify: `ruoyi-ui/src/api/login.js`
- [ ] **Step 1: 扩展测试覆盖登录与注册 API**
```js
const loginConfig = login('admin', 'admin123', '8888', 'uuid-1')
assert.notStrictEqual(loginConfig.data.password, 'admin123')
assert.strictEqual(loginConfig.data.username, 'admin')
const registerConfig = register({ username: 'u1', password: 'p1', confirmPassword: 'p1', code: '8888' })
assert.notStrictEqual(registerConfig.data.password, 'p1')
assert.strictEqual(registerConfig.data.confirmPassword, 'p1')
```
- [ ] **Step 2: 运行测试确认失败**
Run: `cd ruoyi-ui && npm run test:password-transfer`
Expected: FAIL`login.js` 尚未对正式接口密码字段加密
- [ ] **Step 3: 在 API 层接入加密工具**
```js
const data = encryptPasswordFields({ username, password, code, uuid }, ['password'], process.env.VUE_APP_PASSWORD_TRANSFER_KEY)
```
```js
const payload = encryptPasswordFields(data, ['password'], process.env.VUE_APP_PASSWORD_TRANSFER_KEY)
```
要求:
- 只加密 `password`
- 保持字段名不变
- 不在页面组件中写加密逻辑
- [ ] **Step 4: 重新运行前端测试**
Run: `cd ruoyi-ui && npm run test:password-transfer`
Expected: PASS
- [ ] **Step 5: 提交本任务**
```bash
git add ruoyi-ui/src/api/login.js ruoyi-ui/tests/password-transfer-api.test.js
git commit -m "接入登录注册密码加密"
```
### Task 3: 接入个人修改密码接口加密
**Files:**
- Modify: `ruoyi-ui/src/api/system/user.js`
- [ ] **Step 1: 扩展测试覆盖 `updateUserPwd`**
```js
const config = updateUserPwd('oldPwd', 'newPwd')
assert.notStrictEqual(config.data.oldPassword, 'oldPwd')
assert.notStrictEqual(config.data.newPassword, 'newPwd')
```
- [ ] **Step 2: 运行测试确认失败**
Run: `cd ruoyi-ui && npm run test:password-transfer`
Expected: FAIL`updateUserPwd` 仍发送明文
- [ ] **Step 3: 只在 API 层加密两个密码字段**
```js
const data = encryptPasswordFields(
{ oldPassword, newPassword },
['oldPassword', 'newPassword'],
process.env.VUE_APP_PASSWORD_TRANSFER_KEY
)
```
要求:
- 页面 `resetPwd.vue` 不改
- 继续让前端表单在明文状态下完成确认密码校验
- [ ] **Step 4: 重新运行测试**
Run: `cd ruoyi-ui && npm run test:password-transfer`
Expected: PASS
- [ ] **Step 5: 提交本任务**
```bash
git add ruoyi-ui/src/api/system/user.js ruoyi-ui/tests/password-transfer-api.test.js
git commit -m "接入个人修改密码加密"
```
### Task 4: 接入管理员新增用户与重置密码接口加密
**Files:**
- Modify: `ruoyi-ui/src/api/system/user.js`
- Modify: `ruoyi-ui/.env.development`
- Modify: `ruoyi-ui/.env.staging`
- Modify: `ruoyi-ui/.env.production`
- [ ] **Step 1: 扩展测试覆盖 `addUser` 与 `resetUserPwd`**
```js
const addConfig = addUser({ userName: 'u1', password: 'initPwd', nickName: 'n1' })
assert.notStrictEqual(addConfig.data.password, 'initPwd')
const resetConfig = resetUserPwd(2, 'resetPwd')
assert.notStrictEqual(resetConfig.data.password, 'resetPwd')
```
- [ ] **Step 2: 运行测试确认失败**
Run: `cd ruoyi-ui && npm run test:password-transfer`
Expected: FAIL`addUser``resetUserPwd` 仍发送明文
- [ ] **Step 3: 在受控接口接入加密并补环境配置**
```js
const payload = encryptPasswordFields(data, ['password'], process.env.VUE_APP_PASSWORD_TRANSFER_KEY)
```
```dotenv
VUE_APP_PASSWORD_TRANSFER_KEY=请替换为16位固定密钥
```
要求:
- 只改 `addUser``resetUserPwd`
- `updateUser` 不做密码加密处理
- 三套环境文件都补同名配置项
- [ ] **Step 4: 重新运行测试**
Run: `cd ruoyi-ui && npm run test:password-transfer`
Expected: PASS
- [ ] **Step 5: 提交本任务**
```bash
git add ruoyi-ui/src/api/system/user.js ruoyi-ui/.env.development ruoyi-ui/.env.staging ruoyi-ui/.env.production ruoyi-ui/tests/password-transfer-api.test.js
git commit -m "接入用户密码接口前端加密"
```
### Task 5: 汇总验证与前端实施记录
**Files:**
- Create: `doc/implementation-report-2026-03-30-login-password-encryption-frontend.md`
- [ ] **Step 1: 运行前端目标测试**
Run: `cd ruoyi-ui && npm run test:password-transfer`
Expected: PASS受控 API 的密码字段都按预期加密
- [ ] **Step 2: 运行一次前端构建验证**
Run: `cd ruoyi-ui && npm run build:stage`
Expected: PASS新增依赖、环境变量与 API 修改不影响构建
- [ ] **Step 3: 写前端实施记录**
```markdown
# 密码加密传输前端实施记录
- 新增强制密码字段加密工具
- 登录、注册、修改密码、重置密码、新增用户在 API 层加密
- 页面表单逻辑保持不变
```
- [ ] **Step 4: 再次检查 git 状态**
Run: `git status --short`
Expected: 仅包含前端实现文件与 `doc/implementation-report-2026-03-30-login-password-encryption-frontend.md`
- [ ] **Step 5: 提交本任务**
```bash
git add doc/implementation-report-2026-03-30-login-password-encryption-frontend.md
git commit -m "完成密码加密传输前端实现"
```

View File

@@ -0,0 +1,265 @@
# 系统登录与密码类接口加密传输设计文档
## 1. 背景
当前系统登录、注册、修改密码、重置密码、新增用户等正式接口,在请求体中直接传输明文密码。后端在收到密码后再执行现有的登录校验或 BCrypt 加密入库逻辑。
本次需求是在不改变现有业务语义的前提下,为所有正式密码提交接口增加“密码加密传输”能力,避免密码以明文形式直接出现在接口请求体中。
## 2. 已确认约束
- 采用对称加密方案
- 前端使用固定密钥加密密码字段
- 后端使用同一固定密钥解密密码字段
- 覆盖所有正式密码提交接口
- 明确不包含 `/login/test`
- 不新增兼容性或补丁性方案
- 不允许“解密失败后按明文继续处理”
- 保持最短路径实现,不改现有账号、认证、密码存储主逻辑
## 3. 接口范围
本次加密传输仅覆盖以下正式接口:
- `/login`
- `/register`
- `/system/user/profile/updatePwd`
- `/system/user/resetPwd`
- `/system/user`
各接口需要处理的密码字段如下:
- `/login``password`
- `/register``password`
- `/system/user/profile/updatePwd``oldPassword``newPassword`
- `/system/user/resetPwd``password`
- `/system/user``password`
以下接口不在本次范围内:
- `/login/test`
- 任何不提交密码字段的接口
## 4. 现状分析
### 4.1 前端现状
当前前端接口调用中,登录、注册、个人修改密码、管理员重置密码、管理员新增用户都直接提交明文密码字段。仓库中虽然已经存在 `JSEncrypt` 工具,但仅用于“记住密码”场景下 Cookie 的本地存储加密,并没有用于登录或其他正式密码接口。
### 4.2 后端现状
后端正式接口的密码处理链路如下:
- 登录接口直接读取 `LoginBody.password` 并交给认证流程
- 注册接口直接读取 `RegisterBody.password` 并执行 BCrypt 加密入库
- 修改密码接口直接读取 `oldPassword``newPassword` 并执行旧密码校验和新密码入库
- 管理员重置密码和新增用户接口直接读取 `SysUser.password` 并执行 BCrypt 加密
现有后端没有统一的密码传输解密层,因此如果直接在前端加密而后端不解密,现有校验链路会全部失效。
## 5. 方案对比
### 方案一:保留现有字段名,前端加密后提交,后端统一解密
做法:
- 保持现有请求结构不变
- 前端在 API 提交前仅加密密码字段
- 后端在控制器进入业务逻辑前,对密码字段统一解密
优点:
- 改动路径最短
- 页面、DTO、控制器入参结构基本不变
- 现有业务校验和 BCrypt 逻辑可直接复用
缺点:
- 需要明确每个接口的密码字段清单
- 前后端都要维护一份受控字段映射
### 方案二:新增专用密文字段
做法:
- 每个接口新增 `encryptedPassword``encryptedOldPassword` 等字段
- 后端只处理密文字段
优点:
- 语义清楚
- 明文和密文边界直观
缺点:
- 改动面大
- 前后端 DTO、表单、测试样例都要整体调整
- 不符合本次最短路径实现原则
### 方案三:全局请求拦截器加密 + 全局参数层解密
做法:
- 前端在 axios 拦截器中按 URL 自动加密密码字段
- 后端在过滤器或参数解析层统一自动解密
优点:
- 页面层改动最少
缺点:
- 隐式逻辑过重
- 对不同入参类型的接口可读性差
- 不利于后续定位问题
## 6. 设计结论
采用方案一。
本次仅在接口边界增加密码加密传输能力,业务层继续只处理解密后的明文密码。传输链路如下:
1. 前端表单收集用户输入的密码明文
2. API 提交前,使用固定对称密钥加密密码字段
3. 后端控制器收到请求后,先对约定密码字段解密
4. 解密成功后继续走现有业务逻辑
5. 解密失败时直接返回错误,不进入后续业务处理
`/login/test` 保持现状,不加入加密与解密逻辑。
## 7. 前端设计
### 7.1 设计目标
前端只负责“在请求发出前对密码字段加密”,不在页面组件中分散实现逻辑,也不修改表单字段命名。
### 7.2 收口位置
加密逻辑收口在 API 调用层,不放在页面组件层。
原因:
- 登录页、注册页、个人中心、用户管理都存在密码提交场景
- 若每个页面独立处理,加密逻辑容易分散和重复
- API 层更容易统一维护接口与字段映射
### 7.3 前端改动点
- 新增统一的对称加密工具
- 新增“密码字段加密”辅助方法
- 在以下接口调用前对对应字段加密:
- 登录
- 注册
- 个人修改密码
- 管理员重置密码
- 管理员新增用户
- 保持请求字段名不变
### 7.4 配置方式
前端固定密钥通过环境配置读取,不直接散落在业务代码中。
## 8. 后端设计
### 8.1 设计目标
后端只负责“在进入现有业务逻辑前将密码字段解密为明文”,不改动现有认证和密码存储主流程。
### 8.2 收口位置
解密逻辑收口在控制器入口之后、业务逻辑之前,由统一的密码解密工具完成。
原因:
- 当前接口入参类型不统一,包含 `LoginBody``RegisterBody``SysUser``Map<String, String>`
- 如果直接放到全局过滤器或参数解析层,会增加隐式复杂度
- 控制器显式调用统一解密工具,路径更短、更直观
### 8.3 后端改动点
- 新增统一的对称解密工具
- 新增面向不同入参类型的密码字段解密方法
- 在以下正式接口进入业务逻辑前显式解密:
- `SysLoginController.login`
- `SysRegisterController.register`
- `SysProfileController.updatePwd`
- `SysUserController.resetPwd`
- `SysUserController.add`
- 不改动 `SysLoginController.loginWithoutCaptcha`
### 8.4 业务链路保持不变
解密成功后继续沿用现有逻辑:
- 登录继续走认证管理器与 `SysPasswordService`
- 注册继续走 BCrypt 加密入库
- 个人修改密码继续先校验旧密码,再加密新密码入库
- 管理员重置密码和新增用户继续走 BCrypt 加密入库
## 9. 配置设计
前后端分别维护固定对称密钥配置:
- 前端从环境变量读取固定密钥
- 后端从 `application.yml` 读取固定密钥
本次设计默认前后端使用同一把固定密钥,不涉及动态下发、轮换或多套密钥管理。
## 10. 错误处理
本次只采用单一路径,不做兼容分支:
- 受控正式接口收到密码字段后,后端默认按密文处理
- 任一密码字段解密失败,接口直接返回错误
- 不允许“尝试解密失败后继续按明文处理”
- 不允许只加密部分密码字段后继续流转
修改密码接口中,`oldPassword``newPassword` 必须同时成功解密后才能进入现有校验流程。
## 11. 非目标
本次不包含以下内容:
- 不修改 `/login/test`
- 不改造密码存储方式
- 不改造现有 BCrypt 校验逻辑
- 不引入非对称加密
- 不增加密钥动态下发能力
- 不增加明密文双通道兼容逻辑
- 不修改与密码无关的请求字段
## 12. 风险与控制
主要风险如下:
1. 前后端固定密钥不一致,会导致所有正式密码接口失败
2. 某些密码字段漏加密或漏解密,会导致登录失败或入库异常
3. 个人修改密码接口包含多个密码字段,若字段映射错误,会导致旧密码校验失败
控制方式:
- 前后端统一约定固定密钥配置名称与用途
- 将密码字段清单明确写入实现计划
- 将正式接口逐一纳入测试验证
- 保持 `/login/test` 完全不接入,避免影响现有测试用途
## 13. 验证方案
实施后至少验证以下场景:
1. `/login` 提交加密后的 `password` 可以正常登录
2. `/register` 提交加密后的 `password` 可以正常注册
3. `/system/user/profile/updatePwd` 提交加密后的 `oldPassword``newPassword` 可以正常修改密码
4. `/system/user/resetPwd` 提交加密后的 `password` 可以正常重置密码
5. `/system/user` 提交加密后的 `password` 可以正常新增用户
6. 受控正式接口在密文非法时直接失败
7. `/login/test` 仍按现有方式运行,不受本次改动影响
## 14. 实施范围
- 前端:登录、注册、个人中心、用户管理相关 API 和密码加密工具
- 后端:登录、注册、个人中心、用户管理相关控制器和密码解密工具
- 配置:前端环境配置、后端应用配置
- 数据库:无表结构改动
本次属于接口边界增强,不涉及数据库结构和核心认证机制重构。

View File

@@ -18,6 +18,7 @@ import com.ruoyi.common.core.text.Convert;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.framework.web.service.PasswordTransferCryptoService;
import com.ruoyi.framework.web.service.SysLoginService;
import com.ruoyi.framework.web.service.SysPermissionService;
import com.ruoyi.framework.web.service.TokenService;
@@ -47,6 +48,9 @@ public class SysLoginController
@Autowired
private ISysConfigService configService;
@Autowired
private PasswordTransferCryptoService passwordTransferCryptoService;
/**
* 登录方法
*
@@ -57,6 +61,7 @@ public class SysLoginController
public AjaxResult login(@RequestBody LoginBody loginBody)
{
AjaxResult ajax = AjaxResult.success();
loginBody.setPassword(passwordTransferCryptoService.decrypt(loginBody.getPassword()));
// 生成令牌
String token = loginService.login(loginBody.getUsername(), loginBody.getPassword(), loginBody.getCode(),
loginBody.getUuid());

View File

@@ -23,6 +23,7 @@ import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.file.FileUploadUtils;
import com.ruoyi.common.utils.file.FileUtils;
import com.ruoyi.common.utils.file.MimeTypeUtils;
import com.ruoyi.framework.web.service.PasswordTransferCryptoService;
import com.ruoyi.framework.web.service.TokenService;
import com.ruoyi.system.service.ISysUserService;
@@ -41,6 +42,9 @@ public class SysProfileController extends BaseController
@Autowired
private TokenService tokenService;
@Autowired
private PasswordTransferCryptoService passwordTransferCryptoService;
/**
* 个人信息
*/
@@ -92,8 +96,8 @@ public class SysProfileController extends BaseController
@PutMapping("/updatePwd")
public AjaxResult updatePwd(@RequestBody Map<String, String> params)
{
String oldPassword = params.get("oldPassword");
String newPassword = params.get("newPassword");
String oldPassword = passwordTransferCryptoService.decrypt(params.get("oldPassword"));
String newPassword = passwordTransferCryptoService.decrypt(params.get("newPassword"));
LoginUser loginUser = getLoginUser();
Long userId = loginUser.getUserId();
SysUser user = userService.selectUserById(userId);

View File

@@ -8,6 +8,7 @@ import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.domain.model.RegisterBody;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.framework.web.service.PasswordTransferCryptoService;
import com.ruoyi.framework.web.service.SysRegisterService;
import com.ruoyi.system.service.ISysConfigService;
@@ -25,6 +26,9 @@ public class SysRegisterController extends BaseController
@Autowired
private ISysConfigService configService;
@Autowired
private PasswordTransferCryptoService passwordTransferCryptoService;
@PostMapping("/register")
public AjaxResult register(@RequestBody RegisterBody user)
{
@@ -32,6 +36,7 @@ public class SysRegisterController extends BaseController
{
return error("当前系统没有开启注册功能!");
}
user.setPassword(passwordTransferCryptoService.decrypt(user.getPassword()));
String msg = registerService.register(user);
return StringUtils.isEmpty(msg) ? success() : error(msg);
}

View File

@@ -27,6 +27,7 @@ import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.framework.web.service.PasswordTransferCryptoService;
import com.ruoyi.system.service.ISysDeptService;
import com.ruoyi.system.service.ISysPostService;
import com.ruoyi.system.service.ISysRoleService;
@@ -53,6 +54,9 @@ public class SysUserController extends BaseController
@Autowired
private ISysPostService postService;
@Autowired
private PasswordTransferCryptoService passwordTransferCryptoService;
/**
* 获取用户列表
*/
@@ -139,6 +143,7 @@ public class SysUserController extends BaseController
return error("新增用户'" + user.getUserName() + "'失败,邮箱账号已存在");
}
user.setCreateBy(getUsername());
user.setPassword(passwordTransferCryptoService.decrypt(user.getPassword()));
user.setPassword(SecurityUtils.encryptPassword(user.getPassword()));
return toAjax(userService.insertUser(user));
}
@@ -196,6 +201,7 @@ public class SysUserController extends BaseController
{
userService.checkUserAllowed(user);
userService.checkUserDataScope(user.getUserId());
user.setPassword(passwordTransferCryptoService.decrypt(user.getPassword()));
user.setPassword(SecurityUtils.encryptPassword(user.getPassword()));
user.setUpdateBy(getUsername());
return toAjax(userService.resetPwd(user));

View File

@@ -1,7 +1,7 @@
# 开发环境配置
server:
# 服务器的HTTP端口默认为8080
port: 8080
# 服务器的HTTP端口默认为63310
port: 63310
servlet:
# 应用的访问路径
context-path: /
@@ -79,4 +79,8 @@ spring:
config:
multi-statement-allow: true
model:
url: http://localhost:8080/rate/pricing/mock/invokeModel
url: http://localhost:63310/rate/pricing/mock/invokeModel
security:
password-transfer:
key: "1234567890abcdef"

View File

@@ -0,0 +1,86 @@
# 开发环境配置
server:
# 服务器的HTTP端口默认为63310
port: 63310
servlet:
# 应用的访问路径
context-path: /
tomcat:
# tomcat的URI编码
uri-encoding: UTF-8
# 连接数满后的排队数默认为100
accept-count: 1000
threads:
# tomcat最大线程数默认为200
max: 800
# Tomcat启动初始化的线程数默认值10
min-spare: 100
# 数据源配置
spring:
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driverClassName: com.mysql.cj.jdbc.Driver
druid:
# 主库数据源
master:
url: jdbc:mysql://64.127.23.6:3306/loan-pricing?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
username: lrdb
password: Synx@2024
# 从库数据源
slave:
# 从数据源开关/默认关闭
enabled: false
url:
username:
password:
# 初始连接数
initialSize: 5
# 最小连接池数量
minIdle: 10
# 最大连接池数量
maxActive: 20
# 配置获取连接等待超时的时间
maxWait: 60000
# 配置连接超时时间
connectTimeout: 30000
# 配置网络超时时间
socketTimeout: 60000
# 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
timeBetweenEvictionRunsMillis: 60000
# 配置一个连接在池中最小生存的时间,单位是毫秒
minEvictableIdleTimeMillis: 300000
# 配置一个连接在池中最大生存的时间,单位是毫秒
maxEvictableIdleTimeMillis: 900000
# 配置检测连接是否有效
validationQuery: SELECT 1 FROM DUAL
testWhileIdle: true
testOnBorrow: false
testOnReturn: false
webStatFilter:
enabled: true
statViewServlet:
enabled: true
# 设置白名单,不填则允许所有访问
allow:
url-pattern: /druid/*
# 控制台管理用户名和密码
login-username: ruoyi
login-password: 123456
filter:
stat:
enabled: true
# 慢SQL记录
log-slow-sql: true
slow-sql-millis: 1000
merge-sql: true
wall:
config:
multi-statement-allow: true
model:
url: http://localhost:63310/rate/pricing/mock/invokeModel
security:
password-transfer:
key: "1234567890abcdef"

View File

@@ -0,0 +1,86 @@
# 开发环境配置
server:
# 服务器的HTTP端口默认为63310
port: 63310
servlet:
# 应用的访问路径
context-path: /
tomcat:
# tomcat的URI编码
uri-encoding: UTF-8
# 连接数满后的排队数默认为100
accept-count: 1000
threads:
# tomcat最大线程数默认为200
max: 800
# Tomcat启动初始化的线程数默认值10
min-spare: 100
# 数据源配置
spring:
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driverClassName: com.mysql.cj.jdbc.Driver
druid:
# 主库数据源
master:
url: jdbc:mysql://192.168.0.111:40628/loan-pricing?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
username: root
password: Kfcx@1234
# 从库数据源
slave:
# 从数据源开关/默认关闭
enabled: false
url:
username:
password:
# 初始连接数
initialSize: 5
# 最小连接池数量
minIdle: 10
# 最大连接池数量
maxActive: 20
# 配置获取连接等待超时的时间
maxWait: 60000
# 配置连接超时时间
connectTimeout: 30000
# 配置网络超时时间
socketTimeout: 60000
# 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
timeBetweenEvictionRunsMillis: 60000
# 配置一个连接在池中最小生存的时间,单位是毫秒
minEvictableIdleTimeMillis: 300000
# 配置一个连接在池中最大生存的时间,单位是毫秒
maxEvictableIdleTimeMillis: 900000
# 配置检测连接是否有效
validationQuery: SELECT 1 FROM DUAL
testWhileIdle: true
testOnBorrow: false
testOnReturn: false
webStatFilter:
enabled: true
statViewServlet:
enabled: true
# 设置白名单,不填则允许所有访问
allow:
url-pattern: /druid/*
# 控制台管理用户名和密码
login-username: ruoyi
login-password: 123456
filter:
stat:
enabled: true
# 慢SQL记录
log-slow-sql: true
slow-sql-millis: 1000
merge-sql: true
wall:
config:
multi-statement-allow: true
model:
url: http://localhost:63310/rate/pricing/mock/invokeModel
security:
password-transfer:
key: "1234567890abcdef"

View File

@@ -99,3 +99,11 @@ xss:
excludes: /system/notice
# 匹配链接
urlPatterns: /system/*,/monitor/*,/tool/*
security:
password-transfer:
key: "1234567890abcdef"
loan-pricing:
sensitive:
key: "1234567890abcdef"

View File

@@ -0,0 +1,40 @@
package com.ruoyi.web.controller.system;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import com.ruoyi.framework.web.service.PasswordTransferCryptoService;
import com.ruoyi.framework.web.service.SysLoginService;
class SysLoginControllerPasswordTransferTest
{
@Test
void shouldDecryptPasswordBeforeCallingLoginService() throws Exception
{
SysLoginService loginService = mock(SysLoginService.class);
PasswordTransferCryptoService passwordTransferCryptoService = mock(PasswordTransferCryptoService.class);
when(passwordTransferCryptoService.decrypt("cipher")).thenReturn("admin123");
when(loginService.login("admin", "admin123", "1", "u")).thenReturn("token");
SysLoginController controller = new SysLoginController();
ReflectionTestUtils.setField(controller, "loginService", loginService);
ReflectionTestUtils.setField(controller, "passwordTransferCryptoService", passwordTransferCryptoService);
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
mockMvc.perform(post("/login")
.contentType("application/json")
.content("{\"username\":\"admin\",\"password\":\"cipher\",\"code\":\"1\",\"uuid\":\"u\"}"))
.andExpect(status().isOk());
verify(passwordTransferCryptoService).decrypt("cipher");
verify(loginService).login("admin", "admin123", "1", "u");
}
}

View File

@@ -0,0 +1,72 @@
package com.ruoyi.web.controller.system;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.util.Collections;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.framework.web.service.PasswordTransferCryptoService;
import com.ruoyi.framework.web.service.TokenService;
import com.ruoyi.system.service.ISysUserService;
class SysProfileControllerPasswordTransferTest
{
@AfterEach
void tearDown()
{
SecurityContextHolder.clearContext();
}
@Test
void shouldDecryptPasswordsBeforeCheckingOldPassword() throws Exception
{
ISysUserService userService = mock(ISysUserService.class);
TokenService tokenService = mock(TokenService.class);
PasswordTransferCryptoService passwordTransferCryptoService = mock(PasswordTransferCryptoService.class);
when(passwordTransferCryptoService.decrypt("oldCipher")).thenReturn("oldPlain");
when(passwordTransferCryptoService.decrypt("newCipher")).thenReturn("newPlain");
when(userService.resetUserPwd(org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyString()))
.thenReturn(1);
SysUser storedUser = new SysUser();
storedUser.setUserId(2L);
storedUser.setPassword(SecurityUtils.encryptPassword("oldPlain"));
when(userService.selectUserById(2L)).thenReturn(storedUser);
SysUser currentUser = new SysUser();
currentUser.setUserId(2L);
currentUser.setUserName("admin");
LoginUser loginUser = new LoginUser(2L, 1L, currentUser, Collections.emptySet());
SecurityContextHolder.getContext()
.setAuthentication(new UsernamePasswordAuthenticationToken(loginUser, null, Collections.emptyList()));
SysProfileController controller = new SysProfileController();
ReflectionTestUtils.setField(controller, "userService", userService);
ReflectionTestUtils.setField(controller, "tokenService", tokenService);
ReflectionTestUtils.setField(controller, "passwordTransferCryptoService", passwordTransferCryptoService);
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
mockMvc.perform(put("/system/user/profile/updatePwd")
.contentType("application/json")
.content("{\"oldPassword\":\"oldCipher\",\"newPassword\":\"newCipher\"}"))
.andExpect(status().isOk());
verify(passwordTransferCryptoService).decrypt("oldCipher");
verify(passwordTransferCryptoService).decrypt("newCipher");
verify(userService).resetUserPwd(org.mockito.ArgumentMatchers.eq(2L), org.mockito.ArgumentMatchers.anyString());
verify(tokenService).setLoginUser(loginUser);
}
}

View File

@@ -0,0 +1,50 @@
package com.ruoyi.web.controller.system;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import com.ruoyi.common.core.domain.model.RegisterBody;
import com.ruoyi.framework.web.service.PasswordTransferCryptoService;
import com.ruoyi.framework.web.service.SysRegisterService;
import com.ruoyi.system.service.ISysConfigService;
class SysRegisterControllerPasswordTransferTest
{
@Test
void shouldDecryptPasswordBeforeCallingRegisterService() throws Exception
{
SysRegisterService registerService = mock(SysRegisterService.class);
ISysConfigService configService = mock(ISysConfigService.class);
PasswordTransferCryptoService passwordTransferCryptoService = mock(PasswordTransferCryptoService.class);
when(configService.selectConfigByKey("sys.account.registerUser")).thenReturn("true");
when(passwordTransferCryptoService.decrypt("cipher")).thenReturn("admin123");
when(registerService.register(any(RegisterBody.class))).thenReturn("");
SysRegisterController controller = new SysRegisterController();
ReflectionTestUtils.setField(controller, "registerService", registerService);
ReflectionTestUtils.setField(controller, "configService", configService);
ReflectionTestUtils.setField(controller, "passwordTransferCryptoService", passwordTransferCryptoService);
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
mockMvc.perform(post("/register")
.contentType("application/json")
.content("{\"username\":\"u1\",\"password\":\"cipher\",\"code\":\"1\",\"uuid\":\"u\"}"))
.andExpect(status().isOk());
verify(passwordTransferCryptoService).decrypt("cipher");
ArgumentCaptor<RegisterBody> captor = ArgumentCaptor.forClass(RegisterBody.class);
verify(registerService).register(captor.capture());
assertEquals("admin123", captor.getValue().getPassword());
}
}

View File

@@ -0,0 +1,113 @@
package com.ruoyi.web.controller.system;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.util.Collections;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.framework.web.service.PasswordTransferCryptoService;
import com.ruoyi.system.service.ISysDeptService;
import com.ruoyi.system.service.ISysPostService;
import com.ruoyi.system.service.ISysRoleService;
import com.ruoyi.system.service.ISysUserService;
class SysUserControllerPasswordTransferTest
{
@AfterEach
void tearDown()
{
SecurityContextHolder.clearContext();
}
@Test
void shouldDecryptPasswordBeforeAddingUser() throws Exception
{
ISysUserService userService = mock(ISysUserService.class);
ISysRoleService roleService = mock(ISysRoleService.class);
ISysDeptService deptService = mock(ISysDeptService.class);
ISysPostService postService = mock(ISysPostService.class);
PasswordTransferCryptoService passwordTransferCryptoService = mock(PasswordTransferCryptoService.class);
when(passwordTransferCryptoService.decrypt("cipher")).thenReturn("initPwd");
when(userService.checkUserNameUnique(org.mockito.ArgumentMatchers.any(SysUser.class))).thenReturn(true);
when(userService.insertUser(org.mockito.ArgumentMatchers.any(SysUser.class))).thenReturn(1);
setAuthentication();
SysUserController controller = new SysUserController();
ReflectionTestUtils.setField(controller, "userService", userService);
ReflectionTestUtils.setField(controller, "roleService", roleService);
ReflectionTestUtils.setField(controller, "deptService", deptService);
ReflectionTestUtils.setField(controller, "postService", postService);
ReflectionTestUtils.setField(controller, "passwordTransferCryptoService", passwordTransferCryptoService);
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
mockMvc.perform(post("/system/user")
.contentType("application/json")
.content("{\"userName\":\"u1\",\"nickName\":\"n1\",\"deptId\":1,\"password\":\"cipher\"}"))
.andExpect(status().isOk());
verify(passwordTransferCryptoService).decrypt("cipher");
ArgumentCaptor<SysUser> captor = ArgumentCaptor.forClass(SysUser.class);
verify(userService).insertUser(captor.capture());
assertTrue(SecurityUtils.matchesPassword("initPwd", captor.getValue().getPassword()));
}
@Test
void shouldDecryptPasswordBeforeResettingUserPassword() throws Exception
{
ISysUserService userService = mock(ISysUserService.class);
ISysRoleService roleService = mock(ISysRoleService.class);
ISysDeptService deptService = mock(ISysDeptService.class);
ISysPostService postService = mock(ISysPostService.class);
PasswordTransferCryptoService passwordTransferCryptoService = mock(PasswordTransferCryptoService.class);
when(passwordTransferCryptoService.decrypt("cipher")).thenReturn("resetPwd");
when(userService.resetPwd(org.mockito.ArgumentMatchers.any(SysUser.class))).thenReturn(1);
setAuthentication();
SysUserController controller = new SysUserController();
ReflectionTestUtils.setField(controller, "userService", userService);
ReflectionTestUtils.setField(controller, "roleService", roleService);
ReflectionTestUtils.setField(controller, "deptService", deptService);
ReflectionTestUtils.setField(controller, "postService", postService);
ReflectionTestUtils.setField(controller, "passwordTransferCryptoService", passwordTransferCryptoService);
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
mockMvc.perform(put("/system/user/resetPwd")
.contentType("application/json")
.content("{\"userId\":2,\"password\":\"cipher\"}"))
.andExpect(status().isOk());
verify(passwordTransferCryptoService).decrypt("cipher");
ArgumentCaptor<SysUser> captor = ArgumentCaptor.forClass(SysUser.class);
verify(userService).resetPwd(captor.capture());
assertTrue(SecurityUtils.matchesPassword("resetPwd", captor.getValue().getPassword()));
}
private void setAuthentication()
{
SysUser currentUser = new SysUser();
currentUser.setUserId(1L);
currentUser.setUserName("admin");
LoginUser loginUser = new LoginUser(1L, 1L, currentUser, Collections.emptySet());
SecurityContextHolder.getContext()
.setAuthentication(new UsernamePasswordAuthenticationToken(loginUser, null, Collections.emptyList()));
}
}

View File

@@ -0,0 +1,30 @@
package com.ruoyi.framework.web.service;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import com.ruoyi.common.exception.ServiceException;
@Service
public class PasswordTransferCryptoService
{
@Value("${security.password-transfer.key}")
private String key;
public String decrypt(String cipherText)
{
try
{
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "AES"));
return new String(cipher.doFinal(Base64.getDecoder().decode(cipherText)), StandardCharsets.UTF_8);
}
catch (Exception ex)
{
throw new ServiceException("密码解密失败");
}
}
}

View File

@@ -0,0 +1,47 @@
package com.ruoyi.framework.web.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import com.ruoyi.common.exception.ServiceException;
class PasswordTransferCryptoServiceTest
{
private static final String KEY = "1234567890abcdef";
private PasswordTransferCryptoService service;
@BeforeEach
void setUp()
{
service = new PasswordTransferCryptoService();
ReflectionTestUtils.setField(service, "key", KEY);
}
@Test
void shouldDecryptValidCipherText() throws Exception
{
String plain = service.decrypt(encrypt("admin123"));
assertEquals("admin123", plain);
}
@Test
void shouldRejectInvalidCipherText()
{
assertThrows(ServiceException.class, () -> service.decrypt("not-base64"));
}
private String encrypt(String plainText) throws Exception
{
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(KEY.getBytes(StandardCharsets.UTF_8), "AES"));
return Base64.getEncoder().encodeToString(cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)));
}
}

View File

@@ -150,4 +150,9 @@ public class ModelInvokeDTO {
*/
private String idType;
/**
* 证件号码(非必填)
*/
private String idNum;
}

View File

@@ -39,12 +39,25 @@ public class LoanPricingModelService {
@Resource
private ModelCorpOutputFieldsMapper modelCorpOutputFieldsMapper;
@Resource
private SensitiveFieldCryptoService sensitiveFieldCryptoService;
public void invokeModelAsync(Long workflowId) {
LoanPricingWorkflow loanPricingWorkflow = loanPricingWorkflowMapper.selectById(workflowId);
if (Objects.isNull(loanPricingWorkflow)){
log.error("未找到对应的流程信息,未调用模型服务");
return;
}
try
{
loanPricingWorkflow.setCustName(sensitiveFieldCryptoService.decrypt(loanPricingWorkflow.getCustName()));
loanPricingWorkflow.setIdNum(sensitiveFieldCryptoService.decrypt(loanPricingWorkflow.getIdNum()));
}
catch (RuntimeException ex)
{
log.error("贷款定价模型调用前敏感字段解密失败", ex);
throw ex;
}
ModelInvokeDTO modelInvokeDTO = new ModelInvokeDTO();
BeanUtils.copyProperties(loanPricingWorkflow, modelInvokeDTO);
JSONObject response = modelService.invokeModel(modelInvokeDTO);
@@ -53,16 +66,20 @@ public class LoanPricingModelService {
ModelRetailOutputFields modelRetailOutputFields = JSON.parseObject(response.toJSONString(), ModelRetailOutputFields.class);
modelRetailOutputFieldsMapper.insert(modelRetailOutputFields);
log.info("个人模型调用成功");
loanPricingWorkflow.setModelOutputId(modelRetailOutputFields.getId());
loanPricingWorkflowMapper.updateById(loanPricingWorkflow);
LoanPricingWorkflow workflowToUpdate = new LoanPricingWorkflow();
workflowToUpdate.setId(loanPricingWorkflow.getId());
workflowToUpdate.setModelOutputId(modelRetailOutputFields.getId());
loanPricingWorkflowMapper.updateById(workflowToUpdate);
log.info("更新流程信息成功");
}else if (loanPricingWorkflow.getCustType().equals("企业")){
// 企业模型
ModelCorpOutputFields modelCorpOutputFields = JSON.parseObject(response.toJSONString(), ModelCorpOutputFields.class);
modelCorpOutputFieldsMapper.insert(modelCorpOutputFields);
log.info("企业模型调用成功");
loanPricingWorkflow.setModelOutputId(modelCorpOutputFields.getId());
loanPricingWorkflowMapper.updateById(loanPricingWorkflow);
LoanPricingWorkflow workflowToUpdate = new LoanPricingWorkflow();
workflowToUpdate.setId(loanPricingWorkflow.getId());
workflowToUpdate.setModelOutputId(modelCorpOutputFields.getId());
loanPricingWorkflowMapper.updateById(workflowToUpdate);
log.info("更新流程信息成功");
}
}

View File

@@ -0,0 +1,46 @@
package com.ruoyi.loanpricing.service;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@Service
public class LoanPricingSensitiveDisplayService
{
public String maskCustName(String custName)
{
if (!StringUtils.hasText(custName))
{
return custName;
}
if (custName.contains("公司") && custName.length() > 4)
{
return custName.substring(0, 2) + "*".repeat(custName.length() - 4) + custName.substring(custName.length() - 2);
}
if (custName.length() == 1)
{
return custName;
}
return custName.substring(0, 1) + "*".repeat(custName.length() - 1);
}
public String maskIdNum(String idNum)
{
if (!StringUtils.hasText(idNum))
{
return idNum;
}
if (idNum.startsWith("91") && idNum.length() == 18)
{
return idNum.substring(0, 2) + "*".repeat(13) + idNum.substring(idNum.length() - 3);
}
if (idNum.matches("\\d{17}[\\dXx]"))
{
return idNum.substring(0, 4) + "*".repeat(8) + idNum.substring(idNum.length() - 4);
}
if (idNum.length() > 5)
{
return idNum.substring(0, 2) + "*".repeat(idNum.length() - 5) + idNum.substring(idNum.length() - 3);
}
return "*".repeat(idNum.length());
}
}

View File

@@ -0,0 +1,68 @@
package com.ruoyi.loanpricing.service;
import com.ruoyi.common.exception.ServiceException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
@Service
public class SensitiveFieldCryptoService
{
private final String key;
public SensitiveFieldCryptoService(@Value("${loan-pricing.sensitive.key:}") String key)
{
this.key = key;
}
public String encrypt(String plainText)
{
validateKey();
if (!StringUtils.hasText(plainText))
{
return plainText;
}
try
{
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "AES"));
return Base64.getEncoder().encodeToString(cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)));
}
catch (Exception ex)
{
throw new ServiceException("贷款定价敏感字段加密失败");
}
}
public String decrypt(String cipherText)
{
validateKey();
if (!StringUtils.hasText(cipherText))
{
return cipherText;
}
try
{
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "AES"));
return new String(cipher.doFinal(Base64.getDecoder().decode(cipherText)), StandardCharsets.UTF_8);
}
catch (Exception ex)
{
throw new ServiceException("贷款定价敏感字段解密失败");
}
}
private void validateKey()
{
if (!StringUtils.hasText(key))
{
throw new IllegalStateException("loan-pricing.sensitive.key 未配置");
}
}
}

View File

@@ -14,7 +14,9 @@ import com.ruoyi.loanpricing.mapper.LoanPricingWorkflowMapper;
import com.ruoyi.loanpricing.mapper.ModelCorpOutputFieldsMapper;
import com.ruoyi.loanpricing.mapper.ModelRetailOutputFieldsMapper;
import com.ruoyi.loanpricing.service.ILoanPricingWorkflowService;
import com.ruoyi.loanpricing.service.LoanPricingSensitiveDisplayService;
import com.ruoyi.loanpricing.service.LoanPricingModelService;
import com.ruoyi.loanpricing.service.SensitiveFieldCryptoService;
import com.ruoyi.loanpricing.util.LoanPricingConverter;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
@@ -47,6 +49,12 @@ public class LoanPricingWorkflowServiceImpl implements ILoanPricingWorkflowServi
@Resource
private ModelCorpOutputFieldsMapper modelCorpOutputFieldsMapper;
@Resource
private SensitiveFieldCryptoService sensitiveFieldCryptoService;
@Resource
private LoanPricingSensitiveDisplayService loanPricingSensitiveDisplayService;
/**
* 发起利率定价流程
@@ -72,6 +80,8 @@ public class LoanPricingWorkflowServiceImpl implements ILoanPricingWorkflowServi
loanPricingWorkflow.setRunType("1");
}
loanPricingWorkflow.setCustName(sensitiveFieldCryptoService.encrypt(loanPricingWorkflow.getCustName()));
loanPricingWorkflow.setIdNum(sensitiveFieldCryptoService.encrypt(loanPricingWorkflow.getIdNum()));
loanPricingWorkflowMapper.insert(loanPricingWorkflow);
loanPricingModelService.invokeModelAsync(loanPricingWorkflow.getId());
@@ -129,7 +139,11 @@ public class LoanPricingWorkflowServiceImpl implements ILoanPricingWorkflowServi
@Override
public IPage<LoanPricingWorkflowListVO> selectLoanPricingPage(Page<LoanPricingWorkflowListVO> page, LoanPricingWorkflow loanPricingWorkflow)
{
return loanPricingWorkflowMapper.selectWorkflowPageWithRates(page, loanPricingWorkflow);
IPage<LoanPricingWorkflowListVO> pageResult = loanPricingWorkflowMapper.selectWorkflowPageWithRates(page, loanPricingWorkflow);
pageResult.getRecords().forEach(row -> row.setCustName(
loanPricingSensitiveDisplayService.maskCustName(
sensitiveFieldCryptoService.decrypt(row.getCustName()))));
return pageResult;
}
/**
@@ -146,6 +160,10 @@ public class LoanPricingWorkflowServiceImpl implements ILoanPricingWorkflowServi
LambdaQueryWrapper<LoanPricingWorkflow> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(LoanPricingWorkflow::getSerialNum, serialNum);
LoanPricingWorkflow loanPricingWorkflow = loanPricingWorkflowMapper.selectOne(wrapper);
String plainCustName = sensitiveFieldCryptoService.decrypt(loanPricingWorkflow.getCustName());
String plainIdNum = sensitiveFieldCryptoService.decrypt(loanPricingWorkflow.getIdNum());
loanPricingWorkflow.setCustName(loanPricingSensitiveDisplayService.maskCustName(plainCustName));
loanPricingWorkflow.setIdNum(loanPricingSensitiveDisplayService.maskIdNum(plainIdNum));
loanPricingWorkflowVO.setLoanPricingWorkflow(loanPricingWorkflow);
if (Objects.nonNull(loanPricingWorkflow.getModelOutputId())){
@@ -153,6 +171,7 @@ public class LoanPricingWorkflowServiceImpl implements ILoanPricingWorkflowServi
ModelRetailOutputFields modelRetailOutputFields = modelRetailOutputFieldsMapper.selectById(loanPricingWorkflow.getModelOutputId());
if (Objects.nonNull(modelRetailOutputFields))
{
maskModelRetailOutputBasicInfo(modelRetailOutputFields);
loanPricingWorkflow.setLoanRate(modelRetailOutputFields.getCalculateRate());
}
loanPricingWorkflowVO.setModelRetailOutputFields(modelRetailOutputFields);
@@ -161,6 +180,7 @@ public class LoanPricingWorkflowServiceImpl implements ILoanPricingWorkflowServi
ModelCorpOutputFields modelCorpOutputFields = modelCorpOutputFieldsMapper.selectById(loanPricingWorkflow.getModelOutputId());
if (Objects.nonNull(modelCorpOutputFields))
{
maskModelCorpOutputBasicInfo(modelCorpOutputFields);
loanPricingWorkflow.setLoanRate(modelCorpOutputFields.getCalculateRate());
}
loanPricingWorkflowVO.setModelCorpOutputFields(modelCorpOutputFields);
@@ -187,10 +207,10 @@ public class LoanPricingWorkflowServiceImpl implements ILoanPricingWorkflowServi
wrapper.like(LoanPricingWorkflow::getCreateBy, loanPricingWorkflow.getCreateBy());
}
// 按客户名称模糊查询
if (StringUtils.hasText(loanPricingWorkflow.getCustName()))
// 按客户内码模糊查询
if (StringUtils.hasText(loanPricingWorkflow.getCustIsn()))
{
wrapper.like(LoanPricingWorkflow::getCustName, loanPricingWorkflow.getCustName());
wrapper.like(LoanPricingWorkflow::getCustIsn, loanPricingWorkflow.getCustIsn());
}
// 按机构号筛选
@@ -202,6 +222,22 @@ public class LoanPricingWorkflowServiceImpl implements ILoanPricingWorkflowServi
return wrapper;
}
private void maskModelRetailOutputBasicInfo(ModelRetailOutputFields modelRetailOutputFields)
{
modelRetailOutputFields.setCustName(
loanPricingSensitiveDisplayService.maskCustName(modelRetailOutputFields.getCustName()));
modelRetailOutputFields.setIdNum(
loanPricingSensitiveDisplayService.maskIdNum(modelRetailOutputFields.getIdNum()));
}
private void maskModelCorpOutputBasicInfo(ModelCorpOutputFields modelCorpOutputFields)
{
modelCorpOutputFields.setCustName(
loanPricingSensitiveDisplayService.maskCustName(modelCorpOutputFields.getCustName()));
modelCorpOutputFields.setIdNum(
loanPricingSensitiveDisplayService.maskIdNum(modelCorpOutputFields.getIdNum()));
}
/**
* 设定执行利率
*

View File

@@ -26,8 +26,8 @@
<if test="query != null and query.createBy != null and query.createBy != ''">
AND lpw.create_by LIKE CONCAT('%', #{query.createBy}, '%')
</if>
<if test="query != null and query.custName != null and query.custName != ''">
AND lpw.cust_name LIKE CONCAT('%', #{query.custName}, '%')
<if test="query != null and query.custIsn != null and query.custIsn != ''">
AND lpw.cust_isn LIKE CONCAT('%', #{query.custIsn}, '%')
</if>
<if test="query != null and query.orgCode != null and query.orgCode != ''">
AND lpw.org_code LIKE CONCAT('%', #{query.orgCode}, '%')

View File

@@ -0,0 +1,90 @@
package com.ruoyi.loanpricing.service;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.alibaba.fastjson2.JSONObject;
import com.ruoyi.loanpricing.domain.dto.ModelInvokeDTO;
import com.ruoyi.loanpricing.domain.entity.LoanPricingWorkflow;
import com.ruoyi.loanpricing.mapper.LoanPricingWorkflowMapper;
import com.ruoyi.loanpricing.mapper.ModelCorpOutputFieldsMapper;
import com.ruoyi.loanpricing.mapper.ModelRetailOutputFieldsMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Objects;
@ExtendWith(MockitoExtension.class)
class LoanPricingModelServiceTest
{
@Mock
private ModelService modelService;
@Mock
private LoanPricingWorkflowMapper loanPricingWorkflowMapper;
@Mock
private ModelRetailOutputFieldsMapper modelRetailOutputFieldsMapper;
@Mock
private ModelCorpOutputFieldsMapper modelCorpOutputFieldsMapper;
@Mock
private SensitiveFieldCryptoService sensitiveFieldCryptoService;
@InjectMocks
private LoanPricingModelService loanPricingModelService;
@Test
void shouldDecryptCustNameAndIdNumBeforeInvokeModel()
{
LoanPricingWorkflow workflow = new LoanPricingWorkflow();
workflow.setId(1L);
workflow.setCustType("个人");
workflow.setCustName("cipher-name");
workflow.setIdNum("cipher-id");
JSONObject response = new JSONObject();
response.put("calculateRate", "6.15");
when(loanPricingWorkflowMapper.selectById(1L)).thenReturn(workflow);
when(sensitiveFieldCryptoService.decrypt("cipher-name")).thenReturn("张三");
when(sensitiveFieldCryptoService.decrypt("cipher-id")).thenReturn("110101199001011234");
when(modelService.invokeModel(any())).thenReturn(response);
loanPricingModelService.invokeModelAsync(1L);
verify(modelService).invokeModel(argThat((ModelInvokeDTO dto) ->
Objects.equals("张三", dto.getCustName())
&& Objects.equals("110101199001011234", dto.getIdNum())));
}
@Test
void shouldNotWritePlainCustNameAndIdNumBackWhenUpdatingWorkflow()
{
LoanPricingWorkflow workflow = new LoanPricingWorkflow();
workflow.setId(2L);
workflow.setCustType("个人");
workflow.setCustName("cipher-name");
workflow.setIdNum("cipher-id");
JSONObject response = new JSONObject();
response.put("calculateRate", "6.15");
when(loanPricingWorkflowMapper.selectById(2L)).thenReturn(workflow);
when(sensitiveFieldCryptoService.decrypt("cipher-name")).thenReturn("张三");
when(sensitiveFieldCryptoService.decrypt("cipher-id")).thenReturn("110101199001011234");
when(modelService.invokeModel(any())).thenReturn(response);
loanPricingModelService.invokeModelAsync(2L);
verify(loanPricingWorkflowMapper).updateById(argThat((LoanPricingWorkflow entity) ->
!Objects.equals("张三", entity.getCustName())
&& !Objects.equals("110101199001011234", entity.getIdNum())));
}
}

View File

@@ -0,0 +1,24 @@
package com.ruoyi.loanpricing.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
class LoanPricingSensitiveDisplayServiceTest
{
private final LoanPricingSensitiveDisplayService displayService = new LoanPricingSensitiveDisplayService();
@Test
void shouldMaskPersonalNameAndIdNum()
{
assertEquals("张*", displayService.maskCustName("张三"));
assertEquals("1101********1234", displayService.maskIdNum("110101199001011234"));
}
@Test
void shouldMaskCorporateNameAndCreditCode()
{
assertEquals("测试****公司", displayService.maskCustName("测试科技有限公司"));
assertEquals("91*************00X", displayService.maskIdNum("91110000100000000X"));
}
}

View File

@@ -0,0 +1,32 @@
package com.ruoyi.loanpricing.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.api.Test;
class SensitiveFieldCryptoServiceTest
{
@Test
void shouldEncryptAndDecryptCustNameAndIdNum()
{
SensitiveFieldCryptoService service = new SensitiveFieldCryptoService("1234567890abcdef");
String nameCipher = service.encrypt("张三");
String idNumCipher = service.encrypt("110101199001011234");
assertNotEquals("张三", nameCipher);
assertNotEquals("110101199001011234", idNumCipher);
assertEquals("张三", service.decrypt(nameCipher));
assertEquals("110101199001011234", service.decrypt(idNumCipher));
}
@Test
void shouldRejectBlankKeyConfiguration()
{
SensitiveFieldCryptoService service = new SensitiveFieldCryptoService("");
assertThrows(IllegalStateException.class, () -> service.encrypt("张三"));
}
}

View File

@@ -1,10 +1,16 @@
package com.ruoyi.loanpricing.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.loanpricing.domain.entity.LoanPricingWorkflow;
import com.ruoyi.loanpricing.domain.entity.ModelCorpOutputFields;
@@ -14,13 +20,20 @@ import com.ruoyi.loanpricing.domain.vo.LoanPricingWorkflowVO;
import com.ruoyi.loanpricing.mapper.LoanPricingWorkflowMapper;
import com.ruoyi.loanpricing.mapper.ModelCorpOutputFieldsMapper;
import com.ruoyi.loanpricing.mapper.ModelRetailOutputFieldsMapper;
import com.ruoyi.loanpricing.service.LoanPricingSensitiveDisplayService;
import com.ruoyi.loanpricing.service.LoanPricingModelService;
import com.ruoyi.loanpricing.service.SensitiveFieldCryptoService;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Collections;
import java.util.Objects;
@ExtendWith(MockitoExtension.class)
class LoanPricingWorkflowServiceImplTest
{
@@ -36,9 +49,34 @@ class LoanPricingWorkflowServiceImplTest
@Mock
private ModelCorpOutputFieldsMapper modelCorpOutputFieldsMapper;
@Mock
private SensitiveFieldCryptoService sensitiveFieldCryptoService;
@Mock
private LoanPricingSensitiveDisplayService loanPricingSensitiveDisplayService;
@InjectMocks
private LoanPricingWorkflowServiceImpl loanPricingWorkflowService;
@Test
void shouldEncryptCustNameAndIdNumBeforeInsert()
{
LoanPricingWorkflow workflow = new LoanPricingWorkflow();
workflow.setCustName("张三");
workflow.setIdNum("110101199001011234");
workflow.setCustIsn("CUST001");
when(sensitiveFieldCryptoService.encrypt("张三")).thenReturn("cipher-name");
when(sensitiveFieldCryptoService.encrypt("110101199001011234")).thenReturn("cipher-id");
loanPricingWorkflowService.createLoanPricing(workflow);
verify(loanPricingWorkflowMapper).insert(argThat((LoanPricingWorkflow entity) ->
Objects.equals("cipher-name", entity.getCustName())
&& Objects.equals("cipher-id", entity.getIdNum())
&& Objects.equals("CUST001", entity.getCustIsn())));
}
@Test
void shouldReturnPagedWorkflowListWithCalculateRate()
{
@@ -55,6 +93,46 @@ class LoanPricingWorkflowServiceImplTest
assertEquals("6.15", result.getRecords().get(0).getCalculateRate());
}
@Test
void shouldMaskCustNameWhenReturningPagedWorkflowList()
{
LoanPricingWorkflowListVO row = new LoanPricingWorkflowListVO();
row.setCustName("cipher-name");
Page<LoanPricingWorkflowListVO> pageResult = new Page<>(1, 10);
pageResult.setRecords(Collections.singletonList(row));
when(loanPricingWorkflowMapper.selectWorkflowPageWithRates(any(), any())).thenReturn(pageResult);
when(sensitiveFieldCryptoService.decrypt("cipher-name")).thenReturn("张三");
when(loanPricingSensitiveDisplayService.maskCustName("张三")).thenReturn("张*");
IPage<LoanPricingWorkflowListVO> result = loanPricingWorkflowService.selectLoanPricingPage(new Page<>(1, 10), new LoanPricingWorkflow());
assertEquals("张*", result.getRecords().get(0).getCustName());
}
@Test
void shouldUseCustIsnInsteadOfCustNameAsQueryCondition()
{
LoanPricingWorkflow query = new LoanPricingWorkflow();
query.setCustIsn("CUST001");
query.setCustName("张三");
when(loanPricingWorkflowMapper.selectList(any())).thenReturn(Collections.emptyList());
loanPricingWorkflowService.selectLoanPricingList(query);
ArgumentCaptor<LambdaQueryWrapper<LoanPricingWorkflow>> wrapperCaptor = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(loanPricingWorkflowMapper).selectList(wrapperCaptor.capture());
LambdaQueryWrapper<LoanPricingWorkflow> wrapper = wrapperCaptor.getValue();
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), LoanPricingWorkflow.class);
String sqlSegment = wrapper.getSqlSegment();
assertTrue(sqlSegment.contains("cust_isn"), sqlSegment);
assertTrue(!sqlSegment.contains("cust_name"), sqlSegment);
}
@Test
void shouldUseRetailModelOutputCalculateRateForWorkflowDetail()
{
@@ -76,6 +154,55 @@ class LoanPricingWorkflowServiceImplTest
assertEquals("6.15", result.getModelRetailOutputFields().getCalculateRate());
}
@Test
void shouldMaskCustNameAndIdNumWhenReturningWorkflowDetail()
{
LoanPricingWorkflow workflow = new LoanPricingWorkflow();
workflow.setSerialNum("P20260328001");
workflow.setCustType("个人");
workflow.setCustName("cipher-name");
workflow.setIdNum("cipher-id");
when(loanPricingWorkflowMapper.selectOne(any())).thenReturn(workflow);
when(sensitiveFieldCryptoService.decrypt("cipher-name")).thenReturn("张三");
when(sensitiveFieldCryptoService.decrypt("cipher-id")).thenReturn("110101199001011234");
when(loanPricingSensitiveDisplayService.maskCustName("张三")).thenReturn("张*");
when(loanPricingSensitiveDisplayService.maskIdNum("110101199001011234")).thenReturn("1101********1234");
LoanPricingWorkflowVO result = loanPricingWorkflowService.selectLoanPricingBySerialNum("P20260328001");
assertEquals("张*", result.getLoanPricingWorkflow().getCustName());
assertEquals("1101********1234", result.getLoanPricingWorkflow().getIdNum());
}
@Test
void shouldMaskCustNameAndIdNumInRetailModelOutputBasicInfo()
{
LoanPricingWorkflow workflow = new LoanPricingWorkflow();
workflow.setSerialNum("P20260328001");
workflow.setCustType("个人");
workflow.setCustName("cipher-name");
workflow.setIdNum("cipher-id");
workflow.setModelOutputId(11L);
ModelRetailOutputFields retailOutputFields = new ModelRetailOutputFields();
retailOutputFields.setCustName("张三");
retailOutputFields.setIdNum("110101199001011234");
retailOutputFields.setCalculateRate("6.15");
when(loanPricingWorkflowMapper.selectOne(any())).thenReturn(workflow);
when(modelRetailOutputFieldsMapper.selectById(11L)).thenReturn(retailOutputFields);
when(sensitiveFieldCryptoService.decrypt("cipher-name")).thenReturn("张三");
when(sensitiveFieldCryptoService.decrypt("cipher-id")).thenReturn("110101199001011234");
when(loanPricingSensitiveDisplayService.maskCustName("张三")).thenReturn("张*");
when(loanPricingSensitiveDisplayService.maskIdNum("110101199001011234")).thenReturn("1101********1234");
LoanPricingWorkflowVO result = loanPricingWorkflowService.selectLoanPricingBySerialNum("P20260328001");
assertEquals("张*", result.getModelRetailOutputFields().getCustName());
assertEquals("1101********1234", result.getModelRetailOutputFields().getIdNum());
}
@Test
void shouldUseCorporateModelOutputCalculateRateForWorkflowDetail()
{
@@ -96,4 +223,32 @@ class LoanPricingWorkflowServiceImplTest
assertEquals("3.932", result.getLoanPricingWorkflow().getLoanRate());
assertEquals("3.932", result.getModelCorpOutputFields().getCalculateRate());
}
@Test
void shouldMaskCustNameAndIdNumInCorporateModelOutputBasicInfo()
{
LoanPricingWorkflow workflow = new LoanPricingWorkflow();
workflow.setSerialNum("C20260328001");
workflow.setCustType("企业");
workflow.setCustName("cipher-name");
workflow.setIdNum("cipher-id");
workflow.setModelOutputId(22L);
ModelCorpOutputFields corpOutputFields = new ModelCorpOutputFields();
corpOutputFields.setCustName("测试科技有限公司");
corpOutputFields.setIdNum("91110000100000000X");
corpOutputFields.setCalculateRate("3.932");
when(loanPricingWorkflowMapper.selectOne(any())).thenReturn(workflow);
when(modelCorpOutputFieldsMapper.selectById(22L)).thenReturn(corpOutputFields);
when(sensitiveFieldCryptoService.decrypt("cipher-name")).thenReturn("测试科技有限公司");
when(sensitiveFieldCryptoService.decrypt("cipher-id")).thenReturn("91110000100000000X");
when(loanPricingSensitiveDisplayService.maskCustName("测试科技有限公司")).thenReturn("测试****公司");
when(loanPricingSensitiveDisplayService.maskIdNum("91110000100000000X")).thenReturn("91*************00X");
LoanPricingWorkflowVO result = loanPricingWorkflowService.selectLoanPricingBySerialNum("C20260328001");
assertEquals("测试****公司", result.getModelCorpOutputFields().getCustName());
assertEquals("91*************00X", result.getModelCorpOutputFields().getIdNum());
}
}

View File

@@ -6,6 +6,7 @@ ENV = 'development'
# 若依管理系统/开发环境
VUE_APP_BASE_API = '/dev-api'
VUE_APP_PASSWORD_TRANSFER_KEY = '1234567890abcdef'
# 路由懒加载
VUE_CLI_BABEL_TRANSPILE_MODULES = true

View File

@@ -6,3 +6,4 @@ ENV = 'production'
# 若依管理系统/生产环境
VUE_APP_BASE_API = '/prod-api'
VUE_APP_PASSWORD_TRANSFER_KEY = '1234567890abcdef'

View File

@@ -10,3 +10,4 @@ ENV = 'staging'
# 若依管理系统/测试环境
VUE_APP_BASE_API = '/stage-api'
VUE_APP_PASSWORD_TRANSFER_KEY = '1234567890abcdef'

18756
ruoyi-ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,8 @@
"dev": "vue-cli-service serve",
"build:prod": "vue-cli-service build",
"build:stage": "vue-cli-service build --mode staging",
"preview": "node build/index.js --preview"
"preview": "node build/index.js --preview",
"test:password-transfer": "node tests/password-transfer-api.test.js"
},
"keywords": [
"vue",
@@ -33,6 +34,7 @@
"file-saver": "2.0.5",
"fuse.js": "6.4.3",
"highlight.js": "9.18.5",
"crypto-js": "4.2.0",
"js-beautify": "1.13.0",
"js-cookie": "3.0.1",
"jsencrypt": "3.0.0-rc.1",

View File

@@ -1,13 +1,14 @@
import request from '@/utils/request'
import { encryptPasswordFields } from '@/utils/passwordTransfer'
// 登录方法
export function login(username, password, code, uuid) {
const data = {
const data = encryptPasswordFields({
username,
password,
code,
uuid
}
}, ['password'], process.env.VUE_APP_PASSWORD_TRANSFER_KEY)
return request({
url: '/login',
headers: {
@@ -21,13 +22,14 @@ export function login(username, password, code, uuid) {
// 注册方法
export function register(data) {
const payload = encryptPasswordFields(data, ['password'], process.env.VUE_APP_PASSWORD_TRANSFER_KEY)
return request({
url: '/register',
headers: {
isToken: false
},
method: 'post',
data: data
data: payload
})
}
@@ -57,4 +59,4 @@ export function getCodeImg() {
method: 'get',
timeout: 20000
})
}
}

View File

@@ -1,5 +1,6 @@
import request from '@/utils/request'
import { parseStrEmpty } from "@/utils/ruoyi";
import { encryptPasswordFields } from '@/utils/passwordTransfer'
// 查询用户列表
export function listUser(query) {
@@ -20,10 +21,11 @@ export function getUser(userId) {
// 新增用户
export function addUser(data) {
const payload = encryptPasswordFields(data, ['password'], process.env.VUE_APP_PASSWORD_TRANSFER_KEY)
return request({
url: '/system/user',
method: 'post',
data: data
data: payload
})
}
@@ -46,10 +48,10 @@ export function delUser(userId) {
// 用户密码重置
export function resetUserPwd(userId, password) {
const data = {
const data = encryptPasswordFields({
userId,
password
}
}, ['password'], process.env.VUE_APP_PASSWORD_TRANSFER_KEY)
return request({
url: '/system/user/resetPwd',
method: 'put',
@@ -89,10 +91,10 @@ export function updateUserProfile(data) {
// 用户密码重置
export function updateUserPwd(oldPassword, newPassword) {
const data = {
const data = encryptPasswordFields({
oldPassword,
newPassword
}
}, ['oldPassword', 'newPassword'], process.env.VUE_APP_PASSWORD_TRANSFER_KEY)
return request({
url: '/system/user/profile/updatePwd',
method: 'put',

View File

@@ -0,0 +1,14 @@
import CryptoJS from 'crypto-js'
export function encryptPasswordFields(payload, fields, key) {
const next = { ...payload }
fields.forEach((field) => {
if (next[field]) {
next[field] = CryptoJS.AES.encrypt(next[field], CryptoJS.enc.Utf8.parse(key), {
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7
}).toString()
}
})
return next
}

View File

@@ -1,10 +1,10 @@
<template>
<div class="app-container">
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="68px">
<el-form-item label="客户名称" prop="custName">
<el-form-item label="客户内码" prop="custIsn">
<el-input
v-model="queryParams.custName"
placeholder="请输入客户名称"
v-model="queryParams.custIsn"
placeholder="请输入客户内码"
clearable
@keyup.enter.native="handleQuery"
/>
@@ -122,7 +122,7 @@ export default {
queryParams: {
pageNum: 1,
pageSize: 10,
custName: undefined,
custIsn: undefined,
createBy: undefined,
orgCode: undefined
}

View File

@@ -0,0 +1,88 @@
const assert = require('assert')
const fs = require('fs')
const path = require('path')
const vm = require('vm')
function loadModule(filePath, stubs = {}) {
const source = fs.readFileSync(filePath, 'utf8')
const exportedNames = []
const transformed = source
.replace(/^import .*$/gm, '')
.replace(/export function\s+([A-Za-z0-9_]+)\s*\(/g, (_, name) => {
exportedNames.push(name)
return `function ${name}(`
})
.replace(/export default\s+/g, 'module.exports = ')
const sandbox = {
module: { exports: {} },
exports: {},
require,
console,
process: {
env: {
VUE_APP_PASSWORD_TRANSFER_KEY: '1234567890abcdef'
}
},
...stubs
}
vm.runInNewContext(
`${transformed}\nmodule.exports = { ${exportedNames.join(', ')} };`,
sandbox,
{ filename: filePath }
)
return sandbox.module.exports
}
const passwordTransferModule = loadModule(
path.resolve(__dirname, '../src/utils/passwordTransfer.js'),
{ CryptoJS: require('crypto-js') }
)
const { encryptPasswordFields } = passwordTransferModule
const encrypted = encryptPasswordFields(
{ password: 'admin123', code: '8888' },
['password'],
'1234567890abcdef'
)
assert.notStrictEqual(encrypted.password, 'admin123')
assert.strictEqual(encrypted.code, '8888')
const request = config => config
const loginModule = loadModule(
path.resolve(__dirname, '../src/api/login.js'),
{ request, encryptPasswordFields }
)
const loginConfig = loginModule.login('admin', 'admin123', '8888', 'uuid-1')
assert.notStrictEqual(loginConfig.data.password, 'admin123')
assert.strictEqual(loginConfig.data.username, 'admin')
const registerConfig = loginModule.register({ username: 'u1', password: 'p1', confirmPassword: 'p1', code: '8888' })
assert.notStrictEqual(registerConfig.data.password, 'p1')
assert.strictEqual(registerConfig.data.confirmPassword, 'p1')
const userModule = loadModule(
path.resolve(__dirname, '../src/api/system/user.js'),
{
request,
encryptPasswordFields,
parseStrEmpty: value => value
}
)
const updatePwdConfig = userModule.updateUserPwd('oldPwd', 'newPwd')
assert.notStrictEqual(updatePwdConfig.data.oldPassword, 'oldPwd')
assert.notStrictEqual(updatePwdConfig.data.newPassword, 'newPwd')
const addUserConfig = userModule.addUser({ userName: 'u1', password: 'initPwd', nickName: 'n1' })
assert.notStrictEqual(addUserConfig.data.password, 'initPwd')
const resetUserPwdConfig = userModule.resetUserPwd(2, 'resetPwd')
assert.notStrictEqual(resetUserPwdConfig.data.password, 'resetPwd')
console.log('password-transfer-api test passed')

View File

@@ -9,7 +9,7 @@ const CompressionPlugin = require('compression-webpack-plugin')
const name = process.env.VUE_APP_TITLE || '若依管理系统' // 网页标题
const baseUrl = 'http://localhost:8080' // 后端接口
const baseUrl = 'http://localhost:63310' // 后端接口
const port = process.env.port || process.env.npm_config_port || 80 // 端口

View File

@@ -0,0 +1,6 @@
-- 清理贷款定价流程历史数据
-- 执行日期: 2026-03-30
DELETE FROM model_retail_output_fields;
DELETE FROM model_corp_output_fields;
DELETE FROM loan_pricing_workflow;

View File

@@ -6,7 +6,7 @@
### ============================================================
### 1. 获取测试 Token如果未获取
### ============================================================
POST http://localhost:8080/login/test
POST http://localhost:63310/login/test
Content-Type: application/json
{
@@ -25,7 +25,7 @@ Content-Type: application/json
### ============================================================
### 2. 企业客户发起 - 成功场景(完整必填字段)
### ============================================================
POST http://localhost:8080/loanPricing/workflow/create/corporate
POST http://localhost:63310/loanPricing/workflow/create/corporate
Authorization: Bearer {{token}}
Content-Type: application/json
@@ -55,7 +55,7 @@ Content-Type: application/json
### ============================================================
### 3. 企业客户发起 - 缺少必填字段 custIsn
### ============================================================
POST http://localhost:8080/loanPricing/workflow/create/corporate
POST http://localhost:63310/loanPricing/workflow/create/corporate
Authorization: Bearer {{token}}
Content-Type: application/json
@@ -77,7 +77,7 @@ Content-Type: application/json
### ============================================================
### 4. 企业客户发起 - 缺少必填字段 guarType
### ============================================================
POST http://localhost:8080/loanPricing/workflow/create/corporate
POST http://localhost:63310/loanPricing/workflow/create/corporate
Authorization: Bearer {{token}}
Content-Type: application/json
@@ -99,7 +99,7 @@ Content-Type: application/json
### ============================================================
### 5. 企业客户发起 - 缺少必填字段 applyAmt
### ============================================================
POST http://localhost:8080/loanPricing/workflow/create/corporate
POST http://localhost:63310/loanPricing/workflow/create/corporate
Authorization: Bearer {{token}}
Content-Type: application/json
@@ -121,7 +121,7 @@ Content-Type: application/json
### ============================================================
### 6. 企业客户发起 - 担保方式枚举验证失败
### ============================================================
POST http://localhost:8080/loanPricing/workflow/create/corporate
POST http://localhost:63310/loanPricing/workflow/create/corporate
Authorization: Bearer {{token}}
Content-Type: application/json
@@ -144,7 +144,7 @@ Content-Type: application/json
### ============================================================
### 7. 企业客户发起 - 包含省农担担保贷款标识
### ============================================================
POST http://localhost:8080/loanPricing/workflow/create/corporate
POST http://localhost:63310/loanPricing/workflow/create/corporate
Authorization: Bearer {{token}}
Content-Type: application/json
@@ -170,7 +170,7 @@ Content-Type: application/json
### ============================================================
### 8. 企业客户发起 - 贸易和建筑业企业标识
### ============================================================
POST http://localhost:8080/loanPricing/workflow/create/corporate
POST http://localhost:63310/loanPricing/workflow/create/corporate
Authorization: Bearer {{token}}
Content-Type: application/json
@@ -196,7 +196,7 @@ Content-Type: application/json
### ============================================================
### 9. 企业客户发起 - 所有字段必填(信用贷款场景)
### ============================================================
POST http://localhost:8080/loanPricing/workflow/create/corporate
POST http://localhost:63310/loanPricing/workflow/create/corporate
Authorization: Bearer {{token}}
Content-Type: application/json

View File

@@ -6,7 +6,7 @@
# ============================================================
# 配置
BASE_URL="http://localhost:8080"
BASE_URL="http://localhost:63310"
LOGIN_URL="${BASE_URL}/login/test"
CORPORATE_CREATE_URL="${BASE_URL}/loanPricing/workflow/create/corporate"

View File

@@ -6,7 +6,7 @@
### ============================================================
### 1. 获取测试 Token
### ============================================================
POST http://localhost:8080/login/test
POST http://localhost:63310/login/test
Content-Type: application/json
{
@@ -25,7 +25,7 @@ Content-Type: application/json
### ============================================================
### 2. 个人客户发起 - 成功场景(完整必填字段)
### ============================================================
POST http://localhost:8080/loanPricing/workflow/create/personal
POST http://localhost:63310/loanPricing/workflow/create/personal
Authorization: Bearer {{token}}
Content-Type: application/json
@@ -52,7 +52,7 @@ Content-Type: application/json
### ============================================================
### 3. 个人客户发起 - 缺少必填字段 custIsn
### ============================================================
POST http://localhost:8080/loanPricing/workflow/create/personal
POST http://localhost:63310/loanPricing/workflow/create/personal
Authorization: Bearer {{token}}
Content-Type: application/json
@@ -74,7 +74,7 @@ Content-Type: application/json
### ============================================================
### 4. 个人客户发起 - 缺少必填字段 guarType
### ============================================================
POST http://localhost:8080/loanPricing/workflow/create/personal
POST http://localhost:63310/loanPricing/workflow/create/personal
Authorization: Bearer {{token}}
Content-Type: application/json
@@ -96,7 +96,7 @@ Content-Type: application/json
### ============================================================
### 5. 个人客户发起 - 缺少必填字段 applyAmt
### ============================================================
POST http://localhost:8080/loanPricing/workflow/create/personal
POST http://localhost:63310/loanPricing/workflow/create/personal
Authorization: Bearer {{token}}
Content-Type: application/json
@@ -118,7 +118,7 @@ Content-Type: application/json
### ============================================================
### 6. 个人客户发起 - 担保方式枚举验证失败
### ============================================================
POST http://localhost:8080/loanPricing/workflow/create/personal
POST http://localhost:63310/loanPricing/workflow/create/personal
Authorization: Bearer {{token}}
Content-Type: application/json
@@ -141,7 +141,7 @@ Content-Type: application/json
### ============================================================
### 7. 个人客户发起 - 包含抵质押信息
### ============================================================
POST http://localhost:8080/loanPricing/workflow/create/personal
POST http://localhost:63310/loanPricing/workflow/create/personal
Authorization: Bearer {{token}}
Content-Type: application/json
@@ -170,7 +170,7 @@ Content-Type: application/json
### ============================================================
### 8. 个人客户发起 - 所有字段必填(质押贷款场景)
### ============================================================
POST http://localhost:8080/loanPricing/workflow/create/personal
POST http://localhost:63310/loanPricing/workflow/create/personal
Authorization: Bearer {{token}}
Content-Type: application/json

View File

@@ -6,7 +6,7 @@
# ============================================================
# 配置
BASE_URL="http://localhost:8080"
BASE_URL="http://localhost:63310"
LOGIN_URL="${BASE_URL}/login/test"
PERSONAL_CREATE_URL="${BASE_URL}/loanPricing/workflow/create/personal"