合并第一期银行流水真实规则后端实现

This commit is contained in:
wkc
2026-03-20 14:03:33 +08:00
10 changed files with 471 additions and 59 deletions

View File

@@ -190,17 +190,21 @@ public interface CcdiBankTagAnalysisMapper {
* 单笔购汇金额超限
*
* @param projectId 项目ID
* @param threshold 单笔购汇阈值
* @return 流水命中结果
*/
List<BankTagStatementHitVO> selectForexBuyAmtStatements(@Param("projectId") Long projectId);
List<BankTagStatementHitVO> selectForexBuyAmtStatements(@Param("projectId") Long projectId,
@Param("threshold") BigDecimal threshold);
/**
* 单笔结汇金额超限
*
* @param projectId 项目ID
* @param threshold 单笔结汇阈值
* @return 流水命中结果
*/
List<BankTagStatementHitVO> selectForexSellAmtStatements(@Param("projectId") Long projectId);
List<BankTagStatementHitVO> selectForexSellAmtStatements(@Param("projectId") Long projectId,
@Param("threshold") BigDecimal threshold);
/**
* 单笔跨境汇款金额超限
@@ -238,17 +242,21 @@ public interface CcdiBankTagAnalysisMapper {
* 可疑银证大额转账
*
* @param projectId 项目ID
* @param threshold 银证转账阈值
* @return 流水命中结果
*/
List<BankTagStatementHitVO> selectStockTfrLargeStatements(@Param("projectId") Long projectId);
List<BankTagStatementHitVO> selectStockTfrLargeStatements(@Param("projectId") Long projectId,
@Param("threshold") BigDecimal threshold);
/**
* 微信支付宝频繁提现
*
* @param projectId 项目ID
* @param frequencyThreshold 提现频次阈值
* @return 对象命中结果
*/
List<BankTagObjectHitVO> selectWithdrawCntObjects(@Param("projectId") Long projectId);
List<BankTagObjectHitVO> selectWithdrawCntObjects(@Param("projectId") Long projectId,
@Param("frequencyThreshold") Integer frequencyThreshold);
/**
* 微信支付宝提现超额
@@ -278,9 +286,11 @@ public interface CcdiBankTagAnalysisMapper {
* 大额炒股
*
* @param projectId 项目ID
* @param threshold 三方资管交易阈值
* @return 流水命中结果
*/
List<BankTagStatementHitVO> selectLargeStockTradingStatements(@Param("projectId") Long projectId);
List<BankTagStatementHitVO> selectLargeStockTradingStatements(@Param("projectId") Long projectId,
@Param("threshold") BigDecimal threshold);
/**
* 疑似代理他人账户

View File

@@ -24,13 +24,18 @@ import lombok.extern.slf4j.Slf4j;
@Component
public class BankTagRuleConfigResolver {
private static final Map<String, Set<String>> RULE_PARAM_MAPPING = Map.of(
"SINGLE_LARGE_INCOME", Set.of("SINGLE_TRANSACTION_AMOUNT"),
"CUMULATIVE_INCOME", Set.of("CUMULATIVE_TRANSACTION_AMOUNT"),
"ANNUAL_TURNOVER", Set.of("ANNUAL_TURNOVER"),
"LARGE_CASH_DEPOSIT", Set.of("LARGE_CASH_DEPOSIT"),
"FREQUENT_CASH_DEPOSIT", Set.of("LARGE_CASH_DEPOSIT", "FREQUENT_CASH_DEPOSIT"),
"LARGE_TRANSFER", Set.of("FREQUENT_TRANSFER")
private static final Map<String, Set<String>> RULE_PARAM_MAPPING = Map.ofEntries(
Map.entry("SINGLE_LARGE_INCOME", Set.of("SINGLE_TRANSACTION_AMOUNT")),
Map.entry("CUMULATIVE_INCOME", Set.of("CUMULATIVE_TRANSACTION_AMOUNT")),
Map.entry("ANNUAL_TURNOVER", Set.of("ANNUAL_TURNOVER")),
Map.entry("LARGE_CASH_DEPOSIT", Set.of("LARGE_CASH_DEPOSIT")),
Map.entry("FREQUENT_CASH_DEPOSIT", Set.of("LARGE_CASH_DEPOSIT", "FREQUENT_CASH_DEPOSIT")),
Map.entry("LARGE_TRANSFER", Set.of("FREQUENT_TRANSFER")),
Map.entry("FOREX_BUY_AMT", Set.of("SINGLE_PURCHASE_AMOUNT")),
Map.entry("FOREX_SELL_AMT", Set.of("SINGLE_SETTLEMENT_AMOUNT")),
Map.entry("WITHDRAW_CNT", Set.of("WITHDRAW_CNT")),
Map.entry("STOCK_TFR_LARGE", Set.of("STOCK_TFR_LARGE")),
Map.entry("LARGE_STOCK_TRADING", Set.of("STOCK_TFR_LARGE"))
);
@Resource

View File

@@ -233,12 +233,20 @@ public class CcdiBankTagServiceImpl implements ICcdiBankTagService {
case "PROPERTY_FEE_REGISTRATION_MISMATCH" -> analysisMapper.selectPropertyFeeRegistrationMismatchStatements(projectId);
case "TAX_ASSET_REGISTRATION_MISMATCH" -> analysisMapper.selectTaxAssetRegistrationMismatchStatements(projectId);
case "INCOME_ASSET_MISMATCH" -> analysisMapper.selectIncomeAssetMismatchStatements(projectId);
case "FOREX_BUY_AMT" -> analysisMapper.selectForexBuyAmtStatements(projectId);
case "FOREX_SELL_AMT" -> analysisMapper.selectForexSellAmtStatements(projectId);
case "FOREX_BUY_AMT" -> analysisMapper.selectForexBuyAmtStatements(
projectId, toBigDecimal(config.getThresholdValue("SINGLE_PURCHASE_AMOUNT"))
);
case "FOREX_SELL_AMT" -> analysisMapper.selectForexSellAmtStatements(
projectId, toBigDecimal(config.getThresholdValue("SINGLE_SETTLEMENT_AMOUNT"))
);
case "CROSS_BORDER_AMT" -> analysisMapper.selectCrossBorderAmtStatements(projectId);
case "LARGE_PURCHASE_TRANSACTION" -> analysisMapper.selectLargePurchaseTransactionStatements(projectId);
case "STOCK_TFR_LARGE" -> analysisMapper.selectStockTfrLargeStatements(projectId);
case "LARGE_STOCK_TRADING" -> analysisMapper.selectLargeStockTradingStatements(projectId);
case "STOCK_TFR_LARGE" -> analysisMapper.selectStockTfrLargeStatements(
projectId, toBigDecimal(config.getThresholdValue("STOCK_TFR_LARGE"))
);
case "LARGE_STOCK_TRADING" -> analysisMapper.selectLargeStockTradingStatements(
projectId, toBigDecimal(config.getThresholdValue("STOCK_TFR_LARGE"))
);
default -> List.of();
};
}
@@ -264,7 +272,9 @@ public class CcdiBankTagServiceImpl implements ICcdiBankTagService {
case "FIXED_COUNTERPARTY_TRANSFER" -> analysisMapper.selectFixedCounterpartyTransferObjects(projectId);
case "INTEREST_PAYMENT_BY_OTHERS" -> analysisMapper.selectInterestPaymentByOthersObjects(projectId);
case "SUPPLIER_CONCENTRATION" -> analysisMapper.selectSupplierConcentrationObjects(projectId);
case "WITHDRAW_CNT" -> analysisMapper.selectWithdrawCntObjects(projectId);
case "WITHDRAW_CNT" -> analysisMapper.selectWithdrawCntObjects(
projectId, toInteger(config.getThresholdValue("WITHDRAW_CNT"))
);
case "WITHDRAW_AMT" -> analysisMapper.selectWithdrawAmtObjects(projectId);
case "SALARY_QUICK_TRANSFER" -> analysisMapper.selectSalaryQuickTransferObjects(projectId);
case "SALARY_UNUSED" -> analysisMapper.selectSalaryUnusedObjects(projectId);

View File

@@ -396,9 +396,19 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
'占位SQL待补充真实规则' AS reasonDetail
CONCAT(
'摘要/对手命中赌博敏感词,摘要“', IFNULL(bs.USER_MEMO, ''),
'”,对手方“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''),
'”,支出金额 ', CAST(IFNULL(bs.AMOUNT_DR, 0) AS CHAR), ' 元'
) AS reasonDetail
from ccdi_bank_statement bs
where 1 = 0
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 '游戏|抖币|体彩|福彩|彩票|赌|球|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|捕鱼|电子游艺|投注'
)
</select>
<select id="selectSpecialAmountTransactionStatements" resultMap="BankTagStatementHitResultMap">
@@ -406,9 +416,25 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
'占位SQL待补充真实规则' AS reasonDetail
CONCAT(
'与非配偶/子女交易出现特殊金额 ',
CAST(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) AS CHAR),
' 元,对手方“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''),
'”,关系类型“', IFNULL(relation.relation_type, '非亲属'), '”'
) AS reasonDetail
from ccdi_bank_statement bs
where 1 = 0
inner join ccdi_base_staff staff on staff.id_card = bs.cret_no
left join ccdi_staff_fmy_relation relation
on relation.person_id = staff.id_card
and relation.relation_name = bs.CUSTOMER_ACCOUNT_NAME
and relation.status = 1
where bs.project_id = #{projectId}
and IFNULL(bs.LE_ACCOUNT_NAME, '') &lt;&gt; IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '')
and (IFNULL(relation.relation_type, '') = '' or relation.relation_type not in ('配偶', '子女'))
and (
IFNULL(bs.AMOUNT_DR, 0) in (520, 1314)
or IFNULL(bs.AMOUNT_CR, 0) in (520, 1314)
)
</select>
<select id="selectMonthlyFixedIncomeObjects" resultMap="BankTagObjectHitResultMap">
@@ -434,9 +460,20 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
'占位SQL待补充真实规则' AS reasonDetail
CONCAT(
'摘要命中收入关键词,摘要“', IFNULL(bs.USER_MEMO, ''),
'”,对手方“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''),
'”,流入金额 ', CAST(IFNULL(bs.AMOUNT_CR, 0) AS CHAR), ' 元'
) AS reasonDetail
from ccdi_bank_statement bs
where 1 = 0
inner join ccdi_base_staff staff on staff.id_card = bs.cret_no
where bs.project_id = #{projectId}
and IFNULL(bs.AMOUNT_CR, 0) > 0
and IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') &lt;&gt; '浙江兰溪农村商业银行股份有限公司'
and (
IFNULL(bs.USER_MEMO, '') REGEXP '代发|工资|分红|红利|奖金|薪酬|薪金|补贴|薪|年终奖|年金|加班费|劳务费|劳务外包|提成|劳务派遣|绩效|酬劳|批量代付|PAYROLL|SALA|CPF|directors.*fees'
or IFNULL(bs.CASH_TYPE, '') REGEXP '代发|工资|劳务费'
)
</select>
<select id="selectHouseRegistrationMismatchStatements" resultMap="BankTagStatementHitResultMap">
@@ -484,9 +521,18 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
'占位SQL待补充真实规则' AS reasonDetail
CONCAT(
'购汇交易金额 ', CAST(IFNULL(bs.AMOUNT_DR, 0) AS CHAR),
' 元,超过阈值 ', CAST(#{threshold} AS CHAR),
' 元,对手方“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''),
'”,摘要“', IFNULL(bs.USER_MEMO, ''), '”'
) AS reasonDetail
from ccdi_bank_statement bs
where 1 = 0
inner join ccdi_base_staff staff on staff.id_card = bs.cret_no
where bs.project_id = #{projectId}
and IFNULL(bs.AMOUNT_DR, 0) > #{threshold}
and IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '银行|外汇|售汇|国家外汇管理局'
and IFNULL(bs.USER_MEMO, '') REGEXP '购汇|换汇|外汇|汇率|外币|现汇|人民币兑换外币|外汇买入|购外币|购买外汇'
</select>
<select id="selectForexSellAmtStatements" resultMap="BankTagStatementHitResultMap">
@@ -494,9 +540,18 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
'占位SQL待补充真实规则' AS reasonDetail
CONCAT(
'结汇交易金额 ', CAST(IFNULL(bs.AMOUNT_CR, 0) AS CHAR),
' 元,超过阈值 ', CAST(#{threshold} AS CHAR),
' 元,对手方“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''),
'”,摘要“', IFNULL(bs.USER_MEMO, ''), '”'
) AS reasonDetail
from ccdi_bank_statement bs
where 1 = 0
inner join ccdi_base_staff staff on staff.id_card = bs.cret_no
where bs.project_id = #{projectId}
and IFNULL(bs.AMOUNT_CR, 0) > #{threshold}
and IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '银行|外汇|结汇|国家外汇管理局'
and IFNULL(bs.USER_MEMO, '') REGEXP '购汇|结汇|换汇|外汇|汇率|外币|现汇|结汇水单|外币兑换人民币|结汇入账|外汇结汇'
</select>
<select id="selectCrossBorderAmtStatements" resultMap="BankTagStatementHitResultMap">
@@ -520,12 +575,36 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<select id="selectLargePurchaseTransactionStatements" resultMap="BankTagStatementHitResultMap">
select
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
'占位SQL待补充真实规则' AS reasonDetail
from ccdi_bank_statement bs
where 1 = 0
CAST(NULL AS SIGNED) AS bankStatementId,
CAST(NULL AS SIGNED) AS groupId,
CAST(NULL AS SIGNED) AS logId,
CONCAT(
'采购事项“', IFNULL(t.subjectName, ''),
'”实际采购金额 ', CAST(IFNULL(t.actualAmount, 0) AS CHAR),
' 元,供应商“', IFNULL(t.supplierName, ''), '”'
) AS reasonDetail
from (
select distinct
pt.purchase_id AS purchaseId,
pt.subject_name AS subjectName,
pt.supplier_name AS supplierName,
pt.actual_amount AS actualAmount
from ccdi_purchase_transaction pt
inner join ccdi_base_staff staff
on CAST(staff.staff_id AS CHAR) = pt.applicant_id
where IFNULL(pt.actual_amount, 0) > 100000
union
select distinct
pt.purchase_id AS purchaseId,
pt.subject_name AS subjectName,
pt.supplier_name AS supplierName,
pt.actual_amount AS actualAmount
from ccdi_purchase_transaction pt
inner join ccdi_base_staff staff
on CAST(staff.staff_id AS CHAR) = pt.purchase_leader_id
where pt.purchase_leader_id is not null
and IFNULL(pt.actual_amount, 0) > 100000
) t
</select>
<select id="selectSupplierConcentrationObjects" resultMap="BankTagObjectHitResultMap">
@@ -542,18 +621,51 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
'占位SQL待补充真实规则' AS reasonDetail
CONCAT(
'银证转账金额 ', CAST(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) AS CHAR),
' 元,超过阈值 ', CAST(#{threshold} AS CHAR),
' 元,对手方“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''),
'”,摘要“', IFNULL(bs.USER_MEMO, ''), '”'
) AS reasonDetail
from ccdi_bank_statement bs
where 1 = 0
inner join ccdi_base_staff staff on staff.id_card = bs.cret_no
where bs.project_id = #{projectId}
and (
IFNULL(bs.AMOUNT_DR, 0) > #{threshold}
or IFNULL(bs.AMOUNT_CR, 0) > #{threshold}
)
and (
IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|资金存管|第三方存管'
or IFNULL(bs.USER_MEMO, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|资金存管|第三方存管|银证转账|银证|证转银|银转证'
or IFNULL(bs.CASH_TYPE, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|资金存管|第三方存管|银证转账|银证|证转银|银转证'
)
</select>
<select id="selectWithdrawCntObjects" resultMap="BankTagObjectHitResultMap">
select
'STAFF_ID_CARD' AS objectType,
'' AS objectKey,
'占位SQL待补充真实规则' AS reasonDetail
from ccdi_bank_statement bs
where 1 = 0
t.objectKey AS objectKey,
CONCAT(
'单日微信/支付宝提现 ', CAST(t.withdrawCount AS CHAR),
' 次,超过阈值 ', CAST(#{frequencyThreshold} AS CHAR),
' 次,交易日:', t.transDate
) AS reasonDetail
from (
select
staff.id_card AS objectKey,
LEFT(TRIM(bs.TRX_DATE), 10) AS transDate,
COUNT(1) AS withdrawCount
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_CR, 0) >= 0
and (
IFNULL(bs.USER_MEMO, '') REGEXP '财付通|微信零钱|微信|wechat|WeChat|Tenpay|支付宝|Alipay|提现'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '财付通|微信零钱|微信|wechat|WeChat|Tenpay|支付宝|Alipay|提现'
)
group by staff.id_card, LEFT(TRIM(bs.TRX_DATE), 10)
having COUNT(1) > #{frequencyThreshold}
) t
</select>
<select id="selectWithdrawAmtObjects" resultMap="BankTagObjectHitResultMap">
@@ -588,9 +700,21 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
'占位SQL待补充真实规则' AS reasonDetail
CONCAT(
'三方资管交易金额 ', CAST(IFNULL(bs.AMOUNT_DR, 0) AS CHAR),
' 元,超过阈值 ', CAST(#{threshold} AS CHAR),
' 元,对手方“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''),
'”,摘要“', IFNULL(bs.USER_MEMO, ''), '”'
) AS reasonDetail
from ccdi_bank_statement bs
where 1 = 0
inner join ccdi_base_staff staff on staff.id_card = bs.cret_no
where bs.project_id = #{projectId}
and IFNULL(bs.AMOUNT_DR, 0) > #{threshold}
and (
IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|理财|资金存管|第三方存管'
or IFNULL(bs.USER_MEMO, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|理财|资金存管|第三方存管'
or IFNULL(bs.CASH_TYPE, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|理财|资金存管|第三方存管'
)
</select>
<select id="selectProxyAccountOperationObjects" resultMap="BankTagObjectHitResultMap">

View File

@@ -8,6 +8,8 @@ import java.io.StringReader;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -15,31 +17,33 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
class CcdiBankTagAnalysisMapperXmlTest {
private static final String RESOURCE = "mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml";
private static final List<String> PHASE_ONE_STATEMENT_SELECT_IDS = List.of(
"selectGamblingSensitiveKeywordStatements",
"selectSpecialAmountTransactionStatements",
"selectSuspiciousIncomeKeywordStatements",
"selectForexBuyAmtStatements",
"selectForexSellAmtStatements",
"selectLargePurchaseTransactionStatements",
"selectStockTfrLargeStatements",
"selectLargeStockTradingStatements"
);
private static final List<String> PLACEHOLDER_SELECT_IDS = List.of(
"selectAbnormalCustomerTransactionStatements",
"selectLowIncomeRelativeLargeTransactionObjects",
"selectMultiPartyGamblingTransferObjects",
"selectGamblingSensitiveKeywordStatements",
"selectSpecialAmountTransactionStatements",
"selectMonthlyFixedIncomeObjects",
"selectFixedCounterpartyTransferObjects",
"selectSuspiciousIncomeKeywordStatements",
"selectHouseRegistrationMismatchStatements",
"selectPropertyFeeRegistrationMismatchStatements",
"selectTaxAssetRegistrationMismatchStatements",
"selectIncomeAssetMismatchStatements",
"selectForexBuyAmtStatements",
"selectForexSellAmtStatements",
"selectCrossBorderAmtStatements",
"selectInterestPaymentByOthersObjects",
"selectLargePurchaseTransactionStatements",
"selectSupplierConcentrationObjects",
"selectStockTfrLargeStatements",
"selectWithdrawCntObjects",
"selectWithdrawAmtObjects",
"selectSalaryQuickTransferObjects",
"selectSalaryUnusedObjects",
"selectLargeStockTradingStatements",
"selectProxyAccountOperationObjects"
);
@@ -74,6 +78,9 @@ class CcdiBankTagAnalysisMapperXmlTest {
@Test
void allPlaceholderRules_shouldExistInAnalysisMapperXml() throws Exception {
String xml = readXml(RESOURCE);
for (String selectId : PHASE_ONE_STATEMENT_SELECT_IDS) {
assertTrue(xml.contains(selectId), () -> "缺少第一期规则 SQL: " + selectId);
}
for (String selectId : PLACEHOLDER_SELECT_IDS) {
assertTrue(xml.contains(selectId), () -> "缺少占位规则 SQL: " + selectId);
}
@@ -83,7 +90,30 @@ class CcdiBankTagAnalysisMapperXmlTest {
void placeholderRules_shouldUseEmptyResultSqlTemplate() throws Exception {
String xml = readXml(RESOURCE);
assertTrue(xml.contains("占位SQL待补充真实规则"));
assertEquals(25, countMatches(xml, "where 1 = 0"));
assertEquals(16, countMatches(xml, "where 1 = 0"));
}
@Test
void phaseOneStatementRules_shouldUseRealSqlAndKeepHitFields() throws Exception {
String xml = readXml(RESOURCE);
for (String selectId : PHASE_ONE_STATEMENT_SELECT_IDS) {
String selectSql = extractSelectSql(xml, selectId);
assertTrue(selectSql.contains("AS bankStatementId"), () -> selectId + " 缺少 bankStatementId");
assertTrue(selectSql.contains("AS groupId"), () -> selectId + " 缺少 groupId");
assertTrue(selectSql.contains("AS logId"), () -> selectId + " 缺少 logId");
assertTrue(selectSql.contains("reasonDetail"), () -> selectId + " 缺少 reasonDetail");
assertTrue(!selectSql.contains("where 1 = 0"), () -> selectId + " 仍是占位 SQL");
}
}
@Test
void withdrawCntObjectRule_shouldUseRealSqlAndKeepObjectHitFields() throws Exception {
String xml = readXml(RESOURCE);
String selectSql = extractSelectSql(xml, "selectWithdrawCntObjects");
assertTrue(selectSql.contains("'STAFF_ID_CARD' AS objectType"));
assertTrue(selectSql.contains("AS objectKey"));
assertTrue(selectSql.contains("reasonDetail"));
assertTrue(!selectSql.contains("where 1 = 0"));
}
@Test
@@ -110,4 +140,13 @@ class CcdiBankTagAnalysisMapperXmlTest {
}
return count;
}
private String extractSelectSql(String xml, String selectId) {
Pattern pattern = Pattern.compile(
"<select\\s+id=\"" + selectId + "\"[\\s\\S]*?</select>"
);
Matcher matcher = pattern.matcher(xml);
assertTrue(matcher.find(), () -> "未找到 select: " + selectId);
return matcher.group();
}
}

View File

@@ -17,6 +17,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.slf4j.LoggerFactory;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -130,6 +131,38 @@ class BankTagRuleConfigResolverTest {
assertEquals("8888", config.getThresholdValue("ANNUAL_TURNOVER"));
}
@Test
void resolve_shouldMapPhaseOneThresholdRulesToUppercaseParamCodes() {
CcdiProject project = new CcdiProject();
project.setProjectId(40L);
project.setConfigType("default");
when(projectMapper.selectById(40L)).thenReturn(project);
assertSingleThresholdRule("SUSPICIOUS_FOREIGN_EXCHANGE", "FOREX_BUY_AMT",
"SINGLE_PURCHASE_AMOUNT", "50000");
assertSingleThresholdRule("SUSPICIOUS_FOREIGN_EXCHANGE", "FOREX_SELL_AMT",
"SINGLE_SETTLEMENT_AMOUNT", "60000");
assertSingleThresholdRule("ABNORMAL_BEHAVIOR", "WITHDRAW_CNT",
"WITHDRAW_CNT", "3");
assertSingleThresholdRule("ABNORMAL_BEHAVIOR", "STOCK_TFR_LARGE",
"STOCK_TFR_LARGE", "1000000");
assertSingleThresholdRule("ABNORMAL_BEHAVIOR", "LARGE_STOCK_TRADING",
"STOCK_TFR_LARGE", "1000000");
}
@Test
void resolve_shouldKeepEmptyThresholdsForPhaseOneRulesWithoutParams() {
CcdiProject project = new CcdiProject();
project.setProjectId(40L);
project.setConfigType("default");
when(projectMapper.selectById(40L)).thenReturn(project);
assertRuleHasNoThresholds("SUSPICIOUS_GAMBLING", "GAMBLING_SENSITIVE_KEYWORD");
assertRuleHasNoThresholds("SUSPICIOUS_RELATION", "SPECIAL_AMOUNT_TRANSACTION");
assertRuleHasNoThresholds("SUSPICIOUS_PART_TIME", "SUSPICIOUS_INCOME_KEYWORD");
assertRuleHasNoThresholds("SUSPICIOUS_PURCHASE", "LARGE_PURCHASE_TRANSACTION");
}
private CcdiModelParam buildParam(String paramCode, String paramValue) {
CcdiModelParam param = new CcdiModelParam();
param.setProjectId(0L);
@@ -138,4 +171,42 @@ class BankTagRuleConfigResolverTest {
param.setParamValue(paramValue);
return param;
}
private void assertSingleThresholdRule(String modelCode, String ruleCode, String paramCode, String paramValue) {
when(modelParamMapper.selectByProjectAndModel(0L, modelCode)).thenReturn(List.of(
buildParam(modelCode, paramCode, paramValue)
));
CcdiBankTagRule ruleMeta = new CcdiBankTagRule();
ruleMeta.setModelCode(modelCode);
ruleMeta.setRuleCode(ruleCode);
ruleMeta.setIndicatorCode(paramCode);
BankTagRuleExecutionConfig config = resolver.resolve(40L, ruleMeta);
assertEquals(Map.of(paramCode, paramValue), config.getThresholdValues());
}
private void assertRuleHasNoThresholds(String modelCode, String ruleCode) {
when(modelParamMapper.selectByProjectAndModel(0L, modelCode)).thenReturn(List.of(
buildParam(modelCode, "IGNORED_PARAM", "999")
));
CcdiBankTagRule ruleMeta = new CcdiBankTagRule();
ruleMeta.setModelCode(modelCode);
ruleMeta.setRuleCode(ruleCode);
BankTagRuleExecutionConfig config = resolver.resolve(40L, ruleMeta);
assertTrue(config.getThresholdValues().isEmpty());
}
private CcdiModelParam buildParam(String modelCode, String paramCode, String paramValue) {
CcdiModelParam param = new CcdiModelParam();
param.setProjectId(0L);
param.setModelCode(modelCode);
param.setParamCode(paramCode);
param.setParamValue(paramValue);
return param;
}
}

View File

@@ -6,6 +6,7 @@ import ch.qos.logback.core.read.ListAppender;
import com.ruoyi.ccdi.project.domain.entity.CcdiBankTagRule;
import com.ruoyi.ccdi.project.domain.entity.CcdiBankTagTask;
import com.ruoyi.ccdi.project.domain.enums.TriggerType;
import com.ruoyi.ccdi.project.domain.vo.BankTagObjectHitVO;
import com.ruoyi.ccdi.project.domain.vo.BankTagRuleExecutionConfig;
import com.ruoyi.ccdi.project.domain.vo.BankTagStatementHitVO;
import com.ruoyi.ccdi.project.mapper.CcdiBankTagAnalysisMapper;
@@ -24,6 +25,7 @@ import org.slf4j.LoggerFactory;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Executor;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -243,6 +245,52 @@ class CcdiBankTagServiceImplTest {
verify(taskMapper).updateTask(argThat(task -> "SUCCESS".equals(task.getStatus()) && task.getFailedRuleCount() == 0));
}
@Test
void rebuildProject_shouldDispatchWithdrawCntObjectRuleWithResolvedThreshold() {
ReflectionTestUtils.setField(service, "tagRuleExecutor", (Executor) Runnable::run);
CcdiBankTagRule rule = buildRule("ABNORMAL_BEHAVIOR", "异常行为",
"WITHDRAW_CNT", "微信支付宝频繁提现", "OBJECT");
BankTagRuleExecutionConfig config = buildConfig(40L, rule);
config.setThresholdValues(Map.of("WITHDRAW_CNT", "3"));
BankTagObjectHitVO hit = new BankTagObjectHitVO();
hit.setObjectType("STAFF_ID_CARD");
hit.setObjectKey("330101199001011234");
hit.setReasonDetail("单日微信提现 4 次,超过阈值 3 次");
when(ruleMapper.selectEnabledRules("ABNORMAL_BEHAVIOR")).thenReturn(List.of(rule));
when(configResolver.resolve(40L, rule)).thenReturn(config);
when(analysisMapper.selectWithdrawCntObjects(40L, 3)).thenReturn(List.of(hit));
service.rebuildProject(40L, "ABNORMAL_BEHAVIOR", "admin", TriggerType.MANUAL);
verify(analysisMapper).selectWithdrawCntObjects(40L, 3);
verify(resultMapper).insertBatch(argThat(results -> results.size() == 1
&& "STAFF_ID_CARD".equals(results.get(0).getObjectType())
&& "330101199001011234".equals(results.get(0).getObjectKey())));
}
@Test
void rebuildProject_shouldTreatEmptyWithdrawCntHitsAsSuccess() {
ReflectionTestUtils.setField(service, "tagRuleExecutor", (Executor) Runnable::run);
CcdiBankTagRule rule = buildRule("ABNORMAL_BEHAVIOR", "异常行为",
"WITHDRAW_CNT", "微信支付宝频繁提现", "OBJECT");
BankTagRuleExecutionConfig config = buildConfig(40L, rule);
config.setThresholdValues(Map.of("WITHDRAW_CNT", "3"));
when(ruleMapper.selectEnabledRules("ABNORMAL_BEHAVIOR")).thenReturn(List.of(rule));
when(configResolver.resolve(40L, rule)).thenReturn(config);
when(analysisMapper.selectWithdrawCntObjects(40L, 3)).thenReturn(List.of());
service.rebuildProject(40L, "ABNORMAL_BEHAVIOR", "admin", TriggerType.MANUAL);
verify(analysisMapper).selectWithdrawCntObjects(40L, 3);
verify(resultMapper, never()).insertBatch(anyList());
verify(taskMapper).updateTask(argThat(task -> "SUCCESS".equals(task.getStatus()) && task.getHitCount() == 0));
}
@Test
void shouldMarkProjectTaggingBeforeExecutingAndCompletedAfterSuccess() {
ReflectionTestUtils.setField(service, "tagRuleExecutor", (Executor) Runnable::run);