Compare commits

3 Commits

Author SHA1 Message Date
wjj
7a317204f7 调整流水打标规则口径 2026-07-13 13:05:35 +08:00
wjj
2c6c982ed3 账户库中介风险等级固定为高 2026-07-10 17:46:51 +08:00
wjj
486a499af7 优化流水账号入库和明细筛选 2026-07-10 17:33:16 +08:00
19 changed files with 482 additions and 40 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> BANK_SCOPES = Set.of("INTERNAL", "EXTERNAL");
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<>();
@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";

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.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<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() {
CcdiAccountInfoAddDTO dto = new CcdiAccountInfoAddDTO();
dto.setAccountNo("6222024000000001");

View File

@@ -51,7 +51,7 @@ public class GetBankStatementResponse {
private String leName;
/** 企业银行账号 */
private String accountMaskNo;
private String accountNo;
/** 账号日期ID */
private Integer accountingDateId;
@@ -102,7 +102,7 @@ public class GetBankStatementResponse {
private String customerName;
/** 对手方账号 */
private String customerAccountMaskNo;
private String customerAccountNo;
/** 对手方银行 */
private String customerBank;

View File

@@ -194,8 +194,8 @@ public class CcdiBankStatement implements Serializable {
BeanUtils.copyProperties(item, entity);
// 4. 手动映射字段名不一致的情况
entity.setLeAccountNo(item.getAccountMaskNo());
entity.setCustomerAccountNo(item.getCustomerAccountMaskNo());
entity.setLeAccountNo(item.getAccountNo());
entity.setCustomerAccountNo(item.getCustomerAccountNo());
entity.setLeAccountName(item.getLeName());
entity.setAmountDr(item.getDrAmount());
entity.setAmountCr(item.getCrAmount());

View File

@@ -388,11 +388,23 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
staff.id_card AS idCard,
SUM(IFNULL(bs.AMOUNT_DR, 0) + IFNULL(bs.AMOUNT_CR, 0)) AS annualAmount
from ccdi_bank_statement bs
inner join (
select
max(COALESCE(
STR_TO_DATE(LEFT(TRIM(anchor_bs.TRX_DATE), 19), '%Y-%m-%d %H:%i:%s'),
STR_TO_DATE(LEFT(TRIM(anchor_bs.TRX_DATE), 10), '%Y-%m-%d')
)) AS anchorDate
from ccdi_bank_statement anchor_bs
where anchor_bs.project_id = #{projectId}
) project_anchor on project_anchor.anchorDate is not null
inner join ccdi_base_staff staff on staff.id_card = bs.cret_no
where bs.project_id = #{projectId}
and IFNULL(bs.LE_ACCOUNT_NAME, '') &lt;&gt; IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '')
and <include refid="financialProductExclusionPredicate"/>
and STR_TO_DATE(LEFT(TRIM(bs.TRX_DATE), 10), '%Y-%m-%d') >= DATE_SUB(CURDATE(), INTERVAL 12 MONTH)
and COALESCE(
STR_TO_DATE(LEFT(TRIM(bs.TRX_DATE), 19), '%Y-%m-%d %H:%i:%s'),
STR_TO_DATE(LEFT(TRIM(bs.TRX_DATE), 10), '%Y-%m-%d')
) >= DATE_SUB(project_anchor.anchorDate, INTERVAL 12 MONTH)
group by staff.id_card
having SUM(IFNULL(bs.AMOUNT_DR, 0) + IFNULL(bs.AMOUNT_CR, 0)) > #{threshold}
) t
@@ -541,11 +553,23 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
max(IFNULL(bs.LE_ACCOUNT_NAME, '')) AS personName,
ROUND(SUM(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0))), 2) AS annualAmount
from ccdi_bank_statement bs
inner join (
select
max(COALESCE(
STR_TO_DATE(LEFT(TRIM(anchor_bs.TRX_DATE), 19), '%Y-%m-%d %H:%i:%s'),
STR_TO_DATE(LEFT(TRIM(anchor_bs.TRX_DATE), 10), '%Y-%m-%d')
)) AS anchorDate
from ccdi_bank_statement anchor_bs
where anchor_bs.project_id = #{projectId}
) project_anchor on project_anchor.anchorDate is not null
where bs.project_id = #{projectId}
and <include refid="externalPersonPredicateSql"/>
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 0
and <include refid="financialProductExclusionPredicate"/>
and STR_TO_DATE(LEFT(TRIM(bs.TRX_DATE), 10), '%Y-%m-%d') >= DATE_SUB(CURDATE(), INTERVAL 12 MONTH)
and COALESCE(
STR_TO_DATE(LEFT(TRIM(bs.TRX_DATE), 19), '%Y-%m-%d %H:%i:%s'),
STR_TO_DATE(LEFT(TRIM(bs.TRX_DATE), 10), '%Y-%m-%d')
) >= DATE_SUB(project_anchor.anchorDate, INTERVAL 12 MONTH)
group by bs.cret_no
having ROUND(SUM(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0))), 2) > #{threshold}
) t
@@ -775,12 +799,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
CONCAT(
'摘要/对手命中赌博敏感词,摘要“', IFNULL(bs.USER_MEMO, ''),
'”,对手方“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''),
'”,支出金额 ', CAST(IFNULL(bs.AMOUNT_DR, 0) AS CHAR), ' 元'
'”,支出金额 ', CAST(IFNULL(bs.AMOUNT_DR, 0) AS CHAR),
' 元,达到敏感交易最低金额 200 元'
) AS reasonDetail
from ccdi_bank_statement bs
inner join ccdi_base_staff staff on staff.id_card = bs.cret_no
where bs.project_id = #{projectId}
and IFNULL(bs.AMOUNT_DR, 0) > 0
and IFNULL(bs.AMOUNT_DR, 0) >= 200
and (
IFNULL(bs.USER_MEMO, '') REGEXP '游戏|抖币|体彩|福彩|彩票|赌博|赌球|下注|投注|球赛投注|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|捕鱼|电子游艺|VIP666|USDT下注'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '游戏|抖币|体彩|福彩|彩票|赌博|赌球|下注|投注|球赛投注|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|捕鱼|电子游艺|VIP666|USDT下注'
@@ -796,12 +821,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
'外部人员“', IFNULL(bs.LE_ACCOUNT_NAME, ''),
'”摘要/对手方命中疑似赌博关键词,摘要“', IFNULL(bs.USER_MEMO, ''),
'”,对手方“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''),
'”,交易金额 ', CAST(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) AS CHAR), ' 元'
'”,交易金额 ', CAST(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) AS CHAR),
' 元,达到敏感交易最低金额 200 元'
) AS reasonDetail
from ccdi_bank_statement bs
where bs.project_id = #{projectId}
and <include refid="externalPersonPredicateSql"/>
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 0
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) >= 200
and (
IFNULL(bs.USER_MEMO, '') REGEXP '游戏|抖币|体彩|福彩|彩票|赌博|赌球|下注|投注|球赛投注|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|牌局|捕鱼|电子游艺|VIP666|USDT下注'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '游戏|抖币|体彩|福彩|彩票|赌博|赌球|下注|投注|球赛投注|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|牌局|捕鱼|电子游艺|VIP666|USDT下注'
@@ -1152,7 +1178,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
CONCAT(
'物业缴费金额 ', CAST(trade.amountDr AS CHAR),
' 元,对手方“', IFNULL(trade.customerAccountName, ''),
'”,证件号 ', trade.personId, ' 名下无房产登记'
'”,证件号 ', trade.personId, ' 名下无匹配房产登记'
) AS reasonDetail
from (
select
@@ -1161,14 +1187,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
bs.group_id AS groupId,
bs.batch_id AS logId,
IFNULL(bs.AMOUNT_DR, 0) AS amountDr,
bs.CUSTOMER_ACCOUNT_NAME AS customerAccountName
bs.CUSTOMER_ACCOUNT_NAME AS customerAccountName,
bs.USER_MEMO AS userMemo
from ccdi_bank_statement bs
inner join ccdi_base_staff staff on staff.id_card = bs.cret_no
where bs.project_id = #{projectId}
and IFNULL(bs.AMOUNT_DR, 0) > 0
and (
IFNULL(bs.USER_MEMO, '') REGEXP '物业|物业费|管理费|物业服务|综合服务'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '物业|小区|花园|苑|中心|大厦|业委会|业主委员会|置业|房地产|服务中心|管理处|社区'
IFNULL(bs.USER_MEMO, '') REGEXP '物业|物管|业委会|业主委员会|维修基金|住宅专项维修资金|房屋维修资金'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '物业|物管|业委会|业主委员会|维修基金|住宅专项维修资金|房屋维修资金'
)
union all
@@ -1179,26 +1206,39 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
bs.group_id AS groupId,
bs.batch_id AS logId,
IFNULL(bs.AMOUNT_DR, 0) AS amountDr,
bs.CUSTOMER_ACCOUNT_NAME AS customerAccountName
bs.CUSTOMER_ACCOUNT_NAME AS customerAccountName,
bs.USER_MEMO AS userMemo
from ccdi_bank_statement bs
inner join ccdi_staff_fmy_relation relation on relation.relation_cert_no = bs.cret_no
where bs.project_id = #{projectId}
and relation.status = 1
and IFNULL(bs.AMOUNT_DR, 0) > 0
and (
IFNULL(bs.USER_MEMO, '') REGEXP '物业|物业费|管理费|物业服务|综合服务'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '物业|小区|花园|苑|中心|大厦|业委会|业主委员会|置业|房地产|服务中心|管理处|社区'
IFNULL(bs.USER_MEMO, '') REGEXP '物业|物管|业委会|业主委员会|维修基金|住宅专项维修资金|房屋维修资金'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '物业|物管|业委会|业主委员会|维修基金|住宅专项维修资金|房屋维修资金'
)
) trade
left join (
select distinct
asset.person_id AS personId
asset.person_id AS personId,
asset.asset_name AS assetName
from ccdi_asset_info asset
where asset.asset_main_type = '房产'
and asset.asset_sub_type = '住宅'
and asset.asset_status = '正常'
and trim(IFNULL(asset.asset_name, '')) != ''
) asset
on asset.personId = trade.personId
and (
(
trim(IFNULL(trade.customerAccountName, '')) != ''
and IFNULL(trade.customerAccountName, '') LIKE concat('%', asset.assetName, '%')
)
or (
trim(IFNULL(trade.userMemo, '')) != ''
and IFNULL(trade.userMemo, '') LIKE concat('%', asset.assetName, '%')
)
)
where asset.personId is null
</select>

View File

@@ -23,12 +23,12 @@ class CcdiBankStatementTest {
item.setLeId(100);
item.setAccountId(200L);
item.setLeName("测试企业");
item.setAccountMaskNo("6222****1234");
item.setAccountNo("6222023300001234");
item.setDrAmount(new BigDecimal("1000.00"));
item.setCrAmount(new BigDecimal("500.00"));
item.setBalanceAmount(new BigDecimal("5000.00"));
item.setTrxDate("2026-03-04");
item.setCustomerAccountMaskNo("6228****5678");
item.setCustomerAccountNo("6228483300005678");
// 执行转换
CcdiBankStatement entity = CcdiBankStatement.fromResponse(item);
@@ -41,8 +41,8 @@ class CcdiBankStatementTest {
assertEquals("测试企业", entity.getLeAccountName(), "企业名称应该匹配");
// 验证手动映射的字段
assertEquals("6222****1234", entity.getLeAccountNo(), "企业账号应该从 accountMaskNo 映射");
assertEquals("6228****5678", entity.getCustomerAccountNo(), "对手方账号应该从 customerAccountMaskNo 映射");
assertEquals("6222023300001234", entity.getLeAccountNo(), "企业账号应该从 accountNo 映射");
assertEquals("6228483300005678", entity.getCustomerAccountNo(), "对手方账号应该从 customerAccountNo 映射");
// 验证金额字段
assertEquals(new BigDecimal("1000.00"), entity.getAmountDr(), "付款金额应该匹配");

View File

@@ -152,6 +152,49 @@ class CcdiBankTagAnalysisMapperXmlTest {
assertTrue(!selectSql.contains("social_credit_code = bs"));
}
@Test
void gamblingSensitiveKeywordRules_shouldIgnoreSmallAmountTransactions() throws Exception {
String xml = readXml(RESOURCE);
String staffSelectSql = extractSelectSql(xml, "selectGamblingSensitiveKeywordStatements");
String externalSelectSql = extractSelectSql(xml, "selectExternalGamblingMemoStatements");
assertTrue(staffSelectSql.contains("IFNULL(bs.AMOUNT_DR, 0) >= 200"));
assertTrue(staffSelectSql.contains("达到敏感交易最低金额 200 元"));
assertTrue(externalSelectSql.contains(
"GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) >= 200"
));
assertTrue(externalSelectSql.contains("达到敏感交易最低金额 200 元"));
}
@Test
void annualTurnoverRules_shouldUseProjectStatementDateAsOneYearAnchor() throws Exception {
String xml = readXml(RESOURCE);
String staffSelectSql = extractSelectSql(xml, "selectAnnualTurnoverObjects");
String externalSelectSql = extractSelectSql(xml, "selectExternalAnnualTurnoverObjects");
assertTrue(staffSelectSql.contains("project_anchor.anchorDate"));
assertTrue(staffSelectSql.contains("DATE_SUB(project_anchor.anchorDate, INTERVAL 12 MONTH)"));
assertFalse(staffSelectSql.contains("CURDATE()"));
assertTrue(externalSelectSql.contains("project_anchor.anchorDate"));
assertTrue(externalSelectSql.contains("DATE_SUB(project_anchor.anchorDate, INTERVAL 12 MONTH)"));
assertFalse(externalSelectSql.contains("CURDATE()"));
}
@Test
void propertyFeeMismatchRule_shouldUseReasonablePropertyKeywordsAndAssetNameMatching() throws Exception {
String xml = readXml(RESOURCE);
String selectSql = extractSelectSql(xml, "selectPropertyFeeRegistrationMismatchStatements");
assertTrue(selectSql.contains("物业|物管|业委会|业主委员会|维修基金|住宅专项维修资金|房屋维修资金"));
assertFalse(selectSql.contains("中心"));
assertFalse(selectSql.contains("社区"));
assertFalse(selectSql.contains("大厦"));
assertTrue(selectSql.contains("asset.asset_name AS assetName"));
assertTrue(selectSql.contains("trade.customerAccountName"));
assertTrue(selectSql.contains("trade.userMemo"));
assertTrue(selectSql.contains("名下无匹配房产登记"));
}
@Test
void withdrawCntObjectRule_shouldUseRealSqlAndKeepObjectHitFields() throws Exception {
String xml = readXml(RESOURCE);

View File

@@ -957,9 +957,9 @@ class CcdiFileUploadServiceImplTest {
return response;
}
private GetBankStatementResponse.BankStatementItem buildBankStatementItem(String accountMaskNo) {
private GetBankStatementResponse.BankStatementItem buildBankStatementItem(String accountNo) {
GetBankStatementResponse.BankStatementItem item = new GetBankStatementResponse.BankStatementItem();
item.setAccountMaskNo(accountMaskNo);
item.setAccountNo(accountNo);
item.setAccountingDateId(20260310);
item.setDrAmount(new BigDecimal("100.00"));
item.setCrAmount(new BigDecimal("0.00"));

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,18 @@
# 流水账号字段映射实施记录
## 修改内容
- 在流水分析响应 DTO `GetBankStatementResponse.BankStatementItem` 中补充 `accountNo``customerAccountNo` 字段,并移除 `accountMaskNo``customerAccountMaskNo` 字段。
- 将银行流水实体转换逻辑从 `accountMaskNo``customerAccountMaskNo` 改为直接读取 `accountNo``customerAccountNo`
- 同步调整相关单元测试数据和断言,避免继续按掩码账号字段验证。
## 影响范围
- 影响流水分析接口返回流水明细后写入 `ccdi_bank_statement.LE_ACCOUNT_NO``CUSTOMER_ACCOUNT_NO` 的字段来源。
- 不改变数据库结构。
- 不改变前端页面。
## 验证情况
- 已执行 `mvn -pl ccdi-project -am test "-Dtest=CcdiBankStatementTest,CcdiFileUploadServiceImplTest" "-Dsurefire.failIfNoSpecifiedTests=false"`
- 测试结果39 个测试通过0 失败0 错误。

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

@@ -0,0 +1,30 @@
# 涉疑交易明细筛选联动实施记录
## 修改内容
- 将涉疑交易明细第二个筛选框文案由“可疑规则”统一为“预警类型”。
- 将默认选项文案由“全部可疑规则”调整为“全部预警类型”。
- 在“全部预警类型”下,“命中模型”下拉同时展示员工模型与外部人员模型,并按模型编码去重。
- 选择外部人员模型时不再强制切换“预警类型”,支持 `全部预警类型 + 外部人员可疑关系` 直接查询。
- 保留明确类型约束:当预警类型切到“外部人员预警”时,仅保留外部人员模型;当预警类型切到“员工规则命中”时,清空已选择的外部人员模型。
## 影响范围
- 前端组件:`ruoyi-ui/src/views/ccdiProject/components/detail/RiskDetailSection.vue`
- 前端静态单测:
- `ruoyi-ui/tests/unit/risk-detail-suspicious-transaction-layout.test.js`
- `ruoyi-ui/tests/unit/risk-detail-suspicious-filter-linkage.test.js`
本次未修改后端接口、SQL 或查询参数结构。查询仍沿用 `suspiciousType``modelCode` 两个参数组合。
## 验证情况
- Node 环境:`.nvmrc` 为 14.21.3;当前 PowerShell 下 `nvm use` 未自动读取版本,且本机已知 Node 14 安装缺少 npm因此按项目约定使用已验证可用的 Node 22.22.3 / npm 10.9.8。
- 已执行静态单测:
- `node tests/unit/risk-detail-suspicious-transaction-layout.test.js`
- `node tests/unit/risk-detail-suspicious-filter-linkage.test.js`
- `node tests/unit/project-overview-api.test.js`
- 已启动前端开发服务并编译成功:`npm run dev`,端口 `8088`
- 已使用系统 Chrome 通过 Playwright CLI 打开实际应用入口并截图:`output/playwright/risk-detail-filter-home-independent.png`
- 浏览器验证限制:当前后端 `62318` 未监听实际应用入口停留在登录页并提示“系统接口500异常”因此未进入涉疑交易明细业务页做端到端交互验证。
- 测试后已关闭本次启动的前端开发进程,`8088` 未继续监听。

View File

@@ -0,0 +1,39 @@
# 流水打标规则口径调整后端实施记录
## 修改背景
对现有流水打标规则进行审查后,确认需要优先处理两个后端口径问题:
1. 年流水交易额使用系统当前日期 `CURDATE()` 作为近一年锚点,历史流水项目重复跑模型时结果会随运行日期漂移。
2. 物业缴费与房产登记不匹配规则仅判断是否存在任意房产登记,且关键词口径要兼顾召回和误报控制。
外部人员夜间交易属于早期残留规则,不属于当前最终外部人员四类口径,本次不调整。
## 修改内容
1. 年流水交易额口径
- `ANNUAL_TURNOVER` 改为以当前项目内最大交易日期作为近一年锚点。
- `EXTERNAL_ANNUAL_TURNOVER` 同步改为以当前项目内最大交易日期作为近一年锚点。
- 不再依赖系统当前日期 `CURDATE()`
2. 物业缴费与房产登记不匹配口径
- 物业候选关键词收敛为 `物业、物管、业委会、业主委员会、维修基金、住宅专项维修资金、房屋维修资金`
- 去掉 `中心、社区、大厦` 等泛词,降低普通转账或机构名称误命中的概率。
- 使用 `ccdi_asset_info.asset_name` 与流水对手方名称、摘要做名称匹配。
- 若已登记正常住宅资产名称能匹配流水对手方或摘要,则不再判定为“物业缴费与房产登记不匹配”。
- 命中原因由“名下无房产登记”调整为“名下无匹配房产登记”。
## 影响范围
- 影响员工年流水交易额超限规则。
- 影响外部人员年流水交易额超限规则。
- 影响物业缴费与房产登记不匹配规则。
- 不新增模型参数,不改数据库结构,不改数据库脚本。
- 不调整外部人员夜间交易残留规则。
## 验证情况
- 已补充 Mapper XML 单测断言,覆盖近一年锚点和物业资产名称匹配。
- `mvn -pl ccdi-project -DskipTests compile` 执行通过。
- 已执行 Mapper 静态校验,确认年流水规则不再包含 `CURDATE()`,物业规则使用收敛关键词并保留资产名称匹配,外部夜间交易规则未被调整。
- 尝试执行 `mvn -pl ccdi-project "-Dtest=CcdiBankTagAnalysisMapperXmlTest" test`,但在 `testCompile` 阶段被既有无关测试编译错误阻断:`CcdiFileUploadServiceImplTest` 仍使用旧版 `LsfxAnalysisClient.uploadFile` 参数签名,`CcdiBankStatementTest` 仍调用当前 `BankStatementItem` 不存在的旧 setter。

View File

@@ -0,0 +1,35 @@
# 疑似敏感交易最低金额门槛后端实施记录
## 修改背景
疑似敏感交易原先只要支出流水摘要或对手方命中赌博、游戏、彩票等敏感关键词,且支出金额大于 0 即会命中,导致 20 元、50 元等小额支出也进入疑似敏感交易明细。
参考本地知识库中 LSFX 的个人敏感支出口径,敏感类规则通常会结合金额条件控制误报。本次按业务确认,不新增模型参数,不改数据库脚本,直接在后端规则查询中固定最低金额门槛。
## 修改内容
1. 员工疑似敏感交易 `GAMBLING_SENSITIVE_KEYWORD`
- 命中条件由 `AMOUNT_DR > 0` 调整为 `AMOUNT_DR >= 200`
- 命中原因追加“达到敏感交易最低金额 200 元”。
2. 外部人员疑似赌博摘要 `EXTERNAL_GAMBLING_MEMO`
- 命中条件由交易金额大于 0 调整为交易金额 `>= 200`
- 交易金额仍按收入、支出两侧最大值判断。
- 命中原因追加“达到敏感交易最低金额 200 元”。
3. 后端单测
- 增加 Mapper XML 断言,确保两类敏感关键词规则都保留 200 元最低金额门槛。
## 影响范围
- 影响员工疑似赌博模型下的“疑似敏感交易”流水命中。
- 影响外部人员可疑赌博模型下的“外部人员疑似赌博摘要”流水命中。
- 不新增模型参数,不改 `ccdi_model_param`,不需要执行数据库脚本。
- 低于 200 元的敏感词流水不再命中上述两类规则。
## 验证情况
- 已补充后端 Mapper XML 单测断言。
- `mvn -pl ccdi-project -DskipTests compile` 执行通过。
- 已执行 Mapper 静态校验,确认员工疑似敏感交易和外部人员疑似赌博摘要均包含 `>= 200` 最低金额条件。
- 尝试执行 `mvn -pl ccdi-project "-Dtest=CcdiBankTagAnalysisMapperXmlTest,BankTagRuleConfigResolverTest,CcdiBankTagServiceImplTest" test`,但在 `testCompile` 阶段被既有无关测试编译错误阻断:`CcdiFileUploadServiceImplTest` 仍使用旧版 `LsfxAnalysisClient.uploadFile` 参数签名,`CcdiBankStatementTest` 仍调用当前 `BankStatementItem` 不存在的旧 setter。

View File

@@ -370,7 +370,7 @@
</el-col>
<el-col :span="6">
<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="MEDIUM" value="MEDIUM" />
<el-option label="HIGH" value="HIGH" />
@@ -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,

View File

@@ -35,11 +35,11 @@
/>
</el-select>
</el-form-item>
<el-form-item label="可疑规则">
<el-form-item label="预警类型">
<el-select
v-model="currentSuspiciousType"
class="suspicious-filter-select suspicious-type-select"
placeholder="全部可疑规则"
placeholder="全部预警类型"
@change="handleSuspiciousTypeSelectChange"
>
<el-option
@@ -369,7 +369,7 @@ import { getBankStatementDetail } from "@/api/ccdiProjectBankStatement";
import { buildFlowEvidenceFingerprint, buildFlowEvidenceSnapshot } from "@/utils/ccdiEvidence";
const SUSPICIOUS_TYPE_OPTIONS = [
{ value: "ALL", label: "全部可疑规则" },
{ value: "ALL", label: "全部预警类型" },
{ value: "MODEL_RULE", label: "员工规则命中" },
{ value: "EXTERNAL_PERSON", label: "外部人员预警" },
];
@@ -404,6 +404,23 @@ const normalizeRiskModelOptions = (rows) => {
});
};
const mergeRiskModelOptions = (...optionGroups) => {
const seen = new Set();
return optionGroups
.flat()
.filter((item) => {
if (!item || !item.value || seen.has(item.value)) {
return false;
}
seen.add(item.value);
return true;
});
};
const isExternalModelCode = (value) => (
typeof value === "string" && value.indexOf("EXTERNAL_") === 0
);
const ABNORMAL_CUSTOMER_TRANSACTION_RULE_CODE = "ABNORMAL_CUSTOMER_TRANSACTION";
const ABNORMAL_CUSTOMER_TRANSACTION_RULE_NAME = "与客户之间非正常资金往来";
@@ -542,7 +559,13 @@ export default {
return this.currentSuspiciousType === "EXTERNAL_PERSON";
},
displayRiskModelOptions() {
return this.isExternalSuspiciousType ? EXTERNAL_RISK_MODEL_OPTIONS : this.riskModelOptions;
if (this.isExternalSuspiciousType) {
return EXTERNAL_RISK_MODEL_OPTIONS;
}
if (this.currentSuspiciousType === "ALL") {
return mergeRiskModelOptions(this.riskModelOptions, EXTERNAL_RISK_MODEL_OPTIONS);
}
return this.riskModelOptions;
},
allModelOptionLabel() {
return this.isExternalSuspiciousType ? "全部外部模型" : "全部模型";
@@ -589,7 +612,7 @@ export default {
}
return;
}
if (this.selectedModelCode && this.selectedModelCode.indexOf("EXTERNAL_") === 0) {
if (value === "MODEL_RULE" && isExternalModelCode(this.selectedModelCode)) {
this.selectedModelCode = "";
}
},

View File

@@ -0,0 +1,29 @@
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const source = fs.readFileSync(
path.resolve(
__dirname,
"../../src/views/ccdiProject/components/detail/RiskDetailSection.vue"
),
"utf8"
);
[
'const mergeRiskModelOptions = (...optionGroups) => {',
'const isExternalModelCode = (value) => (',
'if (this.currentSuspiciousType === "ALL") {',
'return mergeRiskModelOptions(this.riskModelOptions, EXTERNAL_RISK_MODEL_OPTIONS);',
'if (value === "MODEL_RULE" && isExternalModelCode(this.selectedModelCode)) {',
'this.selectedModelCode = "";',
].forEach((token) => assert(source.includes(token), token));
[
'@change="handleModelSelectChange"',
'handleModelSelectChange(value) {',
'this.currentSuspiciousType = "EXTERNAL_PERSON";',
'label="可疑规则"',
'placeholder="全部可疑规则"',
'{ value: "ALL", label: "全部可疑规则" }',
].forEach((token) => assert(!source.includes(token), token));

View File

@@ -12,9 +12,10 @@ const source = fs.readFileSync(
[
"涉疑交易明细",
"全部可疑人员类型",
"名单库命中",
"模型规则命中",
"预警类型",
"全部预警类型",
"员工规则命中",
"外部人员预警",
"本方账户",
"对方账户",
"关联员工",
@@ -29,6 +30,7 @@ const source = fs.readFileSync(
].forEach((token) => assert(source.includes(token), token));
[
"label=\"可疑规则\"",
"label=\"可疑人员\"",
"prop=\"suspiciousPersonName\"",
"label=\"关联人\"",