账户库中介风险等级固定为高

This commit is contained in:
wjj
2026-07-10 17:46:51 +08:00
parent 486a499af7
commit 2c6c982ed3
6 changed files with 191 additions and 8 deletions

View File

@@ -49,6 +49,10 @@ public class CcdiAccountInfoServiceImpl implements ICcdiAccountInfoService {
private static final Set<String> ACCOUNT_TYPES = Set.of("BANK", "SECURITIES", "PAYMENT", "OTHER"); private static final Set<String> ACCOUNT_TYPES = Set.of("BANK", "SECURITIES", "PAYMENT", "OTHER");
private static final Set<String> BANK_SCOPES = Set.of("INTERNAL", "EXTERNAL"); private static final Set<String> BANK_SCOPES = Set.of("INTERNAL", "EXTERNAL");
private static final Set<String> LEVELS = Set.of("LOW", "MEDIUM", "HIGH"); private static final Set<String> 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<AccountInfoImportFailureVO> latestImportFailures = new CopyOnWriteArrayList<>(); private final List<AccountInfoImportFailureVO> latestImportFailures = new CopyOnWriteArrayList<>();
@Resource @Resource
@@ -195,12 +199,12 @@ public class CcdiAccountInfoServiceImpl implements ICcdiAccountInfoService {
private void validateOwner(String ownerType, String ownerId) { private void validateOwner(String ownerType, String ownerId) {
if (StringUtils.isEmpty(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("证件号不能为空");
} }
throw new RuntimeException("所属人不能为空"); throw new RuntimeException("所属人不能为空");
} }
if ("EXTERNAL".equals(ownerType) || "INTERMEDIARY".equals(ownerType)) { if ("EXTERNAL".equals(ownerType) || OWNER_TYPE_INTERMEDIARY.equals(ownerType)) {
return; return;
} }
if ("EMPLOYEE".equals(ownerType)) { if ("EMPLOYEE".equals(ownerType)) {
@@ -232,8 +236,9 @@ public class CcdiAccountInfoServiceImpl implements ICcdiAccountInfoService {
} }
private void prepareAnalysisFields(CcdiAccountInfo accountInfo) { private void prepareAnalysisFields(CcdiAccountInfo accountInfo) {
if (!"EXTERNAL".equals(accountInfo.getBankScope())) { if (!BANK_SCOPE_EXTERNAL.equals(accountInfo.getBankScope())) {
clearAnalysisFields(accountInfo); clearAnalysisFields(accountInfo);
applyIntermediaryRiskLevel(accountInfo);
return; return;
} }
if (accountInfo.getIsActualControl() == null) { if (accountInfo.getIsActualControl() == null) {
@@ -249,8 +254,9 @@ public class CcdiAccountInfoServiceImpl implements ICcdiAccountInfoService {
accountInfo.setTxnFrequencyLevel("MEDIUM"); accountInfo.setTxnFrequencyLevel("MEDIUM");
} }
if (StringUtils.isEmpty(accountInfo.getTxnRiskLevel())) { if (StringUtils.isEmpty(accountInfo.getTxnRiskLevel())) {
accountInfo.setTxnRiskLevel("LOW"); accountInfo.setTxnRiskLevel(RISK_LEVEL_LOW);
} }
applyIntermediaryRiskLevel(accountInfo);
} }
private void clearAnalysisFields(CcdiAccountInfo accountInfo) { private void clearAnalysisFields(CcdiAccountInfo accountInfo) {
@@ -265,6 +271,12 @@ public class CcdiAccountInfoServiceImpl implements ICcdiAccountInfoService {
accountInfo.setTxnRiskLevel(null); 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) { private void validateAmount(BigDecimal amount, String fieldLabel) {
if (amount == null) { if (amount == null) {
return; return;
@@ -285,6 +297,7 @@ public class CcdiAccountInfoServiceImpl implements ICcdiAccountInfoService {
addDTO.setTxnFrequencyLevel(toUpper(addDTO.getTxnFrequencyLevel())); addDTO.setTxnFrequencyLevel(toUpper(addDTO.getTxnFrequencyLevel()));
addDTO.setTxnRiskLevel(toUpper(addDTO.getTxnRiskLevel())); addDTO.setTxnRiskLevel(toUpper(addDTO.getTxnRiskLevel()));
addDTO.setOwnerId(normalizeOwnerId(addDTO.getOwnerId())); addDTO.setOwnerId(normalizeOwnerId(addDTO.getOwnerId()));
applyIntermediaryRiskLevel(addDTO);
} }
private void normalizeEditDto(CcdiAccountInfoEditDTO editDTO) { private void normalizeEditDto(CcdiAccountInfoEditDTO editDTO) {
@@ -295,6 +308,19 @@ public class CcdiAccountInfoServiceImpl implements ICcdiAccountInfoService {
editDTO.setTxnFrequencyLevel(toUpper(editDTO.getTxnFrequencyLevel())); editDTO.setTxnFrequencyLevel(toUpper(editDTO.getTxnFrequencyLevel()));
editDTO.setTxnRiskLevel(toUpper(editDTO.getTxnRiskLevel())); editDTO.setTxnRiskLevel(toUpper(editDTO.getTxnRiskLevel()));
editDTO.setOwnerId(normalizeOwnerId(editDTO.getOwnerId())); 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) { private String normalizeCurrency(String currency) {
@@ -362,7 +388,8 @@ public class CcdiAccountInfoServiceImpl implements ICcdiAccountInfoService {
private CcdiAccountInfoAddDTO toAddDto(CcdiAccountInfoExcel excel) { private CcdiAccountInfoAddDTO toAddDto(CcdiAccountInfoExcel excel) {
CcdiAccountInfoAddDTO dto = new CcdiAccountInfoAddDTO(); CcdiAccountInfoAddDTO dto = new CcdiAccountInfoAddDTO();
dto.setOwnerType(parseOwnerType(excel.getOwnerType())); String ownerType = parseOwnerType(excel.getOwnerType());
dto.setOwnerType(ownerType);
dto.setOwnerId(normalizeOwnerId(excel.getOwnerId())); dto.setOwnerId(normalizeOwnerId(excel.getOwnerId()));
dto.setAccountName(trimToNull(excel.getAccountName())); dto.setAccountName(trimToNull(excel.getAccountName()));
dto.setAccountNo(trimToNull(excel.getAccountNo())); dto.setAccountNo(trimToNull(excel.getAccountNo()));
@@ -382,7 +409,9 @@ public class CcdiAccountInfoServiceImpl implements ICcdiAccountInfoService {
dto.setCreditSingleMaxAmount(parseDecimal(excel.getCreditSingleMaxAmount())); dto.setCreditSingleMaxAmount(parseDecimal(excel.getCreditSingleMaxAmount()));
dto.setDebitDailyMaxAmount(parseDecimal(excel.getDebitDailyMaxAmount())); dto.setDebitDailyMaxAmount(parseDecimal(excel.getDebitDailyMaxAmount()));
dto.setCreditDailyMaxAmount(parseDecimal(excel.getCreditDailyMaxAmount())); 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; return dto;
} }
@@ -418,7 +447,7 @@ public class CcdiAccountInfoServiceImpl implements ICcdiAccountInfoService {
return "RELATION"; return "RELATION";
} }
if ("中介".equals(value)) { if ("中介".equals(value)) {
return "INTERMEDIARY"; return OWNER_TYPE_INTERMEDIARY;
} }
if ("外部人员".equals(value)) { if ("外部人员".equals(value)) {
return "EXTERNAL"; return "EXTERNAL";

View File

@@ -3,6 +3,7 @@ package com.ruoyi.info.collection.service;
import com.ruoyi.info.collection.domain.CcdiAccountInfo; import com.ruoyi.info.collection.domain.CcdiAccountInfo;
import com.ruoyi.info.collection.domain.CcdiBaseStaff; import com.ruoyi.info.collection.domain.CcdiBaseStaff;
import com.ruoyi.info.collection.domain.dto.CcdiAccountInfoAddDTO; 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.CcdiAccountInfoMapper;
import com.ruoyi.info.collection.mapper.CcdiBaseStaffMapper; import com.ruoyi.info.collection.mapper.CcdiBaseStaffMapper;
import com.ruoyi.info.collection.mapper.CcdiStaffFmyRelationMapper; import com.ruoyi.info.collection.mapper.CcdiStaffFmyRelationMapper;
@@ -16,6 +17,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.beans.BeanWrapperImpl; import org.springframework.beans.BeanWrapperImpl;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.List;
import java.util.Date; import java.util.Date;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -109,6 +111,64 @@ class CcdiAccountInfoServiceImplTest {
assertNull(wrapper.getPropertyValue("txnRiskLevel")); 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<CcdiAccountInfo> 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<CcdiAccountInfo> captor = ArgumentCaptor.forClass(CcdiAccountInfo.class);
verify(accountInfoMapper).insert(captor.capture());
assertEquals("HIGH", captor.getValue().getTxnRiskLevel());
}
private CcdiAccountInfoAddDTO buildBaseAddDto() { private CcdiAccountInfoAddDTO buildBaseAddDto() {
CcdiAccountInfoAddDTO dto = new CcdiAccountInfoAddDTO(); CcdiAccountInfoAddDTO dto = new CcdiAccountInfoAddDTO();
dto.setAccountNo("6222024000000001"); dto.setAccountNo("6222024000000001");

View File

@@ -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`
- 普通行内员工账户仍清空分析字段,不写风险等级。
- 已有历史数据不会自动更新,只有重新编辑保存或导入更新后才应用该规则。

View File

@@ -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`
- 非中介账户不受前端默认值变更影响。

View File

@@ -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` 不可用,按仓库规则改用已验证可用版本。

View File

@@ -370,7 +370,7 @@
</el-col> </el-col>
<el-col :span="6"> <el-col :span="6">
<el-form-item label="风险等级"> <el-form-item label="风险等级">
<el-select v-model="form.txnRiskLevel" style="width: 100%" :disabled="analysisReadonly"> <el-select v-model="form.txnRiskLevel" style="width: 100%" :disabled="riskLevelReadonly">
<el-option label="LOW" value="LOW" /> <el-option label="LOW" value="LOW" />
<el-option label="MEDIUM" value="MEDIUM" /> <el-option label="MEDIUM" value="MEDIUM" />
<el-option label="HIGH" value="HIGH" /> <el-option label="HIGH" value="HIGH" />
@@ -503,6 +503,12 @@ function createEmptyForm() {
} }
} }
function applyIntermediaryRiskLevel(form) {
if (form.ownerType === 'INTERMEDIARY') {
form.txnRiskLevel = 'HIGH'
}
}
export default { export default {
name: 'CcdiAccountInfo', name: 'CcdiAccountInfo',
components: { ImportResultDialog }, components: { ImportResultDialog },
@@ -587,6 +593,9 @@ export default {
isRelationOwner() { isRelationOwner() {
return this.form.ownerType === 'RELATION' return this.form.ownerType === 'RELATION'
}, },
isIntermediaryOwner() {
return this.form.ownerType === 'INTERMEDIARY'
},
isInternalOwner() { isInternalOwner() {
return this.isEmployeeOwner || this.isRelationOwner return this.isEmployeeOwner || this.isRelationOwner
}, },
@@ -602,6 +611,9 @@ export default {
analysisReadonly() { analysisReadonly() {
return this.dialogMode === 'detail' || this.form.bankScope === 'INTERNAL' return this.dialogMode === 'detail' || this.form.bankScope === 'INTERNAL'
}, },
riskLevelReadonly() {
return this.analysisReadonly || this.isIntermediaryOwner
},
dialogTitle() { dialogTitle() {
if (this.dialogMode === 'detail') return '账户详情' if (this.dialogMode === 'detail') return '账户详情'
if (this.dialogMode === 'edit') return '编辑账户' if (this.dialogMode === 'edit') return '编辑账户'
@@ -750,6 +762,9 @@ export default {
relationId: data.relationId || undefined, relationId: data.relationId || undefined,
invalidDate: data.invalidDate || '' invalidDate: data.invalidDate || ''
}) })
if (mode !== 'detail') {
applyIntermediaryRiskLevel(this.form)
}
this.dialogVisible = true this.dialogVisible = true
this.$nextTick(() => this.clearDialogValidate()) this.$nextTick(() => this.clearDialogValidate())
}, },
@@ -776,6 +791,7 @@ export default {
this.form.accountName = '' this.form.accountName = ''
this.form.ownerId = '' this.form.ownerId = ''
} }
applyIntermediaryRiskLevel(this.form)
}, },
async handleStaffChange(staffId) { async handleStaffChange(staffId) {
const staff = this.staffOptions.find(item => item.staffId === staffId) const staff = this.staffOptions.find(item => item.staffId === staffId)
@@ -802,6 +818,7 @@ export default {
this.form.accountName = relation.relationName this.form.accountName = relation.relationName
}, },
buildPayload() { buildPayload() {
applyIntermediaryRiskLevel(this.form)
return { return {
id: this.form.id, id: this.form.id,
ownerType: this.form.ownerType, ownerType: this.form.ownerType,