合并第二期流水真实规则后端修复

This commit is contained in:
wkc
2026-03-20 16:32:14 +08:00
14 changed files with 1013 additions and 64 deletions

View File

@@ -110,9 +110,13 @@ public interface CcdiBankTagAnalysisMapper {
* 疑似赌博交易 * 疑似赌博交易
* *
* @param projectId 项目ID * @param projectId 项目ID
* @param amountMinThreshold 可疑金额下限
* @param amountMaxThreshold 可疑金额上限
* @return 对象命中结果 * @return 对象命中结果
*/ */
List<BankTagObjectHitVO> selectMultiPartyGamblingTransferObjects(@Param("projectId") Long projectId); List<BankTagObjectHitVO> selectMultiPartyGamblingTransferObjects(@Param("projectId") Long projectId,
@Param("amountMinThreshold") BigDecimal amountMinThreshold,
@Param("amountMaxThreshold") BigDecimal amountMaxThreshold);
/** /**
* 疑似敏感交易 * 疑似敏感交易
@@ -134,17 +138,23 @@ public interface CcdiBankTagAnalysisMapper {
* 月度固定收入疑似兼职 * 月度固定收入疑似兼职
* *
* @param projectId 项目ID * @param projectId 项目ID
* @param threshold 月度固定收入阈值
* @return 对象命中结果 * @return 对象命中结果
*/ */
List<BankTagObjectHitVO> selectMonthlyFixedIncomeObjects(@Param("projectId") Long projectId); List<BankTagObjectHitVO> selectMonthlyFixedIncomeObjects(@Param("projectId") Long projectId,
@Param("threshold") BigDecimal threshold);
/** /**
* 固定交易对手转入疑似兼职 * 固定交易对手转入疑似兼职
* *
* @param projectId 项目ID * @param projectId 项目ID
* @param quarterMinThreshold 季度收入下限
* @param quarterMaxThreshold 季度收入上限
* @return 对象命中结果 * @return 对象命中结果
*/ */
List<BankTagObjectHitVO> selectFixedCounterpartyTransferObjects(@Param("projectId") Long projectId); List<BankTagObjectHitVO> selectFixedCounterpartyTransferObjects(@Param("projectId") Long projectId,
@Param("quarterMinThreshold") BigDecimal quarterMinThreshold,
@Param("quarterMaxThreshold") BigDecimal quarterMaxThreshold);
/** /**
* 摘要收入疑似兼职 * 摘要收入疑似兼职

View File

@@ -35,7 +35,10 @@ public class BankTagRuleConfigResolver {
Map.entry("FOREX_SELL_AMT", Set.of("SINGLE_SETTLEMENT_AMOUNT")), Map.entry("FOREX_SELL_AMT", Set.of("SINGLE_SETTLEMENT_AMOUNT")),
Map.entry("WITHDRAW_CNT", Set.of("WITHDRAW_CNT")), Map.entry("WITHDRAW_CNT", Set.of("WITHDRAW_CNT")),
Map.entry("STOCK_TFR_LARGE", Set.of("STOCK_TFR_LARGE")), Map.entry("STOCK_TFR_LARGE", Set.of("STOCK_TFR_LARGE")),
Map.entry("LARGE_STOCK_TRADING", Set.of("STOCK_TFR_LARGE")) Map.entry("LARGE_STOCK_TRADING", Set.of("STOCK_TFR_LARGE")),
Map.entry("MULTI_PARTY_GAMBLING_TRANSFER", Set.of("MULTI_PARTY_AMT_MIN", "MULTI_PARTY_AMT_MAX")),
Map.entry("MONTHLY_FIXED_INCOME", Set.of("MONTHLY_FIXED_INCOME")),
Map.entry("FIXED_COUNTERPARTY_TRANSFER", Set.of("FIXED_COUNTERPARTY_TRANSFER_MIN", "FIXED_COUNTERPARTY_TRANSFER_MAX"))
); );
@Resource @Resource

View File

@@ -267,9 +267,19 @@ public class CcdiBankTagServiceImpl implements ICcdiBankTagService {
toInteger(config.getThresholdValue("FREQUENT_CASH_DEPOSIT")) toInteger(config.getThresholdValue("FREQUENT_CASH_DEPOSIT"))
); );
case "LOW_INCOME_RELATIVE_LARGE_TRANSACTION" -> analysisMapper.selectLowIncomeRelativeLargeTransactionObjects(projectId); case "LOW_INCOME_RELATIVE_LARGE_TRANSACTION" -> analysisMapper.selectLowIncomeRelativeLargeTransactionObjects(projectId);
case "MULTI_PARTY_GAMBLING_TRANSFER" -> analysisMapper.selectMultiPartyGamblingTransferObjects(projectId); case "MULTI_PARTY_GAMBLING_TRANSFER" -> analysisMapper.selectMultiPartyGamblingTransferObjects(
case "MONTHLY_FIXED_INCOME" -> analysisMapper.selectMonthlyFixedIncomeObjects(projectId); projectId,
case "FIXED_COUNTERPARTY_TRANSFER" -> analysisMapper.selectFixedCounterpartyTransferObjects(projectId); toBigDecimal(config.getThresholdValue("MULTI_PARTY_AMT_MIN")),
toBigDecimal(config.getThresholdValue("MULTI_PARTY_AMT_MAX"))
);
case "MONTHLY_FIXED_INCOME" -> analysisMapper.selectMonthlyFixedIncomeObjects(
projectId, toBigDecimal(config.getThresholdValue("MONTHLY_FIXED_INCOME"))
);
case "FIXED_COUNTERPARTY_TRANSFER" -> analysisMapper.selectFixedCounterpartyTransferObjects(
projectId,
toBigDecimal(config.getThresholdValue("FIXED_COUNTERPARTY_TRANSFER_MIN")),
toBigDecimal(config.getThresholdValue("FIXED_COUNTERPARTY_TRANSFER_MAX"))
);
case "INTEREST_PAYMENT_BY_OTHERS" -> analysisMapper.selectInterestPaymentByOthersObjects(projectId); case "INTEREST_PAYMENT_BY_OTHERS" -> analysisMapper.selectInterestPaymentByOthersObjects(projectId);
case "SUPPLIER_CONCENTRATION" -> analysisMapper.selectSupplierConcentrationObjects(projectId); case "SUPPLIER_CONCENTRATION" -> analysisMapper.selectSupplierConcentrationObjects(projectId);
case "WITHDRAW_CNT" -> analysisMapper.selectWithdrawCntObjects( case "WITHDRAW_CNT" -> analysisMapper.selectWithdrawCntObjects(

View File

@@ -124,6 +124,22 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
) )
</sql> </sql>
<sql id="salaryIncomePredicate">
bs.CUSTOMER_ACCOUNT_NAME = '浙江兰溪农村商业银行股份有限公司'
and (
IFNULL(bs.USER_MEMO, '') REGEXP '代发|工资|奖金|薪酬|薪金|补贴|薪|年终奖|年金|加班费|劳务费|劳务外包|提成|劳务派遣|绩效|酬劳|PAYROLL|SALA|CPF|directors.*fees'
or IFNULL(bs.CASH_TYPE, '') REGEXP '代发|工资|劳务费'
)
</sql>
<sql id="salaryDeductionPredicate">
(
IFNULL(bs.USER_MEMO, '') REGEXP '代扣|个税|社保|公积金|水费|电费|燃气|话费|党费|医保'
or IFNULL(bs.CASH_TYPE, '') REGEXP '代扣|个税|社保|公积金'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '税务|社保|公积金'
)
</sql>
<select id="selectHouseOrCarExpenseStatements" resultMap="BankTagStatementHitResultMap"> <select id="selectHouseOrCarExpenseStatements" resultMap="BankTagStatementHitResultMap">
select select
bs.bank_statement_id AS bankStatementId, bs.bank_statement_id AS bankStatementId,
@@ -376,19 +392,89 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<select id="selectLowIncomeRelativeLargeTransactionObjects" resultMap="BankTagObjectHitResultMap"> <select id="selectLowIncomeRelativeLargeTransactionObjects" resultMap="BankTagObjectHitResultMap">
select select
'STAFF_ID_CARD' AS objectType, 'STAFF_ID_CARD' AS objectType,
'' AS objectKey, t.objectKey AS objectKey,
'占位SQL待补充真实规则' AS reasonDetail CONCAT(
from ccdi_bank_statement bs '低收入关系人累计交易 ', CAST(t.totalAmount AS CHAR),
where 1 = 0 ' 元,命中关系人数 ', CAST(t.relationCount AS CHAR), ' 人'
) AS reasonDetail
from (
select
relation.person_id AS objectKey,
ROUND(SUM(IFNULL(bs.AMOUNT_DR, 0) + IFNULL(bs.AMOUNT_CR, 0)), 2) AS totalAmount,
COUNT(DISTINCT relation.relation_cert_no) AS relationCount
from ccdi_staff_fmy_relation relation
inner join ccdi_bank_statement bs on relation.relation_cert_no = bs.cret_no
where relation.status = 1
and (
relation.annual_income is null
or relation.annual_income = 0
or relation.annual_income / 12 &lt; 3000
)
and bs.project_id = #{projectId}
and IFNULL(bs.LE_ACCOUNT_NAME, '') &lt;&gt; IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '')
group by relation.person_id
having SUM(IFNULL(bs.AMOUNT_DR, 0) + IFNULL(bs.AMOUNT_CR, 0)) > 100000
) t
</select> </select>
<select id="selectMultiPartyGamblingTransferObjects" resultMap="BankTagObjectHitResultMap"> <select id="selectMultiPartyGamblingTransferObjects" resultMap="BankTagObjectHitResultMap">
select select
'STAFF_ID_CARD' AS objectType, 'STAFF_ID_CARD' AS objectType,
'' AS objectKey, t.objectKey AS objectKey,
'占位SQL待补充真实规则' AS reasonDetail CONCAT(
from ccdi_bank_statement bs '交易日 ', MAX(t.tradeDate),
where 1 = 0 ' 发生 ', CAST(MAX(t.hitCount) AS CHAR),
' 笔疑似赌博交易,涉及 ', CAST(MAX(t.partyCount) AS CHAR),
' 个对手方,金额合计 ', CAST(MAX(t.totalAmount) AS CHAR), ' 元'
) AS reasonDetail
from (
select
source.objectKey AS objectKey,
source.tradeDate AS tradeDate,
COUNT(1) AS hitCount,
COUNT(DISTINCT source.customerAccountName) AS partyCount,
ROUND(SUM(source.tradeAmount), 2) AS totalAmount
from (
select
staff.id_card AS objectKey,
LEFT(TRIM(bs.TRX_DATE), 10) AS tradeDate,
bs.CUSTOMER_ACCOUNT_NAME AS customerAccountName,
GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) AS tradeAmount
from ccdi_bank_statement bs
inner join ccdi_base_staff staff on staff.id_card = bs.cret_no
where bs.project_id = #{projectId}
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) between #{amountMinThreshold} and #{amountMaxThreshold}
and IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') &lt;&gt; ''
and (
IFNULL(bs.USER_MEMO, '') REGEXP '微信|wechat|WeChat|财付通|Tenpay|支付宝|Alipay|转账|红包'
or IFNULL(bs.CASH_TYPE, '') REGEXP '微信|wechat|WeChat|财付通|Tenpay|支付宝|Alipay|转账|红包'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '微信|wechat|WeChat|财付通|Tenpay|支付宝|Alipay'
)
union all
select
relation.person_id AS objectKey,
LEFT(TRIM(bs.TRX_DATE), 10) AS tradeDate,
bs.CUSTOMER_ACCOUNT_NAME AS customerAccountName,
GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) AS tradeAmount
from ccdi_bank_statement bs
inner join ccdi_staff_fmy_relation relation on relation.relation_cert_no = bs.cret_no
where relation.status = 1
and bs.project_id = #{projectId}
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) between #{amountMinThreshold} and #{amountMaxThreshold}
and IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') &lt;&gt; ''
and (
IFNULL(bs.USER_MEMO, '') REGEXP '微信|wechat|WeChat|财付通|Tenpay|支付宝|Alipay|转账|红包'
or IFNULL(bs.CASH_TYPE, '') REGEXP '微信|wechat|WeChat|财付通|Tenpay|支付宝|Alipay|转账|红包'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '微信|wechat|WeChat|财付通|Tenpay|支付宝|Alipay'
)
) source
group by source.objectKey, source.tradeDate
having COUNT(1) > 2
and COUNT(DISTINCT source.customerAccountName) >= 2
) t
group by t.objectKey
</select> </select>
<select id="selectGamblingSensitiveKeywordStatements" resultMap="BankTagStatementHitResultMap"> <select id="selectGamblingSensitiveKeywordStatements" resultMap="BankTagStatementHitResultMap">
@@ -440,19 +526,110 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<select id="selectMonthlyFixedIncomeObjects" resultMap="BankTagObjectHitResultMap"> <select id="selectMonthlyFixedIncomeObjects" resultMap="BankTagObjectHitResultMap">
select select
'STAFF_ID_CARD' AS objectType, 'STAFF_ID_CARD' AS objectType,
'' AS objectKey, t.objectKey AS objectKey,
'占位SQL待补充真实规则' AS reasonDetail CONCAT(
from ccdi_bank_statement bs '近12个月有 ', CAST(t.monthCount AS CHAR),
where 1 = 0 ' 个月固定收入超过阈值,月均收入 ', CAST(t.avgAmount AS CHAR), ' 元'
) AS reasonDetail
from (
select
monthly_income.idCard AS objectKey,
COUNT(DISTINCT monthly_income.incomeMonth) AS monthCount,
ROUND(AVG(monthly_income.monthAmount), 2) AS avgAmount
from (
select
staff.id_card AS idCard,
LEFT(TRIM(bs.TRX_DATE), 7) AS incomeMonth,
ROUND(SUM(IFNULL(bs.AMOUNT_CR, 0)), 2) AS monthAmount
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.CUSTOMER_ACCOUNT_NAME, '') &lt;&gt; ''
and IFNULL(bs.LE_ACCOUNT_NAME, '') &lt;&gt; IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '')
and <include refid="salaryExclusionPredicate"/>
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(CURDATE(), INTERVAL 12 MONTH)
group by staff.id_card, LEFT(TRIM(bs.TRX_DATE), 7)
having SUM(IFNULL(bs.AMOUNT_CR, 0)) > #{threshold}
) monthly_income
group by monthly_income.idCard
having COUNT(DISTINCT monthly_income.incomeMonth) >= 6
and STDDEV(monthly_income.monthAmount) / NULLIF(AVG(monthly_income.monthAmount), 0) &lt;= 0.3
) t
</select> </select>
<select id="selectFixedCounterpartyTransferObjects" resultMap="BankTagObjectHitResultMap"> <select id="selectFixedCounterpartyTransferObjects" resultMap="BankTagObjectHitResultMap">
select select
'STAFF_ID_CARD' AS objectType, 'STAFF_ID_CARD' AS objectType,
'' AS objectKey, t.objectKey AS objectKey,
'占位SQL待补充真实规则' AS reasonDetail CONCAT(
from ccdi_bank_statement bs '固定对手“', t.customerAccountNames,
where 1 = 0 '”在 ', CAST(t.quarterCount AS CHAR),
' 个季度累计转入位于区间 [', CAST(#{quarterMinThreshold} AS CHAR),
', ', CAST(#{quarterMaxThreshold} AS CHAR), '] 元'
) AS reasonDetail
from (
select
stable_income.idCard AS objectKey,
GROUP_CONCAT(DISTINCT stable_income.customerAccountName ORDER BY stable_income.customerAccountName SEPARATOR '、') AS customerAccountNames,
MAX(stable_income.quarterCount) AS quarterCount
from (
select
quarter_income.idCard AS idCard,
quarter_income.customerAccountName AS customerAccountName,
COUNT(DISTINCT quarter_income.transQuarter) AS quarterCount
from (
select
staff.id_card AS idCard,
bs.CUSTOMER_ACCOUNT_NAME AS customerAccountName,
CONCAT(
YEAR(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')
)),
'-Q',
QUARTER(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')
))
) AS transQuarter,
ROUND(SUM(IFNULL(bs.AMOUNT_CR, 0)), 2) AS quarterAmount
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.CUSTOMER_ACCOUNT_NAME, '') &lt;&gt; ''
and IFNULL(bs.LE_ACCOUNT_NAME, '') &lt;&gt; IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '')
and <include refid="salaryExclusionPredicate"/>
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(CURDATE(), INTERVAL 12 MONTH)
group by
staff.id_card,
bs.CUSTOMER_ACCOUNT_NAME,
CONCAT(
YEAR(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')
)),
'-Q',
QUARTER(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')
))
)
having SUM(IFNULL(bs.AMOUNT_CR, 0)) between #{quarterMinThreshold} and #{quarterMaxThreshold}
) quarter_income
group by quarter_income.idCard, quarter_income.customerAccountName
having COUNT(DISTINCT quarter_income.transQuarter) >= 2
) stable_income
group by stable_income.idCard
having COUNT(DISTINCT stable_income.customerAccountName) &lt; 3
) t
</select> </select>
<select id="selectSuspiciousIncomeKeywordStatements" resultMap="BankTagStatementHitResultMap"> <select id="selectSuspiciousIncomeKeywordStatements" resultMap="BankTagStatementHitResultMap">
@@ -478,32 +655,176 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<select id="selectHouseRegistrationMismatchStatements" resultMap="BankTagStatementHitResultMap"> <select id="selectHouseRegistrationMismatchStatements" resultMap="BankTagStatementHitResultMap">
select select
bs.bank_statement_id AS bankStatementId, trade.bankStatementId AS bankStatementId,
bs.group_id AS groupId, trade.groupId AS groupId,
bs.batch_id AS logId, trade.logId AS logId,
'占位SQL待补充真实规则' AS reasonDetail CONCAT(
from ccdi_bank_statement bs '购房交易金额 ', CAST(trade.amountDr AS CHAR),
where 1 = 0 ' 元,对手方“', IFNULL(trade.customerAccountName, ''),
'”,证件号 ', trade.personId, ' 名下无房产登记'
) AS reasonDetail
from (
select
staff.id_card AS personId,
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
IFNULL(bs.AMOUNT_DR, 0) AS amountDr,
bs.CUSTOMER_ACCOUNT_NAME AS customerAccountName
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 '房地产|置业|置地|地产|房产|不动产|链家|贝壳|我爱我家|房管局'
)
union all
select
relation.relation_cert_no AS personId,
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
IFNULL(bs.AMOUNT_DR, 0) AS amountDr,
bs.CUSTOMER_ACCOUNT_NAME AS customerAccountName
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 '房地产|置业|置地|地产|房产|不动产|链家|贝壳|我爱我家|房管局'
)
) trade
left join (
select distinct
asset.person_id AS personId
from ccdi_asset_info asset
where asset.asset_main_type = '房产'
and asset.asset_sub_type = '住宅'
and asset.asset_status = '正常'
) asset
on asset.personId = trade.personId
where asset.personId is null
</select> </select>
<select id="selectPropertyFeeRegistrationMismatchStatements" resultMap="BankTagStatementHitResultMap"> <select id="selectPropertyFeeRegistrationMismatchStatements" resultMap="BankTagStatementHitResultMap">
select select
bs.bank_statement_id AS bankStatementId, trade.bankStatementId AS bankStatementId,
bs.group_id AS groupId, trade.groupId AS groupId,
bs.batch_id AS logId, trade.logId AS logId,
'占位SQL待补充真实规则' AS reasonDetail CONCAT(
from ccdi_bank_statement bs '物业缴费金额 ', CAST(trade.amountDr AS CHAR),
where 1 = 0 ' 元,对手方“', IFNULL(trade.customerAccountName, ''),
'”,证件号 ', trade.personId, ' 名下无房产登记'
) AS reasonDetail
from (
select
staff.id_card AS personId,
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
IFNULL(bs.AMOUNT_DR, 0) AS amountDr,
bs.CUSTOMER_ACCOUNT_NAME AS customerAccountName
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 '物业|小区|花园|苑|中心|大厦|业委会|业主委员会|置业|房地产|服务中心|管理处|社区'
)
union all
select
relation.relation_cert_no AS personId,
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
IFNULL(bs.AMOUNT_DR, 0) AS amountDr,
bs.CUSTOMER_ACCOUNT_NAME AS customerAccountName
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 '物业|小区|花园|苑|中心|大厦|业委会|业主委员会|置业|房地产|服务中心|管理处|社区'
)
) trade
left join (
select distinct
asset.person_id AS personId
from ccdi_asset_info asset
where asset.asset_main_type = '房产'
and asset.asset_sub_type = '住宅'
and asset.asset_status = '正常'
) asset
on asset.personId = trade.personId
where asset.personId is null
</select> </select>
<select id="selectTaxAssetRegistrationMismatchStatements" resultMap="BankTagStatementHitResultMap"> <select id="selectTaxAssetRegistrationMismatchStatements" resultMap="BankTagStatementHitResultMap">
select select
bs.bank_statement_id AS bankStatementId, trade.bankStatementId AS bankStatementId,
bs.group_id AS groupId, trade.groupId AS groupId,
bs.batch_id AS logId, trade.logId AS logId,
'占位SQL待补充真实规则' AS reasonDetail CONCAT(
from ccdi_bank_statement bs '税费支出金额 ', CAST(trade.amountDr AS CHAR),
where 1 = 0 ' 元,对手方“', IFNULL(trade.customerAccountName, ''),
'”,证件号 ', trade.personId, ' 名下无房产登记'
) AS reasonDetail
from (
select
staff.id_card AS personId,
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
IFNULL(bs.AMOUNT_DR, 0) AS amountDr,
bs.CUSTOMER_ACCOUNT_NAME AS customerAccountName
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) >= 5000
and (
IFNULL(bs.USER_MEMO, '') REGEXP '税务|缴税|税款'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '税务|税务局|国库|国家金库|财政'
)
union all
select
relation.relation_cert_no AS personId,
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
IFNULL(bs.AMOUNT_DR, 0) AS amountDr,
bs.CUSTOMER_ACCOUNT_NAME AS customerAccountName
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) >= 5000
and (
IFNULL(bs.USER_MEMO, '') REGEXP '税务|缴税|税款'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '税务|税务局|国库|国家金库|财政'
)
) trade
left join (
select distinct
asset.person_id AS personId
from ccdi_asset_info asset
where asset.asset_main_type = '房产'
and asset.asset_sub_type = '住宅'
and asset.asset_status = '正常'
) asset
on asset.personId = trade.personId
where asset.personId is null
</select> </select>
<select id="selectIncomeAssetMismatchStatements" resultMap="BankTagStatementHitResultMap"> <select id="selectIncomeAssetMismatchStatements" resultMap="BankTagStatementHitResultMap">
@@ -610,10 +931,84 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<select id="selectSupplierConcentrationObjects" resultMap="BankTagObjectHitResultMap"> <select id="selectSupplierConcentrationObjects" resultMap="BankTagObjectHitResultMap">
select select
'STAFF_ID_CARD' AS objectType, 'STAFF_ID_CARD' AS objectType,
'' AS objectKey, t.objectKey AS objectKey,
'占位SQL待补充真实规则' AS reasonDetail CONCAT(
from ccdi_bank_statement bs '供应商“', t.supplierName,
where 1 = 0 '”采购金额 ', CAST(t.supplierAmount AS CHAR),
' 元,占总采购金额 ', CAST(t.supplierRatioPct AS CHAR), '%'
) AS reasonDetail
from (
select
supplier_hit.objectKey AS objectKey,
SUBSTRING_INDEX(
GROUP_CONCAT(supplier_hit.supplierName ORDER BY supplier_hit.supplierRatio DESC SEPARATOR ','),
',',
1
) AS supplierName,
MAX(supplier_hit.supplierAmount) AS supplierAmount,
ROUND(MAX(supplier_hit.supplierRatio) * 100, 2) AS supplierRatioPct
from (
select
source.objectKey AS objectKey,
source.supplierName AS supplierName,
ROUND(SUM(source.actualAmount), 2) AS supplierAmount,
SUM(source.actualAmount) / NULLIF(total_amount.totalAmount, 0) AS supplierRatio
from (
select distinct
staff.id_card AS objectKey,
pt.purchase_id AS purchaseId,
pt.supplier_name AS supplierName,
IFNULL(pt.actual_amount, 0) 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) > 0
and IFNULL(pt.supplier_name, '') &lt;&gt; ''
union
select distinct
staff.id_card AS objectKey,
pt.purchase_id AS purchaseId,
pt.supplier_name AS supplierName,
IFNULL(pt.actual_amount, 0) 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) > 0
and IFNULL(pt.supplier_name, '') &lt;&gt; ''
) source
inner join (
select
source_total.objectKey AS objectKey,
ROUND(SUM(source_total.actualAmount), 2) AS totalAmount
from (
select distinct
staff.id_card AS objectKey,
pt.purchase_id AS purchaseId,
IFNULL(pt.actual_amount, 0) 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) > 0
union
select distinct
staff.id_card AS objectKey,
pt.purchase_id AS purchaseId,
IFNULL(pt.actual_amount, 0) 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) > 0
) source_total
group by source_total.objectKey
) total_amount
on total_amount.objectKey = source.objectKey
group by source.objectKey, source.supplierName, total_amount.totalAmount
having SUM(source.actualAmount) / NULLIF(total_amount.totalAmount, 0) > 0.7
) supplier_hit
group by supplier_hit.objectKey
) t
</select> </select>
<select id="selectStockTfrLargeStatements" resultMap="BankTagStatementHitResultMap"> <select id="selectStockTfrLargeStatements" resultMap="BankTagStatementHitResultMap">
@@ -680,19 +1075,119 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<select id="selectSalaryQuickTransferObjects" resultMap="BankTagObjectHitResultMap"> <select id="selectSalaryQuickTransferObjects" resultMap="BankTagObjectHitResultMap">
select select
'STAFF_ID_CARD' AS objectType, 'STAFF_ID_CARD' AS objectType,
'' AS objectKey, t.objectKey AS objectKey,
'占位SQL待补充真实规则' AS reasonDetail CONCAT(
from ccdi_bank_statement bs '工资入账 ', CAST(t.salaryAmount AS CHAR),
where 1 = 0 ' 元后24小时内转出 ', CAST(t.transferAmount AS CHAR),
' 元,占比 ', CAST(t.transferRatioPct AS CHAR), '%'
) AS reasonDetail
from (
select
salary.objectKey AS objectKey,
MAX(salary.salaryAmount) AS salaryAmount,
MAX(out_trade.transferAmount) AS transferAmount,
ROUND(MAX(out_trade.transferAmount / NULLIF(salary.salaryAmount, 0)) * 100, 2) AS transferRatioPct
from (
select
staff.id_card AS objectKey,
IFNULL(bs.AMOUNT_CR, 0) AS salaryAmount,
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')
) AS salaryTime
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 <include refid="salaryIncomePredicate"/>
) salary
inner join (
select
salary_source.objectKey AS objectKey,
salary_source.salaryTime AS salaryTime,
ROUND(SUM(IFNULL(out_bs.AMOUNT_DR, 0)), 2) AS transferAmount
from (
select
staff.id_card AS objectKey,
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')
) AS salaryTime
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 <include refid="salaryIncomePredicate"/>
) salary_source
inner join ccdi_bank_statement out_bs
on out_bs.project_id = #{projectId}
and out_bs.cret_no = salary_source.objectKey
and IFNULL(out_bs.AMOUNT_DR, 0) > 0
and COALESCE(
STR_TO_DATE(LEFT(TRIM(out_bs.TRX_DATE), 19), '%Y-%m-%d %H:%i:%s'),
STR_TO_DATE(LEFT(TRIM(out_bs.TRX_DATE), 10), '%Y-%m-%d')
) > salary_source.salaryTime
and COALESCE(
STR_TO_DATE(LEFT(TRIM(out_bs.TRX_DATE), 19), '%Y-%m-%d %H:%i:%s'),
STR_TO_DATE(LEFT(TRIM(out_bs.TRX_DATE), 10), '%Y-%m-%d')
) &lt;= DATE_ADD(salary_source.salaryTime, INTERVAL 24 HOUR)
group by salary_source.objectKey, salary_source.salaryTime
) out_trade
on out_trade.objectKey = salary.objectKey
and out_trade.salaryTime = salary.salaryTime
where out_trade.transferAmount / NULLIF(salary.salaryAmount, 0) > 0.8
group by salary.objectKey
) t
</select> </select>
<select id="selectSalaryUnusedObjects" resultMap="BankTagObjectHitResultMap"> <select id="selectSalaryUnusedObjects" resultMap="BankTagObjectHitResultMap">
select select
'STAFF_ID_CARD' AS objectType, 'STAFF_ID_CARD' AS objectType,
'' AS objectKey, t.objectKey AS objectKey,
'占位SQL待补充真实规则' AS reasonDetail CONCAT(
from ccdi_bank_statement bs '工资入账 ', CAST(t.salaryAmount AS CHAR),
where 1 = 0 ' 元后30天内无消费或转账支出'
) AS reasonDetail
from (
select
salary.objectKey AS objectKey,
MAX(salary.salaryAmount) AS salaryAmount
from (
select
staff.id_card AS objectKey,
IFNULL(bs.AMOUNT_CR, 0) AS salaryAmount,
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')
) AS salaryTime
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 <include refid="salaryIncomePredicate"/>
) salary
where not exists (
select 1
from ccdi_bank_statement expense_bs
where expense_bs.project_id = #{projectId}
and expense_bs.cret_no = salary.objectKey
and IFNULL(expense_bs.AMOUNT_DR, 0) > 0
and COALESCE(
STR_TO_DATE(LEFT(TRIM(expense_bs.TRX_DATE), 19), '%Y-%m-%d %H:%i:%s'),
STR_TO_DATE(LEFT(TRIM(expense_bs.TRX_DATE), 10), '%Y-%m-%d')
) > salary.salaryTime
and COALESCE(
STR_TO_DATE(LEFT(TRIM(expense_bs.TRX_DATE), 19), '%Y-%m-%d %H:%i:%s'),
STR_TO_DATE(LEFT(TRIM(expense_bs.TRX_DATE), 10), '%Y-%m-%d')
) &lt;= DATE_ADD(salary.salaryTime, INTERVAL 30 DAY)
and not (
IFNULL(expense_bs.USER_MEMO, '') REGEXP '代扣|个税|社保|公积金|水费|电费|燃气|话费|党费|医保'
or IFNULL(expense_bs.CASH_TYPE, '') REGEXP '代扣|个税|社保|公积金'
or IFNULL(expense_bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '税务|社保|公积金'
)
)
group by salary.objectKey
) t
</select> </select>
<select id="selectLargeStockTradingStatements" resultMap="BankTagStatementHitResultMap"> <select id="selectLargeStockTradingStatements" resultMap="BankTagStatementHitResultMap">

View File

@@ -11,6 +11,7 @@ import java.util.List;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -27,6 +28,15 @@ class CcdiBankTagAnalysisMapperXmlTest {
"selectStockTfrLargeStatements", "selectStockTfrLargeStatements",
"selectLargeStockTradingStatements" "selectLargeStockTradingStatements"
); );
private static final List<String> PHASE_TWO_OBJECT_SELECT_IDS = List.of(
"selectLowIncomeRelativeLargeTransactionObjects",
"selectMultiPartyGamblingTransferObjects",
"selectMonthlyFixedIncomeObjects",
"selectFixedCounterpartyTransferObjects",
"selectSupplierConcentrationObjects",
"selectSalaryQuickTransferObjects",
"selectSalaryUnusedObjects"
);
private static final List<String> PLACEHOLDER_SELECT_IDS = List.of( private static final List<String> PLACEHOLDER_SELECT_IDS = List.of(
"selectAbnormalCustomerTransactionStatements", "selectAbnormalCustomerTransactionStatements",
"selectLowIncomeRelativeLargeTransactionObjects", "selectLowIncomeRelativeLargeTransactionObjects",
@@ -90,7 +100,7 @@ class CcdiBankTagAnalysisMapperXmlTest {
void placeholderRules_shouldUseEmptyResultSqlTemplate() throws Exception { void placeholderRules_shouldUseEmptyResultSqlTemplate() throws Exception {
String xml = readXml(RESOURCE); String xml = readXml(RESOURCE);
assertTrue(xml.contains("占位SQL待补充真实规则")); assertTrue(xml.contains("占位SQL待补充真实规则"));
assertEquals(16, countMatches(xml, "where 1 = 0")); assertEquals(6, countMatches(xml, "where 1 = 0"));
} }
@Test @Test
@@ -116,6 +126,29 @@ class CcdiBankTagAnalysisMapperXmlTest {
assertTrue(!selectSql.contains("where 1 = 0")); assertTrue(!selectSql.contains("where 1 = 0"));
} }
@Test
void phaseTwoObjectRules_shouldUseRealSqlAndKeepObjectHitFields() throws Exception {
String xml = readXml(RESOURCE);
for (String selectId : PHASE_TWO_OBJECT_SELECT_IDS) {
String selectSql = extractSelectSql(xml, selectId);
assertTrue(selectSql.contains("'STAFF_ID_CARD' AS objectType"), () -> selectId + " 缺少 objectType");
assertTrue(selectSql.contains("AS objectKey"), () -> selectId + " 缺少 objectKey");
assertTrue(selectSql.contains("reasonDetail"), () -> selectId + " 缺少 reasonDetail");
assertTrue(!selectSql.contains("where 1 = 0"), () -> selectId + " 仍是占位 SQL");
}
}
@Test
void assetRegistrationMismatchRules_shouldUseRealSqlAndAssetTable() throws Exception {
String xml = readXml(RESOURCE);
assertAll(
() -> assertStatementRuleUsesAssetTable(xml, "selectHouseRegistrationMismatchStatements"),
() -> assertStatementRuleUsesAssetTable(xml, "selectPropertyFeeRegistrationMismatchStatements"),
() -> assertStatementRuleUsesAssetTable(xml, "selectTaxAssetRegistrationMismatchStatements")
);
}
@Test @Test
void analysisMapperXml_shouldBeWellFormed() throws Exception { void analysisMapperXml_shouldBeWellFormed() throws Exception {
String xml = readXml(RESOURCE); String xml = readXml(RESOURCE);
@@ -149,4 +182,14 @@ class CcdiBankTagAnalysisMapperXmlTest {
assertTrue(matcher.find(), () -> "未找到 select: " + selectId); assertTrue(matcher.find(), () -> "未找到 select: " + selectId);
return matcher.group(); return matcher.group();
} }
private void assertStatementRuleUsesAssetTable(String xml, String selectId) {
String selectSql = extractSelectSql(xml, selectId);
assertTrue(selectSql.contains("ccdi_asset_info"), () -> selectId + " 缺少 ccdi_asset_info");
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");
}
} }

View File

@@ -19,6 +19,7 @@ import org.slf4j.LoggerFactory;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -163,6 +164,52 @@ class BankTagRuleConfigResolverTest {
assertRuleHasNoThresholds("SUSPICIOUS_PURCHASE", "LARGE_PURCHASE_TRANSACTION"); assertRuleHasNoThresholds("SUSPICIOUS_PURCHASE", "LARGE_PURCHASE_TRANSACTION");
} }
@Test
void resolve_shouldSupportPhaseTwoThresholdRulesAndKeepNoParamRulesEmpty() {
CcdiProject project = new CcdiProject();
project.setProjectId(40L);
project.setConfigType("default");
when(projectMapper.selectById(40L)).thenReturn(project);
when(modelParamMapper.selectByProjectAndModel(0L, "SUSPICIOUS_GAMBLING")).thenReturn(List.of(
buildParam("SUSPICIOUS_GAMBLING", "MULTI_PARTY_AMT_MIN", "500"),
buildParam("SUSPICIOUS_GAMBLING", "MULTI_PARTY_AMT_MAX", "5000")
));
when(modelParamMapper.selectByProjectAndModel(0L, "SUSPICIOUS_PART_TIME")).thenReturn(List.of(
buildParam("SUSPICIOUS_PART_TIME", "MONTHLY_FIXED_INCOME", "5000"),
buildParam("SUSPICIOUS_PART_TIME", "FIXED_COUNTERPARTY_TRANSFER_MIN", "3000"),
buildParam("SUSPICIOUS_PART_TIME", "FIXED_COUNTERPARTY_TRANSFER_MAX", "15000")
));
when(modelParamMapper.selectByProjectAndModel(0L, "ABNORMAL_TRANSACTION")).thenReturn(List.of(
buildParam("ABNORMAL_TRANSACTION", "IGNORED_PARAM", "999")
));
when(modelParamMapper.selectByProjectAndModel(0L, "SUSPICIOUS_PURCHASE")).thenReturn(List.of(
buildParam("SUSPICIOUS_PURCHASE", "IGNORED_PARAM", "999")
));
when(modelParamMapper.selectByProjectAndModel(0L, "SUSPICIOUS_PROPERTY")).thenReturn(List.of(
buildParam("SUSPICIOUS_PROPERTY", "IGNORED_PARAM", "999")
));
when(modelParamMapper.selectByProjectAndModel(0L, "ABNORMAL_BEHAVIOR")).thenReturn(List.of(
buildParam("ABNORMAL_BEHAVIOR", "IGNORED_PARAM", "999")
));
assertAll(
() -> assertRuleThresholds("SUSPICIOUS_GAMBLING", "MULTI_PARTY_GAMBLING_TRANSFER",
Map.of("MULTI_PARTY_AMT_MIN", "500", "MULTI_PARTY_AMT_MAX", "5000")),
() -> assertRuleThresholds("SUSPICIOUS_PART_TIME", "MONTHLY_FIXED_INCOME",
Map.of("MONTHLY_FIXED_INCOME", "5000")),
() -> assertRuleThresholds("SUSPICIOUS_PART_TIME", "FIXED_COUNTERPARTY_TRANSFER",
Map.of("FIXED_COUNTERPARTY_TRANSFER_MIN", "3000", "FIXED_COUNTERPARTY_TRANSFER_MAX", "15000")),
() -> assertRuleHasNoThresholds("ABNORMAL_TRANSACTION", "LOW_INCOME_RELATIVE_LARGE_TRANSACTION"),
() -> assertRuleHasNoThresholds("SUSPICIOUS_PURCHASE", "SUPPLIER_CONCENTRATION"),
() -> assertRuleHasNoThresholds("SUSPICIOUS_PROPERTY", "HOUSE_REGISTRATION_MISMATCH"),
() -> assertRuleHasNoThresholds("SUSPICIOUS_PROPERTY", "PROPERTY_FEE_REGISTRATION_MISMATCH"),
() -> assertRuleHasNoThresholds("SUSPICIOUS_PROPERTY", "TAX_ASSET_REGISTRATION_MISMATCH"),
() -> assertRuleHasNoThresholds("ABNORMAL_BEHAVIOR", "SALARY_QUICK_TRANSFER"),
() -> assertRuleHasNoThresholds("ABNORMAL_BEHAVIOR", "SALARY_UNUSED")
);
}
private CcdiModelParam buildParam(String paramCode, String paramValue) { private CcdiModelParam buildParam(String paramCode, String paramValue) {
CcdiModelParam param = new CcdiModelParam(); CcdiModelParam param = new CcdiModelParam();
param.setProjectId(0L); param.setProjectId(0L);
@@ -201,6 +248,16 @@ class BankTagRuleConfigResolverTest {
assertTrue(config.getThresholdValues().isEmpty()); assertTrue(config.getThresholdValues().isEmpty());
} }
private void assertRuleThresholds(String modelCode, String ruleCode, Map<String, String> expectedThresholds) {
CcdiBankTagRule ruleMeta = new CcdiBankTagRule();
ruleMeta.setModelCode(modelCode);
ruleMeta.setRuleCode(ruleCode);
BankTagRuleExecutionConfig config = resolver.resolve(40L, ruleMeta);
assertEquals(expectedThresholds, config.getThresholdValues());
}
private CcdiModelParam buildParam(String modelCode, String paramCode, String paramValue) { private CcdiModelParam buildParam(String modelCode, String paramCode, String paramValue) {
CcdiModelParam param = new CcdiModelParam(); CcdiModelParam param = new CcdiModelParam();
param.setProjectId(0L); param.setProjectId(0L);

View File

@@ -27,6 +27,7 @@ import org.springframework.test.util.ReflectionTestUtils;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.Executor; import java.util.concurrent.Executor;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -327,6 +328,84 @@ class CcdiBankTagServiceImplTest {
verify(projectService).updateProjectStatus(40L, "0", "tester"); verify(projectService).updateProjectStatus(40L, "0", "tester");
} }
@Test
void rebuildProject_shouldDispatchPhaseTwoThresholdObjectRulesWithResolvedThresholds() {
ReflectionTestUtils.setField(service, "tagRuleExecutor", (Executor) Runnable::run);
CcdiBankTagRule gamblingRule = buildRule("SUSPICIOUS_GAMBLING", "疑似赌博",
"MULTI_PARTY_GAMBLING_TRANSFER", "疑似赌博交易", "OBJECT");
CcdiBankTagRule monthlyRule = buildRule("SUSPICIOUS_PART_TIME", "可疑兼职",
"MONTHLY_FIXED_INCOME", "疑似兼职", "OBJECT");
CcdiBankTagRule fixedCounterpartyRule = buildRule("SUSPICIOUS_PART_TIME", "可疑兼职",
"FIXED_COUNTERPARTY_TRANSFER", "疑似兼职", "OBJECT");
BankTagRuleExecutionConfig gamblingConfig = buildConfig(40L, gamblingRule);
gamblingConfig.setThresholdValues(Map.of("MULTI_PARTY_AMT_MIN", "500", "MULTI_PARTY_AMT_MAX", "5000"));
BankTagRuleExecutionConfig monthlyConfig = buildConfig(40L, monthlyRule);
monthlyConfig.setThresholdValues(Map.of("MONTHLY_FIXED_INCOME", "5000"));
BankTagRuleExecutionConfig fixedCounterpartyConfig = buildConfig(40L, fixedCounterpartyRule);
fixedCounterpartyConfig.setThresholdValues(Map.of(
"FIXED_COUNTERPARTY_TRANSFER_MIN", "3000",
"FIXED_COUNTERPARTY_TRANSFER_MAX", "15000"
));
when(ruleMapper.selectEnabledRules("SUSPICIOUS_GAMBLING")).thenReturn(List.of(gamblingRule));
when(configResolver.resolve(40L, gamblingRule)).thenReturn(gamblingConfig);
when(analysisMapper.selectMultiPartyGamblingTransferObjects(40L, new BigDecimal("500"), new BigDecimal("5000")))
.thenReturn(List.of());
when(ruleMapper.selectEnabledRules("SUSPICIOUS_PART_TIME")).thenReturn(List.of(monthlyRule, fixedCounterpartyRule));
when(configResolver.resolve(40L, monthlyRule)).thenReturn(monthlyConfig);
when(configResolver.resolve(40L, fixedCounterpartyRule)).thenReturn(fixedCounterpartyConfig);
when(analysisMapper.selectMonthlyFixedIncomeObjects(40L, new BigDecimal("5000"))).thenReturn(List.of());
when(analysisMapper.selectFixedCounterpartyTransferObjects(40L, new BigDecimal("3000"), new BigDecimal("15000")))
.thenReturn(List.of());
service.rebuildProject(40L, "SUSPICIOUS_GAMBLING", "admin", TriggerType.MANUAL);
service.rebuildProject(40L, "SUSPICIOUS_PART_TIME", "admin", TriggerType.MANUAL);
verify(analysisMapper).selectMultiPartyGamblingTransferObjects(40L, new BigDecimal("500"), new BigDecimal("5000"));
verify(analysisMapper).selectMonthlyFixedIncomeObjects(40L, new BigDecimal("5000"));
verify(analysisMapper).selectFixedCounterpartyTransferObjects(40L, new BigDecimal("3000"), new BigDecimal("15000"));
}
@Test
void rebuildProject_shouldDispatchPhaseTwoObjectRulesWithoutExtraThresholds() {
ReflectionTestUtils.setField(service, "tagRuleExecutor", (Executor) Runnable::run);
CcdiBankTagRule lowIncomeRule = buildRule("ABNORMAL_TRANSACTION", "异常交易",
"LOW_INCOME_RELATIVE_LARGE_TRANSACTION", "低收入亲属大额交易", "OBJECT");
CcdiBankTagRule supplierRule = buildRule("SUSPICIOUS_PURCHASE", "可疑采购",
"SUPPLIER_CONCENTRATION", "可疑采购", "OBJECT");
CcdiBankTagRule salaryQuickRule = buildRule("ABNORMAL_BEHAVIOR", "异常行为",
"SALARY_QUICK_TRANSFER", "工资快速转出", "OBJECT");
CcdiBankTagRule salaryUnusedRule = buildRule("ABNORMAL_BEHAVIOR", "异常行为",
"SALARY_UNUSED", "工资无使用记录", "OBJECT");
when(ruleMapper.selectEnabledRules("ABNORMAL_TRANSACTION")).thenReturn(List.of(lowIncomeRule));
when(configResolver.resolve(40L, lowIncomeRule)).thenReturn(buildConfig(40L, lowIncomeRule));
when(analysisMapper.selectLowIncomeRelativeLargeTransactionObjects(40L)).thenReturn(List.of());
when(ruleMapper.selectEnabledRules("SUSPICIOUS_PURCHASE")).thenReturn(List.of(supplierRule));
when(configResolver.resolve(40L, supplierRule)).thenReturn(buildConfig(40L, supplierRule));
when(analysisMapper.selectSupplierConcentrationObjects(40L)).thenReturn(List.of());
when(ruleMapper.selectEnabledRules("ABNORMAL_BEHAVIOR")).thenReturn(List.of(salaryQuickRule, salaryUnusedRule));
when(configResolver.resolve(40L, salaryQuickRule)).thenReturn(buildConfig(40L, salaryQuickRule));
when(configResolver.resolve(40L, salaryUnusedRule)).thenReturn(buildConfig(40L, salaryUnusedRule));
when(analysisMapper.selectSalaryQuickTransferObjects(40L)).thenReturn(List.of());
when(analysisMapper.selectSalaryUnusedObjects(40L)).thenReturn(List.of());
service.rebuildProject(40L, "ABNORMAL_TRANSACTION", "admin", TriggerType.MANUAL);
service.rebuildProject(40L, "SUSPICIOUS_PURCHASE", "admin", TriggerType.MANUAL);
service.rebuildProject(40L, "ABNORMAL_BEHAVIOR", "admin", TriggerType.MANUAL);
verify(analysisMapper).selectLowIncomeRelativeLargeTransactionObjects(40L);
verify(analysisMapper).selectSupplierConcentrationObjects(40L);
verify(analysisMapper).selectSalaryQuickTransferObjects(40L);
verify(analysisMapper).selectSalaryUnusedObjects(40L);
}
private CcdiBankTagRule buildRule(String modelCode, String modelName, String ruleCode, String ruleName, String resultType) { private CcdiBankTagRule buildRule(String modelCode, String modelName, String ruleCode, String ruleName, String resultType) {
CcdiBankTagRule rule = new CcdiBankTagRule(); CcdiBankTagRule rule = new CcdiBankTagRule();
rule.setModelCode(modelCode); rule.setModelCode(modelCode);

View File

@@ -21,6 +21,15 @@ class CcdiBankTagRuleSqlMetadataTest {
assertPhase1Metadata(migrationSql); assertPhase1Metadata(migrationSql);
} }
@Test
void phase2MetadataSql_shouldAlignInitAndMigrationScripts() throws IOException {
String initSql = readProjectFile("sql", "2026-03-16-bank-tagging.sql");
String migrationSql = readProjectFile("sql", "migration", "2026-03-20-sync-bank-tag-phase2-rule-metadata.sql");
assertPhase2Metadata(initSql);
assertPhase2Metadata(migrationSql);
}
private void assertPhase1Metadata(String sqlContent) { private void assertPhase1Metadata(String sqlContent) {
assertAll( assertAll(
() -> assertTrue(sqlContent.contains("'FOREX_BUY_AMT'") () -> assertTrue(sqlContent.contains("'FOREX_BUY_AMT'")
@@ -41,6 +50,37 @@ class CcdiBankTagRuleSqlMetadataTest {
); );
} }
private void assertPhase2Metadata(String sqlContent) {
assertAll(
() -> assertTrue(sqlContent.contains("真实规则识别低收入关系人累计交易超10万元的员工对象"),
"应同步 LOW_INCOME_RELATIVE_LARGE_TRANSACTION 的真实规则说明"),
() -> assertTrue(sqlContent.contains("真实规则:识别同日多对手方且金额落在可疑区间的疑似赌博对象"),
"应同步 MULTI_PARTY_GAMBLING_TRANSFER 的真实规则说明"),
() -> assertTrue(sqlContent.contains("'MONTHLY_FIXED_INCOME'")
&& sqlContent.contains("真实规则识别近12个月持续出现稳定月度非工资收入的员工对象"),
"应同步 MONTHLY_FIXED_INCOME 的指标编码和真实规则说明"),
() -> assertTrue(sqlContent.contains("'FIXED_COUNTERPARTY_TRANSFER', '疑似兼职', NULL, 'OBJECT'")
&& sqlContent.contains("真实规则:识别固定交易对手季度转入金额落在设定区间的员工对象"),
"FIXED_COUNTERPARTY_TRANSFER 应清空旧 indicator_code 并同步真实规则说明"),
() -> assertTrue(sqlContent.contains("真实规则:识别购房支出但当前房产登记口径缺失的流水"),
"应同步 HOUSE_REGISTRATION_MISMATCH 的真实规则说明"),
() -> assertTrue(sqlContent.contains("真实规则:识别物业缴费但当前房产登记口径缺失的流水"),
"应同步 PROPERTY_FEE_REGISTRATION_MISMATCH 的真实规则说明"),
() -> assertTrue(sqlContent.contains("员工及关系人有5000元以上的纳税记录但当前资产登记口径下无房产登记。"),
"TAX_ASSET_REGISTRATION_MISMATCH 应使用当前资产登记口径表述"),
() -> assertTrue(sqlContent.contains("真实规则:识别大额纳税但当前房产登记口径缺失的流水"),
"应同步 TAX_ASSET_REGISTRATION_MISMATCH 的真实规则说明"),
() -> assertTrue(sqlContent.contains("真实规则识别单个供应商采购额占比超过70%的员工对象"),
"应同步 SUPPLIER_CONCENTRATION 的真实规则说明"),
() -> assertTrue(sqlContent.contains("工资发放后24小时内转出超过80%的资金。")
&& sqlContent.contains("真实规则识别工资入账24小时内快速转出的员工对象"),
"应同步 SALARY_QUICK_TRANSFER 的业务口径和真实规则说明"),
() -> assertTrue(sqlContent.contains("工资发放后除代扣项目外连续30天无消费或转账支出记录。")
&& sqlContent.contains("真实规则识别工资入账后30天内无消费或转账支出的员工对象"),
"应同步 SALARY_UNUSED 的业务口径修复和真实规则说明")
);
}
private String readProjectFile(String... parts) throws IOException { private String readProjectFile(String... parts) throws IOException {
Path path = Path.of("..", parts); Path path = Path.of("..", parts);
return Files.readString(path, StandardCharsets.UTF_8); return Files.readString(path, StandardCharsets.UTF_8);

View File

@@ -0,0 +1,31 @@
# 第二期银行流水规则元数据修复实施记录
## 问题背景
- 2026-03-20 校验发现第二期规则已完成后端真实实现,但当前数据库中的第二期 10 条规则元数据仍停留在占位状态。
- 直接查询 `ccdi_bank_tag_rule` 可见:
- 10 条第二期规则 `remark` 仍为“占位规则待补充真实SQL”
- `FIXED_COUNTERPARTY_TRANSFER.indicator_code` 仍为旧值 `FIXED_COUNTERPARTY_TRANSFER`
- `SALARY_UNUSED.business_caliber` 仍为乱码
- `TAX_ASSET_REGISTRATION_MISMATCH.business_caliber` 仍为旧口径
## 根因分析
- 第二期真实规则落地时已更新初始化脚本 [`sql/2026-03-16-bank-tagging.sql`](/Users/wkc/Desktop/ccdi/ccdi/.worktrees/bank-tag-real-rule-phase2-backend/sql/2026-03-16-bank-tagging.sql),但没有同步补一份增量迁移脚本。
- 当前仓库的 SQL 元数据测试此前只覆盖第一期,没有覆盖第二期,所以“只改初始化脚本、遗漏增量脚本”的问题没有被自动拦截。
- 已执行过旧增量脚本、但未重建规则表初始化数据的环境,会继续保留第二期占位元数据。
## 本次修改
- 扩展 SQL 元数据测试 [`CcdiBankTagRuleSqlMetadataTest.java`](/Users/wkc/Desktop/ccdi/ccdi/.worktrees/bank-tag-real-rule-phase2-backend/ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiBankTagRuleSqlMetadataTest.java)
- 保留第一期元数据校验
- 新增第二期初始化脚本与迁移脚本一致性校验
- 约束第二期 10 条规则的 `indicator_code``business_caliber``remark` 必须与真实规则实现对齐
- 新增增量脚本 [`2026-03-20-sync-bank-tag-phase2-rule-metadata.sql`](/Users/wkc/Desktop/ccdi/ccdi/.worktrees/bank-tag-real-rule-phase2-backend/sql/migration/2026-03-20-sync-bank-tag-phase2-rule-metadata.sql)
- 使用 `INSERT ... ON DUPLICATE KEY UPDATE` 同步第二期 10 条规则元数据
- 清空 `FIXED_COUNTERPARTY_TRANSFER` 的旧 `indicator_code`
- 修复 `SALARY_UNUSED` 乱码与 `TAX_ASSET_REGISTRATION_MISMATCH` 业务口径
- 同步 10 条规则的真实规则 `remark`
- 使用 `bin/mysql_utf8_exec.sh` 将第二期元数据修复脚本落到当前验证数据库
## 实施结果
- 第二期规则元数据已与真实后端实现对齐。
- 新增测试可在仓库层拦住“第二期初始化脚本已改、迁移脚本漏补”的回归。
- 当前数据库中的第二期规则不再继续保留占位 `remark`、旧 `indicator_code` 和乱码业务口径。

View File

@@ -0,0 +1,80 @@
# 银行流水真实规则第二期后端实施记录
## 第二期规则范围
- `LOW_INCOME_RELATIVE_LARGE_TRANSACTION`
- `MULTI_PARTY_GAMBLING_TRANSFER`
- `MONTHLY_FIXED_INCOME`
- `FIXED_COUNTERPARTY_TRANSFER`
- `HOUSE_REGISTRATION_MISMATCH`
- `PROPERTY_FEE_REGISTRATION_MISMATCH`
- `TAX_ASSET_REGISTRATION_MISMATCH`
- `SUPPLIER_CONCENTRATION`
- `SALARY_QUICK_TRANSFER`
- `SALARY_UNUSED`
## 修改内容
- 补齐第二期规则参数映射与阈值透传
- 替换第二期 3 条资产比对明细规则占位 SQL
- 替换第二期 7 条对象聚合规则占位 SQL
- 对齐第二期规则元数据脚本、实施记录与验证记录
## 参数与分发调整
-`BankTagRuleConfigResolver` 中补齐第二期阈值规则参数映射:
- `MULTI_PARTY_GAMBLING_TRANSFER -> MULTI_PARTY_AMT_MIN, MULTI_PARTY_AMT_MAX`
- `MONTHLY_FIXED_INCOME -> MONTHLY_FIXED_INCOME`
- `FIXED_COUNTERPARTY_TRANSFER -> FIXED_COUNTERPARTY_TRANSFER_MIN, FIXED_COUNTERPARTY_TRANSFER_MAX`
- 明确以下规则为无阈值规则,继续返回空参数集:
- `LOW_INCOME_RELATIVE_LARGE_TRANSACTION`
- `SUPPLIER_CONCENTRATION`
- `HOUSE_REGISTRATION_MISMATCH`
- `PROPERTY_FEE_REGISTRATION_MISMATCH`
- `TAX_ASSET_REGISTRATION_MISMATCH`
- `SALARY_QUICK_TRANSFER`
- `SALARY_UNUSED`
-`CcdiBankTagServiceImpl` 中为第二期 3 条阈值型对象规则改为显式透传解析后的阈值,避免继续走占位分支。
## 资产比对真实 SQL
-`CcdiBankTagAnalysisMapper.xml` 中将以下 3 条明细规则替换为真实 SQL
- `HOUSE_REGISTRATION_MISMATCH`
- `PROPERTY_FEE_REGISTRATION_MISMATCH`
- `TAX_ASSET_REGISTRATION_MISMATCH`
- 三条规则统一基于 `ccdi_asset_info` 校验员工及关系人当前资产登记情况,并继续输出 `bankStatementId/groupId/logId/reasonDetail`
- 房产登记口径统一使用当前项目数据中的实际枚举值:
- `asset_main_type = '房产'`
- `asset_sub_type = '住宅'`
- `asset_status = '正常'`
## 对象聚合真实 SQL
-`CcdiBankTagAnalysisMapper.xml` 中将以下 7 条对象规则替换为真实 SQL
- `LOW_INCOME_RELATIVE_LARGE_TRANSACTION`
- `MULTI_PARTY_GAMBLING_TRANSFER`
- `MONTHLY_FIXED_INCOME`
- `FIXED_COUNTERPARTY_TRANSFER`
- `SUPPLIER_CONCENTRATION`
- `SALARY_QUICK_TRANSFER`
- `SALARY_UNUSED`
- 对象型规则统一按员工维度收口,返回 `objectType = 'STAFF_ID_CARD'``objectKey = 员工身份证号`,与结果表唯一键保持一致。
- 各规则口径落地如下:
- `LOW_INCOME_RELATIVE_LARGE_TRANSACTION`:基于关系人年收入折算月收入,筛出无收入或月收入低于 3000 元且累计交易超 10 万元的员工。
- `MULTI_PARTY_GAMBLING_TRANSFER`:按员工同日交易聚合,要求金额落在配置区间、交易笔数超过 2 笔且对手方至少 2 个。
- `MONTHLY_FIXED_INCOME`:统计近 12 个月非工资稳定转入,满足月收入阈值、命中月数不少于 6 个月且波动率受控。
- `FIXED_COUNTERPARTY_TRANSFER`:统计近 12 个月固定对手方按季度转入,季度金额落在配置区间且持续出现。
- `SUPPLIER_CONCENTRATION`:基于采购交易表计算员工负责采购中单一供应商占比是否超过 70%。
- `SALARY_QUICK_TRANSFER`:识别工资入账后 24 小时内快速转出超过 80% 的员工。
- `SALARY_UNUSED`:识别工资入账后剔除代扣项目,连续 30 天无消费或转账支出的员工。
## 元数据脚本调整
-`sql/2026-03-16-bank-tagging.sql` 中将第二期已落地规则的 `remark` 从占位描述更新为真实规则说明。
- `FIXED_COUNTERPARTY_TRANSFER.indicator_code` 调整为 `NULL`,避免继续保留不准确的单指标编码。
- 修正 `SALARY_UNUSED` 的业务口径文本乱码问题。
- 同步将 `TAX_ASSET_REGISTRATION_MISMATCH` 的业务口径更新为当前真实 SQL 已执行的房产登记校验表述。
## 与第一期衔接关系
- 第一期已完成的 9 条规则保持不变,本次不回退第一期参数映射、明细规则与对象规则实现。
- 第二期实现后,当前 19 条已落地真实规则共用同一套任务执行、结果落表、风险人数刷新与概览统计链路。
- 本次回归继续覆盖项目概览相关测试,确认第二期改动未影响已有风险概览接口结构与 SQL。
## 全量收口结论
- 第二期 10 条规则已全部替换为真实后端实现,不再依赖 `where 1 = 0` 占位 SQL。
- 第二期规则参数编码、规则编码、脚本元数据均保持全大写约定。
- 当前银行流水真实规则第一期与第二期后端范围已全部接入现有项目级打标主链路。

View File

@@ -0,0 +1,33 @@
# 第二期银行流水规则元数据修复验证记录
## 执行命令
```bash
mvn test -pl ccdi-project -Dtest=CcdiBankTagRuleSqlMetadataTest
bin/mysql_utf8_exec.sh sql/migration/2026-03-20-sync-bank-tag-phase2-rule-metadata.sql
python3 - <<'PY'
# 查询 ccdi_bank_tag_rule 第二期 10 条规则的 indicator_code、business_caliber、remark
PY
mvn test -pl ccdi-project -Dtest=CcdiBankTagRuleSqlMetadataTest,CcdiBankTagAnalysisMapperXmlTest,BankTagRuleConfigResolverTest,CcdiBankTagServiceImplTest,CcdiBankTagServiceRiskCountRefreshTest,CcdiProjectOverviewServiceStructureTest,CcdiProjectOverviewMapperSqlTest,CcdiProjectOverviewServiceImplTest,CcdiProjectOverviewControllerTest
```
## 执行时间
- 2026-03-20 16:29 执行 `mvn test -pl ccdi-project -Dtest=CcdiBankTagRuleSqlMetadataTest` 红灯验证,确认第二期迁移脚本缺失,测试报 `NoSuchFileException`
- 2026-03-20 16:30 重新执行 `mvn test -pl ccdi-project -Dtest=CcdiBankTagRuleSqlMetadataTest`,结果 `BUILD SUCCESS``Tests run: 2, Failures: 0, Errors: 0, Skipped: 0`
- 2026-03-20 16:30 执行 `bin/mysql_utf8_exec.sh sql/migration/2026-03-20-sync-bank-tag-phase2-rule-metadata.sql`,脚本落库成功,无报错、无乱码输出。
- 2026-03-20 16:30 查询 `ccdi_bank_tag_rule` 第二期 10 条规则元数据,确认数据库已与真实规则状态对齐。
- 2026-03-20 16:30 执行最终回归命令 `mvn test -pl ccdi-project -Dtest=CcdiBankTagRuleSqlMetadataTest,CcdiBankTagAnalysisMapperXmlTest,BankTagRuleConfigResolverTest,CcdiBankTagServiceImplTest,CcdiBankTagServiceRiskCountRefreshTest,CcdiProjectOverviewServiceStructureTest,CcdiProjectOverviewMapperSqlTest,CcdiProjectOverviewServiceImplTest,CcdiProjectOverviewControllerTest`,结果 `BUILD SUCCESS``Tests run: 49, Failures: 0, Errors: 0, Skipped: 0`,完成时间 `2026-03-20T16:30:53+08:00`
## 结果摘要
- 已补第二期元数据迁移脚本与 SQL 校验测试。
- 修复后已确认:
- 第二期 10 条规则 `remark` 均已变为真实规则说明
- `FIXED_COUNTERPARTY_TRANSFER.indicator_code` 已变为 `NULL`
- `SALARY_UNUSED.business_caliber` 乱码已修复为“工资发放后除代扣项目外连续30天无消费或转账支出记录。”
- `TAX_ASSET_REGISTRATION_MISMATCH.business_caliber` 已更新为“员工及关系人有5000元以上的纳税记录但当前资产登记口径下无房产登记。”
- `MONTHLY_FIXED_INCOME.indicator_code` 继续保持 `MONTHLY_FIXED_INCOME`
- 测试日志中的 `threshold missing``refresh failed` 为既有异常路径断言产生的预期日志,不代表最终回归失败。
## 结论
- 第二期规则元数据修复已完成,仓库脚本与当前数据库均已对齐到真实规则状态。
- 当前验证仅执行 Maven 单元测试与数据库只读复核,未启动额外前后端进程,因此无需执行进程清理。

View File

@@ -0,0 +1,27 @@
# 银行流水真实规则第二期后端验证记录
## 执行命令
```bash
mvn test -pl ccdi-project -Dtest=BankTagRuleConfigResolverTest
mvn test -pl ccdi-project -Dtest=CcdiModelParamSqlDefaultsTest
mvn test -pl ccdi-project -Dtest=CcdiBankTagAnalysisMapperXmlTest
mvn test -pl ccdi-project -Dtest=CcdiBankTagAnalysisMapperXmlTest,CcdiBankTagServiceImplTest
mvn test -pl ccdi-project -Dtest=CcdiBankTagAnalysisMapperXmlTest,BankTagRuleConfigResolverTest,CcdiBankTagServiceImplTest,CcdiBankTagServiceRiskCountRefreshTest,CcdiProjectOverviewServiceStructureTest,CcdiProjectOverviewMapperSqlTest,CcdiProjectOverviewServiceImplTest,CcdiProjectOverviewControllerTest
```
## 执行时间
- 2026-03-20 14:59 重新执行最终回归命令 `mvn test -pl ccdi-project -Dtest=CcdiBankTagAnalysisMapperXmlTest,BankTagRuleConfigResolverTest,CcdiBankTagServiceImplTest,CcdiBankTagServiceRiskCountRefreshTest,CcdiProjectOverviewServiceStructureTest,CcdiProjectOverviewMapperSqlTest,CcdiProjectOverviewServiceImplTest,CcdiProjectOverviewControllerTest`47 个测试全部通过0 failure0 error。
- 本轮最终回归 `BUILD SUCCESS`Maven 输出完成时间为 `2026-03-20T14:59:17+08:00`
## 结果摘要
- 第二期 10 条规则已全部通过结构测试或分发测试校验,不再保留占位 SQL。
- `BankTagRuleConfigResolverTest` 已覆盖第二期 3 条阈值规则与 7 条无参规则的参数解析行为。
- `CcdiBankTagAnalysisMapperXmlTest` 已确认第二期 3 条资产比对规则和 7 条对象规则均输出真实查询字段,剩余占位规则数量按预期下降。
- `CcdiBankTagServiceImplTest` 已确认第二期阈值型对象规则参数成功接入服务分发链路,无参规则继续走空参数配置。
- 项目概览相关 4 组测试继续通过,说明第二期规则接入未破坏风险人数刷新、概览 SQL 与概览接口结构。
- 测试日志中出现的 `threshold missing``refresh failed` 为断言异常场景的预期输出,不代表本轮回归失败。
## 结论
- 第二期后端实现已完成10 条规则均已接入现有项目级流水打标主链路。
- 当前验证范围为 Maven 单元测试与 SQL 结构测试,未启动额外前后端进程,因此无需执行进程清理。

View File

@@ -80,27 +80,27 @@ VALUES
('LARGE_TRANSACTION', '大额交易', 'FREQUENT_CASH_DEPOSIT', '短时间多次存现', 'FREQUENT_CASH_DEPOSIT', 'OBJECT', 'HIGH', '识别短时间多次现金存入对象', 1, 70, 'system', '初始化规则'), ('LARGE_TRANSACTION', '大额交易', 'FREQUENT_CASH_DEPOSIT', '短时间多次存现', 'FREQUENT_CASH_DEPOSIT', 'OBJECT', 'HIGH', '识别短时间多次现金存入对象', 1, 70, 'system', '初始化规则'),
('LARGE_TRANSACTION', '大额交易', 'LARGE_TRANSFER', '大额转账交易', 'FREQUENT_TRANSFER', 'STATEMENT', 'HIGH', '识别大额转账流水', 1, 80, 'system', '初始化规则'), ('LARGE_TRANSACTION', '大额交易', 'LARGE_TRANSFER', '大额转账交易', 'FREQUENT_TRANSFER', 'STATEMENT', 'HIGH', '识别大额转账流水', 1, 80, 'system', '初始化规则'),
('ABNORMAL_TRANSACTION', '异常交易', 'ABNORMAL_CUSTOMER_TRANSACTION', '与客户之间非正常资金往来', NULL, 'STATEMENT', 'HIGH', '员工及关系人与客户及关系人之间有超过1000元以上的资金往来客户指信贷类客户包括贷款户、担保人中介库人员包括中介注册的主体及主体关系人。', 1, 10, 'system', '占位规则待补充真实SQL'), ('ABNORMAL_TRANSACTION', '异常交易', 'ABNORMAL_CUSTOMER_TRANSACTION', '与客户之间非正常资金往来', NULL, 'STATEMENT', 'HIGH', '员工及关系人与客户及关系人之间有超过1000元以上的资金往来客户指信贷类客户包括贷款户、担保人中介库人员包括中介注册的主体及主体关系人。', 1, 10, 'system', '占位规则待补充真实SQL'),
('ABNORMAL_TRANSACTION', '异常交易', 'LOW_INCOME_RELATIVE_LARGE_TRANSACTION', '低收入亲属大额交易', NULL, 'OBJECT', 'GENERAL', '关系人中没有收入或月收入低于3000元的人员累计交易金额超过10万元。', 1, 20, 'system', '占位规则待补充真实SQL'), ('ABNORMAL_TRANSACTION', '异常交易', 'LOW_INCOME_RELATIVE_LARGE_TRANSACTION', '低收入亲属大额交易', NULL, 'OBJECT', 'GENERAL', '关系人中没有收入或月收入低于3000元的人员累计交易金额超过10万元。', 1, 20, 'system', '真实规则识别低收入关系人累计交易超10万元的员工对象'),
('SUSPICIOUS_GAMBLING', '疑似赌博', 'MULTI_PARTY_GAMBLING_TRANSFER', '疑似赌博交易', NULL, 'OBJECT', 'HIGH', '多人2人及以上、多次2次以上、相近时间同一天有转账、微信转账、支付宝转账发生且额度在可疑区间。金额区间可在排查设置页面进行设置', 1, 10, 'system', '占位规则待补充真实SQL'), ('SUSPICIOUS_GAMBLING', '疑似赌博', 'MULTI_PARTY_GAMBLING_TRANSFER', '疑似赌博交易', NULL, 'OBJECT', 'HIGH', '多人2人及以上、多次2次以上、相近时间同一天有转账、微信转账、支付宝转账发生且额度在可疑区间。金额区间可在排查设置页面进行设置', 1, 10, 'system', '真实规则:识别同日多对手方且金额落在可疑区间的疑似赌博对象'),
('SUSPICIOUS_GAMBLING', '疑似赌博', 'GAMBLING_SENSITIVE_KEYWORD', '疑似敏感交易', NULL, 'STATEMENT', 'HIGH', '备注或交易摘要、对手有“游戏、抖币、体彩、福彩”等字眼。', 1, 20, 'system', '真实规则:识别摘要或对手方命中赌博敏感词的支出流水'), ('SUSPICIOUS_GAMBLING', '疑似赌博', 'GAMBLING_SENSITIVE_KEYWORD', '疑似敏感交易', NULL, 'STATEMENT', 'HIGH', '备注或交易摘要、对手有“游戏、抖币、体彩、福彩”等字眼。', 1, 20, 'system', '真实规则:识别摘要或对手方命中赌博敏感词的支出流水'),
('SUSPICIOUS_RELATION', '可疑关系', 'SPECIAL_AMOUNT_TRANSACTION', '特殊金额交易', NULL, 'STATEMENT', NULL, '除与配偶、子女外发生特殊金额交易如1314元、520元等具有特殊含义的金额。', 1, 10, 'system', '真实规则:识别与非配偶子女发生的特殊金额交易'), ('SUSPICIOUS_RELATION', '可疑关系', 'SPECIAL_AMOUNT_TRANSACTION', '特殊金额交易', NULL, 'STATEMENT', NULL, '除与配偶、子女外发生特殊金额交易如1314元、520元等具有特殊含义的金额。', 1, 10, 'system', '真实规则:识别与非配偶子女发生的特殊金额交易'),
('SUSPICIOUS_PART_TIME', '可疑兼职', 'MONTHLY_FIXED_INCOME', '疑似兼职', 'MONTHLY_FIXED_INCOME', 'OBJECT', NULL, '除本行工资收入外,每月有固定收入,固定收入金额自行设置。', 1, 10, 'system', '占位规则待补充真实SQL'), ('SUSPICIOUS_PART_TIME', '可疑兼职', 'MONTHLY_FIXED_INCOME', '疑似兼职', 'MONTHLY_FIXED_INCOME', 'OBJECT', NULL, '除本行工资收入外,每月有固定收入,固定收入金额自行设置。', 1, 10, 'system', '真实规则识别近12个月持续出现稳定月度非工资收入的员工对象'),
('SUSPICIOUS_PART_TIME', '可疑兼职', 'FIXED_COUNTERPARTY_TRANSFER', '疑似兼职', 'FIXED_COUNTERPARTY_TRANSFER', 'OBJECT', NULL, '每季或每年从固定交易对手转入金额金额可设区间值如5000-10000。', 1, 20, 'system', '占位规则待补充真实SQL'), ('SUSPICIOUS_PART_TIME', '可疑兼职', 'FIXED_COUNTERPARTY_TRANSFER', '疑似兼职', NULL, 'OBJECT', NULL, '每季或每年从固定交易对手转入金额金额可设区间值如5000-10000。', 1, 20, 'system', '真实规则:识别固定交易对手季度转入金额落在设定区间的员工对象'),
('SUSPICIOUS_PART_TIME', '可疑兼职', 'SUSPICIOUS_INCOME_KEYWORD', '疑似兼职', NULL, 'STATEMENT', 'HIGH', '转入资金摘要有“工资”、“分红”、“红利”、“利息(非银行结息)”等收入', 1, 30, 'system', '真实规则:识别非本行工资代发的收入关键词转入流水'), ('SUSPICIOUS_PART_TIME', '可疑兼职', 'SUSPICIOUS_INCOME_KEYWORD', '疑似兼职', NULL, 'STATEMENT', 'HIGH', '转入资金摘要有“工资”、“分红”、“红利”、“利息(非银行结息)”等收入', 1, 30, 'system', '真实规则:识别非本行工资代发的收入关键词转入流水'),
('SUSPICIOUS_PROPERTY', '可疑财产', 'HOUSE_REGISTRATION_MISMATCH', '购房交易与房产登记不匹配', NULL, 'STATEMENT', NULL, '员工及关系人有购房交易,但名下房产无新增登记;有新增登记购房,但无相关购房交易记录。', 1, 10, 'system', '占位规则待补充真实SQL'), ('SUSPICIOUS_PROPERTY', '可疑财产', 'HOUSE_REGISTRATION_MISMATCH', '购房交易与房产登记不匹配', NULL, 'STATEMENT', NULL, '员工及关系人有购房交易,但名下房产无新增登记;有新增登记购房,但无相关购房交易记录。', 1, 10, 'system', '真实规则:识别购房支出但当前房产登记口径缺失的流水'),
('SUSPICIOUS_PROPERTY', '可疑财产', 'PROPERTY_FEE_REGISTRATION_MISMATCH', '物业缴费与房产登记不匹配', NULL, 'STATEMENT', NULL, '员工及关系人有物业缴费记录,但名下房产无新增登记。', 1, 20, 'system', '占位规则待补充真实SQL'), ('SUSPICIOUS_PROPERTY', '可疑财产', 'PROPERTY_FEE_REGISTRATION_MISMATCH', '物业缴费与房产登记不匹配', NULL, 'STATEMENT', NULL, '员工及关系人有物业缴费记录,但名下房产无新增登记。', 1, 20, 'system', '真实规则:识别物业缴费但当前房产登记口径缺失的流水'),
('SUSPICIOUS_PROPERTY', '可疑财产', 'TAX_ASSET_REGISTRATION_MISMATCH', '大额纳税与资产登记不匹配', NULL, 'STATEMENT', NULL, '员工及关系人有5000元以上的纳税记录名下无房产车产新增登记。', 1, 30, 'system', '占位规则待补充真实SQL'), ('SUSPICIOUS_PROPERTY', '可疑财产', 'TAX_ASSET_REGISTRATION_MISMATCH', '大额纳税与资产登记不匹配', NULL, 'STATEMENT', NULL, '员工及关系人有5000元以上的纳税记录当前资产登记口径下无房产登记。', 1, 30, 'system', '真实规则:识别大额纳税但当前房产登记口径缺失的流水'),
('SUSPICIOUS_PROPERTY', '可疑财产', 'INCOME_ASSET_MISMATCH', '收入资产不符', NULL, 'STATEMENT', 'HIGH', '豪华房产价值超家庭年收入10倍', 1, 40, 'system', '占位规则待补充真实SQL'), ('SUSPICIOUS_PROPERTY', '可疑财产', 'INCOME_ASSET_MISMATCH', '收入资产不符', NULL, 'STATEMENT', 'HIGH', '豪华房产价值超家庭年收入10倍', 1, 40, 'system', '占位规则待补充真实SQL'),
('SUSPICIOUS_FOREIGN_EXCHANGE', '可疑外汇交易', 'FOREX_BUY_AMT', '可疑外汇交易', 'SINGLE_PURCHASE_AMOUNT', 'STATEMENT', NULL, '单笔购汇金额超限', 1, 10, 'system', '真实规则:识别单笔购汇金额超过阈值的流水'), ('SUSPICIOUS_FOREIGN_EXCHANGE', '可疑外汇交易', 'FOREX_BUY_AMT', '可疑外汇交易', 'SINGLE_PURCHASE_AMOUNT', 'STATEMENT', NULL, '单笔购汇金额超限', 1, 10, 'system', '真实规则:识别单笔购汇金额超过阈值的流水'),
('SUSPICIOUS_FOREIGN_EXCHANGE', '可疑外汇交易', 'FOREX_SELL_AMT', '可疑外汇交易', 'SINGLE_SETTLEMENT_AMOUNT', 'STATEMENT', NULL, '单笔结汇金额超限', 1, 20, 'system', '真实规则:识别单笔结汇金额超过阈值的流水'), ('SUSPICIOUS_FOREIGN_EXCHANGE', '可疑外汇交易', 'FOREX_SELL_AMT', '可疑外汇交易', 'SINGLE_SETTLEMENT_AMOUNT', 'STATEMENT', NULL, '单笔结汇金额超限', 1, 20, 'system', '真实规则:识别单笔结汇金额超过阈值的流水'),
('SUSPICIOUS_FOREIGN_EXCHANGE', '可疑外汇交易', 'CROSS_BORDER_AMT', '可疑外汇交易', 'CROSS_BORDER_AMT', 'STATEMENT', NULL, '单笔跨境汇款金额超限', 1, 30, 'system', '占位规则待补充真实SQL'), ('SUSPICIOUS_FOREIGN_EXCHANGE', '可疑外汇交易', 'CROSS_BORDER_AMT', '可疑外汇交易', 'CROSS_BORDER_AMT', 'STATEMENT', NULL, '单笔跨境汇款金额超限', 1, 30, 'system', '占位规则待补充真实SQL'),
('SUSPICIOUS_INTEREST_PAYMENT', '可疑付息', 'INTEREST_PAYMENT_BY_OTHERS', '可疑付息', NULL, 'OBJECT', 'HIGH', '客户经理管户的客户在智柜、柜面连续代交利息且代交人数超过2人。', 1, 10, 'system', '占位规则待补充真实SQL'), ('SUSPICIOUS_INTEREST_PAYMENT', '可疑付息', 'INTEREST_PAYMENT_BY_OTHERS', '可疑付息', NULL, 'OBJECT', 'HIGH', '客户经理管户的客户在智柜、柜面连续代交利息且代交人数超过2人。', 1, 10, 'system', '占位规则待补充真实SQL'),
('SUSPICIOUS_PURCHASE', '可疑采购', 'LARGE_PURCHASE_TRANSACTION', '可疑采购', NULL, 'STATEMENT', NULL, '单笔采购金额超过10万元。', 1, 10, 'system', '真实规则识别单笔采购金额超过10万元的采购事项'), ('SUSPICIOUS_PURCHASE', '可疑采购', 'LARGE_PURCHASE_TRANSACTION', '可疑采购', NULL, 'STATEMENT', NULL, '单笔采购金额超过10万元。', 1, 10, 'system', '真实规则识别单笔采购金额超过10万元的采购事项'),
('SUSPICIOUS_PURCHASE', '可疑采购', 'SUPPLIER_CONCENTRATION', '可疑采购', NULL, 'OBJECT', NULL, '单个供应商采购额占总采购额比例超过70%。', 1, 20, 'system', '占位规则待补充真实SQL'), ('SUSPICIOUS_PURCHASE', '可疑采购', 'SUPPLIER_CONCENTRATION', '可疑采购', NULL, 'OBJECT', NULL, '单个供应商采购额占总采购额比例超过70%。', 1, 20, 'system', '真实规则识别单个供应商采购额占比超过70%的员工对象'),
('ABNORMAL_BEHAVIOR', '异常行为', 'STOCK_TFR_LARGE', '可疑银证大额转账', 'STOCK_TFR_LARGE', 'STATEMENT', NULL, '家庭老人/非关系人银证大额转账', 1, 10, 'system', '真实规则:识别银证转账金额超过阈值的流水'), ('ABNORMAL_BEHAVIOR', '异常行为', 'STOCK_TFR_LARGE', '可疑银证大额转账', 'STOCK_TFR_LARGE', 'STATEMENT', NULL, '家庭老人/非关系人银证大额转账', 1, 10, 'system', '真实规则:识别银证转账金额超过阈值的流水'),
('ABNORMAL_BEHAVIOR', '异常行为', 'WITHDRAW_CNT', '微信支付宝频繁提现', 'WITHDRAW_CNT', 'OBJECT', NULL, '微信、支付宝单日提现次数超过设置次数', 1, 20, 'system', '真实规则:识别微信支付宝单日提现次数超过阈值的对象'), ('ABNORMAL_BEHAVIOR', '异常行为', 'WITHDRAW_CNT', '微信支付宝频繁提现', 'WITHDRAW_CNT', 'OBJECT', NULL, '微信、支付宝单日提现次数超过设置次数', 1, 20, 'system', '真实规则:识别微信支付宝单日提现次数超过阈值的对象'),
('ABNORMAL_BEHAVIOR', '异常行为', 'WITHDRAW_AMT', '微信支付宝提现超额', 'WITHDRAW_AMT', 'OBJECT', NULL, '微信、支付宝单日累计提现金额超过限额', 1, 30, 'system', '占位规则待补充真实SQL'), ('ABNORMAL_BEHAVIOR', '异常行为', 'WITHDRAW_AMT', '微信支付宝提现超额', 'WITHDRAW_AMT', 'OBJECT', NULL, '微信、支付宝单日累计提现金额超过限额', 1, 30, 'system', '占位规则待补充真实SQL'),
('ABNORMAL_BEHAVIOR', '异常行为', 'SALARY_QUICK_TRANSFER', '工资快速转出', NULL, 'OBJECT', NULL, '工资发放后24小时内转出超过80%的资金', 1, 40, 'system', '占位规则待补充真实SQL'), ('ABNORMAL_BEHAVIOR', '异常行为', 'SALARY_QUICK_TRANSFER', '工资快速转出', NULL, 'OBJECT', NULL, '工资发放后24小时内转出超过80%的资金', 1, 40, 'system', '真实规则识别工资入账24小时内快速转出的员工对象'),
('ABNORMAL_BEHAVIOR', '异常行为', 'SALARY_UNUSED', '工资无使用记录', NULL, 'OBJECT', NULL, '工资发放后除代扣项目外连续30天犖奕魏蜗鸦蜃思锹肌', 1, 50, 'system', '占位规则待补充真实SQL'), ('ABNORMAL_BEHAVIOR', '异常行为', 'SALARY_UNUSED', '工资无使用记录', NULL, 'OBJECT', NULL, '工资发放后除代扣项目外连续30天无消费或转账支出记录。', 1, 50, 'system', '真实规则识别工资入账后30天内无消费或转账支出的员工对象'),
('ABNORMAL_BEHAVIOR', '异常行为', 'LARGE_STOCK_TRADING', '大额炒股', 'STOCK_TFR_LARGE', 'STATEMENT', 'HIGH', '单次三方资管交易金额超过100万元。', 1, 60, 'system', '真实规则:识别单笔三方资管交易金额超过阈值的流水'), ('ABNORMAL_BEHAVIOR', '异常行为', 'LARGE_STOCK_TRADING', '大额炒股', 'STOCK_TFR_LARGE', 'STATEMENT', 'HIGH', '单次三方资管交易金额超过100万元。', 1, 60, 'system', '真实规则:识别单笔三方资管交易金额超过阈值的流水'),
('ABNORMAL_BEHAVIOR', '异常行为', 'PROXY_ACCOUNT_OPERATION', '疑似代理他人账户', NULL, 'OBJECT', NULL, NULL, 1, 70, 'system', '占位规则待补充真实SQL'); ('ABNORMAL_BEHAVIOR', '异常行为', 'PROXY_ACCOUNT_OPERATION', '疑似代理他人账户', NULL, 'OBJECT', NULL, NULL, 1, 70, 'system', '占位规则待补充真实SQL');

View File

@@ -0,0 +1,41 @@
START TRANSACTION;
INSERT INTO ccdi_bank_tag_rule (
model_code,
model_name,
rule_code,
rule_name,
indicator_code,
result_type,
risk_level,
business_caliber,
enabled,
sort_order,
create_by,
remark
) VALUES
('ABNORMAL_TRANSACTION', '异常交易', 'LOW_INCOME_RELATIVE_LARGE_TRANSACTION', '低收入亲属大额交易', NULL, 'OBJECT', 'GENERAL', '关系人中没有收入或月收入低于3000元的人员累计交易金额超过10万元。', 1, 20, 'system', '真实规则识别低收入关系人累计交易超10万元的员工对象'),
('SUSPICIOUS_GAMBLING', '疑似赌博', 'MULTI_PARTY_GAMBLING_TRANSFER', '疑似赌博交易', NULL, 'OBJECT', 'HIGH', '多人2人及以上、多次2次以上、相近时间同一天有转账、微信转账、支付宝转账发生且额度在可疑区间。金额区间可在排查设置页面进行设置', 1, 10, 'system', '真实规则:识别同日多对手方且金额落在可疑区间的疑似赌博对象'),
('SUSPICIOUS_PART_TIME', '可疑兼职', 'MONTHLY_FIXED_INCOME', '疑似兼职', 'MONTHLY_FIXED_INCOME', 'OBJECT', NULL, '除本行工资收入外,每月有固定收入,固定收入金额自行设置。', 1, 10, 'system', '真实规则识别近12个月持续出现稳定月度非工资收入的员工对象'),
('SUSPICIOUS_PART_TIME', '可疑兼职', 'FIXED_COUNTERPARTY_TRANSFER', '疑似兼职', NULL, 'OBJECT', NULL, '每季或每年从固定交易对手转入金额金额可设区间值如5000-10000。', 1, 20, 'system', '真实规则:识别固定交易对手季度转入金额落在设定区间的员工对象'),
('SUSPICIOUS_PROPERTY', '可疑财产', 'HOUSE_REGISTRATION_MISMATCH', '购房交易与房产登记不匹配', NULL, 'STATEMENT', NULL, '员工及关系人有购房交易,但名下房产无新增登记;有新增登记购房,但无相关购房交易记录。', 1, 10, 'system', '真实规则:识别购房支出但当前房产登记口径缺失的流水'),
('SUSPICIOUS_PROPERTY', '可疑财产', 'PROPERTY_FEE_REGISTRATION_MISMATCH', '物业缴费与房产登记不匹配', NULL, 'STATEMENT', NULL, '员工及关系人有物业缴费记录,但名下房产无新增登记。', 1, 20, 'system', '真实规则:识别物业缴费但当前房产登记口径缺失的流水'),
('SUSPICIOUS_PROPERTY', '可疑财产', 'TAX_ASSET_REGISTRATION_MISMATCH', '大额纳税与资产登记不匹配', NULL, 'STATEMENT', NULL, '员工及关系人有5000元以上的纳税记录但当前资产登记口径下无房产登记。', 1, 30, 'system', '真实规则:识别大额纳税但当前房产登记口径缺失的流水'),
('SUSPICIOUS_PURCHASE', '可疑采购', 'SUPPLIER_CONCENTRATION', '可疑采购', NULL, 'OBJECT', NULL, '单个供应商采购额占总采购额比例超过70%。', 1, 20, 'system', '真实规则识别单个供应商采购额占比超过70%的员工对象'),
('ABNORMAL_BEHAVIOR', '异常行为', 'SALARY_QUICK_TRANSFER', '工资快速转出', NULL, 'OBJECT', NULL, '工资发放后24小时内转出超过80%的资金。', 1, 40, 'system', '真实规则识别工资入账24小时内快速转出的员工对象'),
('ABNORMAL_BEHAVIOR', '异常行为', 'SALARY_UNUSED', '工资无使用记录', NULL, 'OBJECT', NULL, '工资发放后除代扣项目外连续30天无消费或转账支出记录。', 1, 50, 'system', '真实规则识别工资入账后30天内无消费或转账支出的员工对象')
ON DUPLICATE KEY UPDATE
model_code = VALUES(model_code),
model_name = VALUES(model_name),
rule_name = VALUES(rule_name),
indicator_code = VALUES(indicator_code),
result_type = VALUES(result_type),
risk_level = VALUES(risk_level),
business_caliber = VALUES(business_caliber),
enabled = VALUES(enabled),
sort_order = VALUES(sort_order),
update_by = 'system',
update_time = NOW(),
remark = VALUES(remark);
COMMIT;