diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiAccountInfoServiceImpl.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiAccountInfoServiceImpl.java index afb1e1c4..1fc79b29 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiAccountInfoServiceImpl.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiAccountInfoServiceImpl.java @@ -49,6 +49,10 @@ public class CcdiAccountInfoServiceImpl implements ICcdiAccountInfoService { private static final Set ACCOUNT_TYPES = Set.of("BANK", "SECURITIES", "PAYMENT", "OTHER"); private static final Set BANK_SCOPES = Set.of("INTERNAL", "EXTERNAL"); private static final Set LEVELS = Set.of("LOW", "MEDIUM", "HIGH"); + private static final String OWNER_TYPE_INTERMEDIARY = "INTERMEDIARY"; + private static final String BANK_SCOPE_EXTERNAL = "EXTERNAL"; + private static final String RISK_LEVEL_LOW = "LOW"; + private static final String RISK_LEVEL_HIGH = "HIGH"; private final List latestImportFailures = new CopyOnWriteArrayList<>(); @Resource @@ -195,12 +199,12 @@ public class CcdiAccountInfoServiceImpl implements ICcdiAccountInfoService { private void validateOwner(String ownerType, String ownerId) { if (StringUtils.isEmpty(ownerId)) { - if ("EXTERNAL".equals(ownerType) || "INTERMEDIARY".equals(ownerType)) { + if ("EXTERNAL".equals(ownerType) || OWNER_TYPE_INTERMEDIARY.equals(ownerType)) { throw new RuntimeException("证件号不能为空"); } throw new RuntimeException("所属人不能为空"); } - if ("EXTERNAL".equals(ownerType) || "INTERMEDIARY".equals(ownerType)) { + if ("EXTERNAL".equals(ownerType) || OWNER_TYPE_INTERMEDIARY.equals(ownerType)) { return; } if ("EMPLOYEE".equals(ownerType)) { @@ -232,8 +236,9 @@ public class CcdiAccountInfoServiceImpl implements ICcdiAccountInfoService { } private void prepareAnalysisFields(CcdiAccountInfo accountInfo) { - if (!"EXTERNAL".equals(accountInfo.getBankScope())) { + if (!BANK_SCOPE_EXTERNAL.equals(accountInfo.getBankScope())) { clearAnalysisFields(accountInfo); + applyIntermediaryRiskLevel(accountInfo); return; } if (accountInfo.getIsActualControl() == null) { @@ -249,8 +254,9 @@ public class CcdiAccountInfoServiceImpl implements ICcdiAccountInfoService { accountInfo.setTxnFrequencyLevel("MEDIUM"); } if (StringUtils.isEmpty(accountInfo.getTxnRiskLevel())) { - accountInfo.setTxnRiskLevel("LOW"); + accountInfo.setTxnRiskLevel(RISK_LEVEL_LOW); } + applyIntermediaryRiskLevel(accountInfo); } private void clearAnalysisFields(CcdiAccountInfo accountInfo) { @@ -265,6 +271,12 @@ public class CcdiAccountInfoServiceImpl implements ICcdiAccountInfoService { accountInfo.setTxnRiskLevel(null); } + private void applyIntermediaryRiskLevel(CcdiAccountInfo accountInfo) { + if (OWNER_TYPE_INTERMEDIARY.equals(accountInfo.getOwnerType())) { + accountInfo.setTxnRiskLevel(RISK_LEVEL_HIGH); + } + } + private void validateAmount(BigDecimal amount, String fieldLabel) { if (amount == null) { return; @@ -285,6 +297,7 @@ public class CcdiAccountInfoServiceImpl implements ICcdiAccountInfoService { addDTO.setTxnFrequencyLevel(toUpper(addDTO.getTxnFrequencyLevel())); addDTO.setTxnRiskLevel(toUpper(addDTO.getTxnRiskLevel())); addDTO.setOwnerId(normalizeOwnerId(addDTO.getOwnerId())); + applyIntermediaryRiskLevel(addDTO); } private void normalizeEditDto(CcdiAccountInfoEditDTO editDTO) { @@ -295,6 +308,19 @@ public class CcdiAccountInfoServiceImpl implements ICcdiAccountInfoService { editDTO.setTxnFrequencyLevel(toUpper(editDTO.getTxnFrequencyLevel())); editDTO.setTxnRiskLevel(toUpper(editDTO.getTxnRiskLevel())); editDTO.setOwnerId(normalizeOwnerId(editDTO.getOwnerId())); + applyIntermediaryRiskLevel(editDTO); + } + + private void applyIntermediaryRiskLevel(CcdiAccountInfoAddDTO addDTO) { + if (OWNER_TYPE_INTERMEDIARY.equals(addDTO.getOwnerType())) { + addDTO.setTxnRiskLevel(RISK_LEVEL_HIGH); + } + } + + private void applyIntermediaryRiskLevel(CcdiAccountInfoEditDTO editDTO) { + if (OWNER_TYPE_INTERMEDIARY.equals(editDTO.getOwnerType())) { + editDTO.setTxnRiskLevel(RISK_LEVEL_HIGH); + } } private String normalizeCurrency(String currency) { @@ -362,7 +388,8 @@ public class CcdiAccountInfoServiceImpl implements ICcdiAccountInfoService { private CcdiAccountInfoAddDTO toAddDto(CcdiAccountInfoExcel excel) { CcdiAccountInfoAddDTO dto = new CcdiAccountInfoAddDTO(); - dto.setOwnerType(parseOwnerType(excel.getOwnerType())); + String ownerType = parseOwnerType(excel.getOwnerType()); + dto.setOwnerType(ownerType); dto.setOwnerId(normalizeOwnerId(excel.getOwnerId())); dto.setAccountName(trimToNull(excel.getAccountName())); dto.setAccountNo(trimToNull(excel.getAccountNo())); @@ -382,7 +409,9 @@ public class CcdiAccountInfoServiceImpl implements ICcdiAccountInfoService { dto.setCreditSingleMaxAmount(parseDecimal(excel.getCreditSingleMaxAmount())); dto.setDebitDailyMaxAmount(parseDecimal(excel.getDebitDailyMaxAmount())); dto.setCreditDailyMaxAmount(parseDecimal(excel.getCreditDailyMaxAmount())); - dto.setTxnRiskLevel(parseLevel(excel.getTxnRiskLevel(), "风险等级")); + dto.setTxnRiskLevel(OWNER_TYPE_INTERMEDIARY.equals(ownerType) + ? RISK_LEVEL_HIGH + : parseLevel(excel.getTxnRiskLevel(), "风险等级")); return dto; } @@ -418,7 +447,7 @@ public class CcdiAccountInfoServiceImpl implements ICcdiAccountInfoService { return "RELATION"; } if ("中介".equals(value)) { - return "INTERMEDIARY"; + return OWNER_TYPE_INTERMEDIARY; } if ("外部人员".equals(value)) { return "EXTERNAL"; diff --git a/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiAccountInfoServiceImplTest.java b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiAccountInfoServiceImplTest.java index 965f40a0..4598855d 100644 --- a/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiAccountInfoServiceImplTest.java +++ b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiAccountInfoServiceImplTest.java @@ -3,6 +3,7 @@ package com.ruoyi.info.collection.service; import com.ruoyi.info.collection.domain.CcdiAccountInfo; import com.ruoyi.info.collection.domain.CcdiBaseStaff; import com.ruoyi.info.collection.domain.dto.CcdiAccountInfoAddDTO; +import com.ruoyi.info.collection.domain.excel.CcdiAccountInfoExcel; import com.ruoyi.info.collection.mapper.CcdiAccountInfoMapper; import com.ruoyi.info.collection.mapper.CcdiBaseStaffMapper; import com.ruoyi.info.collection.mapper.CcdiStaffFmyRelationMapper; @@ -16,6 +17,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.beans.BeanWrapperImpl; import java.math.BigDecimal; +import java.util.List; import java.util.Date; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -109,6 +111,64 @@ class CcdiAccountInfoServiceImplTest { assertNull(wrapper.getPropertyValue("txnRiskLevel")); } + @Test + void insertInternalIntermediaryAccount_shouldForceHighRiskLevelOnAccountInfo() { + CcdiAccountInfoAddDTO dto = buildBaseAddDto(); + dto.setOwnerType("INTERMEDIARY"); + dto.setOwnerId("91330100MA00000001"); + dto.setBankScope("INTERNAL"); + dto.setIsActualControl(1); + dto.setAvgMonthTxnCount(8); + dto.setAvgMonthTxnAmount(new BigDecimal("9988.66")); + dto.setTxnFrequencyLevel("HIGH"); + dto.setDebitSingleMaxAmount(new BigDecimal("111.11")); + dto.setCreditSingleMaxAmount(new BigDecimal("222.22")); + dto.setDebitDailyMaxAmount(new BigDecimal("333.33")); + dto.setCreditDailyMaxAmount(new BigDecimal("444.44")); + dto.setTxnRiskLevel("LOW"); + + when(accountInfoMapper.selectCount(any())).thenReturn(0L); + when(accountInfoMapper.insert(any(CcdiAccountInfo.class))).thenReturn(1); + + service.insertAccountInfo(dto); + + ArgumentCaptor captor = ArgumentCaptor.forClass(CcdiAccountInfo.class); + verify(accountInfoMapper).insert(captor.capture()); + BeanWrapperImpl wrapper = new BeanWrapperImpl(captor.getValue()); + assertNull(wrapper.getPropertyValue("isActualControl")); + assertNull(wrapper.getPropertyValue("avgMonthTxnCount")); + assertNull(wrapper.getPropertyValue("avgMonthTxnAmount")); + assertNull(wrapper.getPropertyValue("txnFrequencyLevel")); + assertEquals("HIGH", wrapper.getPropertyValue("txnRiskLevel")); + } + + @Test + void importExternalIntermediaryAccount_shouldForceHighRiskLevelOnAccountInfo() { + CcdiAccountInfoExcel excel = new CcdiAccountInfoExcel(); + excel.setOwnerType("中介"); + excel.setOwnerId("91330100MA00000002"); + excel.setAccountName("测试中介"); + excel.setAccountNo("6222024000000099"); + excel.setAccountType("银行账户"); + excel.setBankScope("行外"); + excel.setOpenBank("中国银行"); + excel.setBankCode("BOC"); + excel.setCurrency("CNY"); + excel.setStatus("正常"); + excel.setEffectiveDate("2026-07-10"); + excel.setTxnRiskLevel("LOW"); + + when(accountInfoMapper.selectOne(any())).thenReturn(null); + when(accountInfoMapper.selectCount(any())).thenReturn(0L); + when(accountInfoMapper.insert(any(CcdiAccountInfo.class))).thenReturn(1); + + service.importAccountInfo(List.of(excel), false); + + ArgumentCaptor captor = ArgumentCaptor.forClass(CcdiAccountInfo.class); + verify(accountInfoMapper).insert(captor.capture()); + assertEquals("HIGH", captor.getValue().getTxnRiskLevel()); + } + private CcdiAccountInfoAddDTO buildBaseAddDto() { CcdiAccountInfoAddDTO dto = new CcdiAccountInfoAddDTO(); dto.setAccountNo("6222024000000001"); diff --git a/docs/plans/backend/2026-07-10-intermediary-account-risk-level-backend-implementation.md b/docs/plans/backend/2026-07-10-intermediary-account-risk-level-backend-implementation.md new file mode 100644 index 00000000..c0fd932a --- /dev/null +++ b/docs/plans/backend/2026-07-10-intermediary-account-risk-level-backend-implementation.md @@ -0,0 +1,25 @@ +# 账户库中介风险等级后端实施计划 + +## 1. 目标 + +账户库中所属人类型为 `INTERMEDIARY` 的账户,风险等级统一为 `HIGH`。该规则覆盖页面新增、编辑保存、Excel 导入新增和 Excel 导入更新。 + +## 2. 实施范围 + +- `ccdi-info-collection` 账户库保存与导入服务 +- 账户库服务单元测试 + +## 3. 实施步骤 + +1. 在 `CcdiAccountInfoServiceImpl` 增加中介风险等级统一处理方法。 +2. 在新增、编辑 DTO 归一化后,将 `ownerType = INTERMEDIARY` 的 `txnRiskLevel` 强制置为 `HIGH`。 +3. 在分析字段准备阶段保留该规则,使行内中介账户即使清空其他分析字段,也保留风险等级 `HIGH`。 +4. 在 Excel 导入 DTO 转换阶段,对中介行跳过模板风险等级值,直接置为 `HIGH`。 +5. 补充单元测试覆盖行内中介保存和行外中介导入。 + +## 4. 验证要点 + +- 行内中介账户保存后 `trans_risk_level = HIGH`。 +- 行外中介账户导入时,即使模板填写 `LOW`,保存结果仍为 `HIGH`。 +- 普通行内员工账户仍清空分析字段,不写风险等级。 +- 已有历史数据不会自动更新,只有重新编辑保存或导入更新后才应用该规则。 diff --git a/docs/plans/frontend/2026-07-10-intermediary-account-risk-level-frontend-implementation.md b/docs/plans/frontend/2026-07-10-intermediary-account-risk-level-frontend-implementation.md new file mode 100644 index 00000000..8368609f --- /dev/null +++ b/docs/plans/frontend/2026-07-10-intermediary-account-risk-level-frontend-implementation.md @@ -0,0 +1,25 @@ +# 账户库中介风险等级前端实施计划 + +## 1. 目标 + +账户库新增或编辑中介账户时,页面表单与后端强制规则保持一致,风险等级显示为 `HIGH`。 + +## 2. 实施范围 + +- `ruoyi-ui/src/views/ccdiAccountInfo/index.vue` + +## 3. 实施步骤 + +1. 增加表单侧中介风险等级处理方法。 +2. 切换所属人类型为 `INTERMEDIARY` 时,将 `txnRiskLevel` 设置为 `HIGH`。 +3. 打开非详情模式的编辑弹窗时,若所属人类型为中介,同步显示 `HIGH`。 +4. 提交表单前再次应用该规则,避免页面状态被手动或旧数据改回其他等级。 +5. 所属人类型为中介时,风险等级下拉置为只读,避免用户选择其他等级后保存结果被后端强制改回 `HIGH`。 + +## 4. 验证要点 + +- 新增账户时选择“中介”,风险等级显示 `HIGH`。 +- 编辑中介账户时,风险等级显示 `HIGH`。 +- 中介账户表单中风险等级不可编辑。 +- 提交 payload 中中介账户的 `txnRiskLevel` 为 `HIGH`。 +- 非中介账户不受前端默认值变更影响。 diff --git a/docs/reports/implementation/2026-07-10-intermediary-account-risk-level.md b/docs/reports/implementation/2026-07-10-intermediary-account-risk-level.md new file mode 100644 index 00000000..eeb1cdf7 --- /dev/null +++ b/docs/reports/implementation/2026-07-10-intermediary-account-risk-level.md @@ -0,0 +1,27 @@ +# 账户库中介风险等级实施记录 + +## 修改内容 + +- 后端 `CcdiAccountInfoServiceImpl` 增加中介风险等级统一规则:`ownerType = INTERMEDIARY` 时强制 `txnRiskLevel = HIGH`。 +- 行内中介账户清空其他分析字段后,保留风险等级为 `HIGH`。 +- Excel 导入中介账户时忽略模板中的风险等级值,统一写入 `HIGH`。 +- 前端账户库表单在选择或编辑中介账户时同步显示风险等级 `HIGH`,并禁用风险等级下拉;提交前再次应用该规则。 +- 补充账户库服务单元测试,覆盖行内中介保存和行外中介导入。 + +## 影响范围 + +- 影响账户库页面新增、编辑保存、Excel 导入新增、Excel 导入更新。 +- 不自动修改已有历史数据;历史中介账户只有重新编辑保存或导入更新后才会统一为 `HIGH`。 +- 不改变数据库表结构。 +- 不改变非中介账户风险等级逻辑。 + +## 验证情况 + +- 已执行 `mvn -pl ccdi-info-collection -am test '-Dtest=CcdiAccountInfoServiceImplTest' '-Dsurefire.failIfNoSpecifiedTests=false'`。 +- 测试结果:4 个测试通过,0 失败,0 错误。 +- 已执行 `npm run build:prod`。 +- 构建结果:通过;存在既有资源体积警告,其中包含工作区已有的 `ruoyi-ui/public/0uS5znL.docx` 被打包提醒。 +- 已使用浏览器打开真实业务页面 `http://localhost/ccdiAccountInfo` 验证新增弹窗:初始员工风险等级为 `LOW`,切换所属人类型为“中介”后风险等级变为 `HIGH` 且下拉不可编辑;未提交保存,未写入测试数据。 +- 已复测中介风险等级交互:风险等级控件在中介状态下为禁用状态,避免用户选择其他等级后被后端覆盖。 +- 页面验证过程中观察到现有中介历史记录风险等级仍为空,符合本次不自动迁移历史数据的口径。 +- 前端验证使用 Node v22.22.3、npm 10.9.8。原因:`.nvmrc` 指向的 Node 14.21.3 在当前 PowerShell 环境切换后 `node`、`npm` 不可用,按仓库规则改用已验证可用版本。 diff --git a/ruoyi-ui/src/views/ccdiAccountInfo/index.vue b/ruoyi-ui/src/views/ccdiAccountInfo/index.vue index 6872cd24..42a4272d 100644 --- a/ruoyi-ui/src/views/ccdiAccountInfo/index.vue +++ b/ruoyi-ui/src/views/ccdiAccountInfo/index.vue @@ -370,7 +370,7 @@ - + @@ -503,6 +503,12 @@ function createEmptyForm() { } } +function applyIntermediaryRiskLevel(form) { + if (form.ownerType === 'INTERMEDIARY') { + form.txnRiskLevel = 'HIGH' + } +} + export default { name: 'CcdiAccountInfo', components: { ImportResultDialog }, @@ -587,6 +593,9 @@ export default { isRelationOwner() { return this.form.ownerType === 'RELATION' }, + isIntermediaryOwner() { + return this.form.ownerType === 'INTERMEDIARY' + }, isInternalOwner() { return this.isEmployeeOwner || this.isRelationOwner }, @@ -602,6 +611,9 @@ export default { analysisReadonly() { return this.dialogMode === 'detail' || this.form.bankScope === 'INTERNAL' }, + riskLevelReadonly() { + return this.analysisReadonly || this.isIntermediaryOwner + }, dialogTitle() { if (this.dialogMode === 'detail') return '账户详情' if (this.dialogMode === 'edit') return '编辑账户' @@ -750,6 +762,9 @@ export default { relationId: data.relationId || undefined, invalidDate: data.invalidDate || '' }) + if (mode !== 'detail') { + applyIntermediaryRiskLevel(this.form) + } this.dialogVisible = true this.$nextTick(() => this.clearDialogValidate()) }, @@ -776,6 +791,7 @@ export default { this.form.accountName = '' this.form.ownerId = '' } + applyIntermediaryRiskLevel(this.form) }, async handleStaffChange(staffId) { const staff = this.staffOptions.find(item => item.staffId === staffId) @@ -802,6 +818,7 @@ export default { this.form.accountName = relation.relationName }, buildPayload() { + applyIntermediaryRiskLevel(this.form) return { id: this.form.id, ownerType: this.form.ownerType,