Compare commits

...

34 Commits

Author SHA1 Message Date
wkc
666253eb41 Merge remote-tracking branch 'origin/dev' into dev 2026-03-17 10:35:04 +08:00
wkc
7d7cfa813b feat: 接入流水标签自动触发并完成验证 2026-03-16 18:25:10 +08:00
wkc
147f234109 feat: 实现流水标签重算服务与协调器 2026-03-16 18:24:27 +08:00
wkc
b948c846b1 feat: 补齐流水标签规则分析SQL 2026-03-16 18:23:45 +08:00
wkc
1a49b6b7e1 feat: 新增流水标签规则结果任务Mapper 2026-03-16 17:27:58 +08:00
wkc
a01dd8dec3 feat: 新增流水标签核心表结构与实体映射 2026-03-16 17:27:29 +08:00
wkc
7b2f2e36c0 test: 补充流水标签重算接口契约 2026-03-16 17:26:53 +08:00
wkc
7fed9927b3 更新大额交易口径与本地流水分析配置 2026-03-16 16:52:34 +08:00
wkc
e1ee681550 修复lsfx删除文件接口logIds解析异常 2026-03-16 16:25:01 +08:00
wkc
7ae3fde8ef fix: 修复项目详情页模块加载错误 2026-03-16 15:47:18 +08:00
wkc
9f88b92985 数据: 生成项目40大额交易测试流水并完成验证 2026-03-16 15:28:45 +08:00
wkc
c5935b58e8 文档: 新增项目40大额交易测试数据设计与实施计划 2026-03-16 15:19:39 +08:00
wkc
37cdf5b399 feat: 完成上传文件删除前端实现 2026-03-16 15:14:54 +08:00
wkc
8f9cb19c9f Merge branch 'codex/project-upload-file-delete-backend' into dev 2026-03-16 14:57:21 +08:00
wkc
0c2d738cfe fix: 调整 NAS 部署配置与脚本清理逻辑 2026-03-16 14:54:42 +08:00
wkc
939f33f81b 测试: 完成上传文件删除后端回归验证 2026-03-16 14:45:37 +08:00
wkc
e7ed2197e6 功能: 扩展上传文件统计支持已删除状态 2026-03-16 14:42:59 +08:00
wkc
7a5ae3f1cc feat: 优化模型参数页底部保存栏悬浮效果 2026-03-16 14:40:38 +08:00
wkc
275fc7a264 测试: 补齐上传文件删除校验与失败保护 2026-03-16 14:38:26 +08:00
wkc
9179e15682 功能: 打通上传文件删除成功主链路 2026-03-16 14:36:27 +08:00
wkc
35781660b4 测试: 补充上传文件删除接口控制器契约 2026-03-16 14:33:38 +08:00
wkc
863700a143 docs: 新增上传文件列表删除设计 2026-03-16 14:21:38 +08:00
wkc
2bc2e87125 test: 完成模型参数前后端联调验收 2026-03-16 14:08:59 +08:00
wkc
1fb632f386 test: 记录模型参数前端动态展示验证 2026-03-16 14:08:35 +08:00
wkc
e749c61549 feat: 优化项目模型参数页动态展示 2026-03-16 13:42:21 +08:00
wkc
608f5b4488 feat: 优化全局模型参数页动态展示 2026-03-16 13:42:00 +08:00
wkc
a40c44f8b8 Align backend model params CSV 2026-03-16 13:35:40 +08:00
wkc
b9c14f9d94 test: 完成模型参数后端接口回归验证 2026-03-16 11:34:59 +08:00
wkc
7cb210e427 test: 记录模型默认参数后端对齐验证 2026-03-16 11:10:46 +08:00
wkc
5739a7bac0 refactor: 收敛模型参数服务对齐逻辑 2026-03-16 11:03:19 +08:00
wkc
7a3838d00a fix: 稳定模型参数查询顺序 2026-03-16 10:54:10 +08:00
wkc
6a3542660b feat: 新增模型默认参数升级脚本 2026-03-16 10:54:01 +08:00
wkc
71b85280a9 feat: 对齐模型默认参数初始化脚本 2026-03-16 10:53:47 +08:00
wkc
568157f720 docs: 新增模型默认参数CSV对齐设计文档 2026-03-16 10:45:06 +08:00
76 changed files with 6936 additions and 1342 deletions

View File

@@ -4,6 +4,6 @@ BACKEND_PORT=62318
LSFX_MOCK_PORT=62320
# Spring Boot 运行配置
SPRING_PROFILES_ACTIVE=local
SPRING_PROFILES_ACTIVE=nas
RUOYI_PROFILE=/app/data/ruoyi
JAVA_OPTS=-Xms512m -Xmx1024m

View File

@@ -0,0 +1,326 @@
-- 项目40大额交易测试流水初始化脚本
SET NAMES utf8mb4;
-- 依赖默认参数:
-- SINGLE_TRANSACTION_AMOUNT = 100000
-- CUMULATIVE_TRANSACTION_AMOUNT = 50000001
-- annual_turnover = 50000001
-- LARGE_CASH_DEPOSIT = 2000001
-- FREQUENT_CASH_DEPOSIT = 5
-- FREQUENT_TRANSFER = 100001
DELETE FROM ccdi_bank_statement
WHERE project_id = 40;
INSERT INTO ccdi_bank_statement (
project_id, LE_ID, ACCOUNT_ID, group_id,
LE_ACCOUNT_NAME, LE_ACCOUNT_NO, ACCOUNTING_DATE_ID, ACCOUNTING_DATE,
TRX_DATE, CURRENCY, AMOUNT_DR, AMOUNT_CR, AMOUNT_BALANCE,
CASH_TYPE, CUSTOMER_LE_ID, CUSTOMER_ACCOUNT_NAME, CUSTOMER_ACCOUNT_NO,
customer_bank, customer_reference, USER_MEMO, BANK_COMMENTS,
BANK_TRX_NUMBER, BANK, TRX_FLAG, TRX_TYPE, EXCEPTION_TYPE,
internal_flag, batch_id, batch_sequence, CREATE_DATE, created_by,
meta_json, no_balance, begin_balance, end_balance,
override_bs_id, payment_method, cret_no
) VALUES
(40, 0, 4000101, 40, '模型测试员工', '6222024000000001', 400001, '2026-03-01', '2026-03-01 10:12:00', 'CNY', 680000.00, 0.00, 5420000.00, '对公转账', 0, '杭州贝壳房地产经纪有限公司', '913301001234000001', '中国银行杭州分行', '', '购买房产首付款', '购买房产首付款', 'P40-0001', 'BOC', '0', 0, '', 0, 40001, 1, '2026-03-16 16:00:00', 1, NULL, 0, 0, 0, 0, NULL, '330101198801010011'),
(40, 0, 4000102, 40, '模型测试家属', '6222024000000002', 400002, '2026-03-02', '2026-03-02 11:05:00', 'CNY', 258000.00, 0.00, 3165000.00, '对私转账', 0, '兰溪星耀汽车销售服务有限公司', '913307811234000002', '工商银行兰溪支行', '', '购车首付款', '购车首付款', 'P40-0002', 'ICBC', '0', 0, '', 0, 40001, 2, '2026-03-16 16:00:00', 1, NULL, 0, 0, 0, 0, NULL, '330101199001010022'),
(40, 0, 4000103, 40, '模型二测试员工', '6222024000000003', 400003, '2026-03-03', '2026-03-03 09:15:00', 'CNY', 52000.00, 0.00, 4680000.00, '税费缴纳', 0, '国家金库兰溪支库', 'TG000000000000003', '中国建设银行兰溪支行', '', '个人所得税税款', '个人所得税税款', 'P40-0003', 'CCB', '0', 0, '', 0, 40001, 3, '2026-03-16 16:00:00', 1, NULL, 0, 0, 0, 0, NULL, '330101198802020033'),
(40, 0, 4000104, 40, '模型二测试家属', '6222024000000004', 400004, '2026-03-04', '2026-03-04 14:40:00', 'CNY', 18800.00, 0.00, 2230000.00, '税费缴纳', 0, '兰溪市税务局第一税务分局', 'TG000000000000004', '农业银行兰溪支行', '', '房产税务缴税', '房产税务缴税', 'P40-0004', 'ABC', '0', 0, '', 0, 40001, 4, '2026-03-16 16:00:00', 1, NULL, 0, 0, 0, 0, NULL, '330101199202020044'),
(40, 0, 4000101, 40, '模型测试员工', '6222024000000001', 400005, '2026-03-05', '2026-03-05 13:25:00', 'CNY', 0.00, 188000.00, 5608000.00, '来账', 0, '杭州启明咨询有限公司', '913301001234000005', '招商银行杭州分行', '', '项目合作分成', '项目合作分成', 'P40-0005', 'CMB', '0', 0, '', 0, 40001, 5, '2026-03-16 16:00:00', 1, NULL, 0, 0, 0, 0, NULL, '330101198801010011'),
(40, 0, 4000103, 40, '模型二测试员工', '6222024000000003', 400006, '2025-11-18', '2025-11-18 10:08:00', 'CNY', 0.00, 20500000.00, 25180000.00, '来账', 0, '浙江远望贸易有限公司', '913300001234000006', '交通银行杭州分行', '', '经营往来收入', '经营往来收入', 'P40-0006', 'BCM', '0', 0, '', 0, 40001, 6, '2026-03-16 16:00:00', 1, NULL, 0, 0, 0, 0, NULL, '330101198802020033'),
(40, 0, 4000103, 40, '模型二测试员工', '6222024000000003', 400007, '2026-01-15', '2026-01-15 15:18:00', 'CNY', 0.00, 20000000.00, 45180000.00, '来账', 0, '浙江远望贸易有限公司', '913300001234000006', '交通银行杭州分行', '', '经营往来收入', '经营往来收入', 'P40-0007', 'BCM', '0', 0, '', 0, 40001, 7, '2026-03-16 16:00:00', 1, NULL, 0, 0, 0, 0, NULL, '330101198802020033'),
(40, 0, 4000103, 40, '模型二测试员工', '6222024000000003', 400008, '2026-02-20', '2026-02-20 16:22:00', 'CNY', 0.00, 19800000.00, 64980000.00, '来账', 0, '浙江远望贸易有限公司', '913300001234000006', '交通银行杭州分行', '', '经营往来收入', '经营往来收入', 'P40-0008', 'BCM', '0', 0, '', 0, 40001, 8, '2026-03-16 16:00:00', 1, NULL, 0, 0, 0, 0, NULL, '330101198802020033'),
(40, 0, 4000103, 40, '模型二测试员工', '6222024000000003', 400009, '2026-02-25', '2026-02-25 11:41:00', 'CNY', 8000000.00, 0.00, 56980000.00, '对公转账', 0, '浙江腾越供应链有限公司', '913300001234000009', '中国银行义乌支行', '', '材料采购转账', '材料采购转账', 'P40-0009', 'BOC', '0', 0, '', 0, 40001, 9, '2026-03-16 16:00:00', 1, NULL, 0, 0, 0, 0, NULL, '330101198802020033'),
(40, 0, 4000103, 40, '模型二测试员工', '6222024000000003', 400010, '2026-03-06', '2026-03-06 12:05:00', 'CNY', 6000000.00, 0.00, 50980000.00, '对公转账', 0, '杭州诚誉科技有限公司', '913301001234000010', '宁波银行杭州分行', '', '服务采购转账', '服务采购转账', 'P40-0010', 'NBCB', '0', 0, '', 0, 40001, 10, '2026-03-16 16:00:00', 1, NULL, 0, 0, 0, 0, NULL, '330101198802020033'),
(40, 0, 4000101, 40, '模型测试员工', '6222024000000001', 400011, '2026-03-10', '2026-03-10 09:00:00', 'CNY', 0.00, 2500000.00, 7598000.00, '现金存款', 0, '', '', '兰溪农商行营业部', '', '柜面现金存款', '柜面现金存款', 'P40-0011', 'LXNRCB', '0', 0, '', 0, 40001, 11, '2026-03-16 16:00:00', 1, NULL, 0, 0, 0, 0, NULL, '330101198801010011'),
(40, 0, 4000101, 40, '模型测试员工', '6222024000000001', 400012, '2026-03-10', '2026-03-10 09:20:00', 'CNY', 0.00, 2100000.00, 9698000.00, '现金存款', 0, '', '', '兰溪农商行营业部', '', 'ATM现金存款', 'ATM现金存款', 'P40-0012', 'LXNRCB', '0', 0, '', 0, 40001, 12, '2026-03-16 16:00:00', 1, NULL, 0, 0, 0, 0, NULL, '330101198801010011'),
(40, 0, 4000101, 40, '模型测试员工', '6222024000000001', 400013, '2026-03-10', '2026-03-10 10:05:00', 'CNY', 0.00, 2200000.00, 11898000.00, '现金存款', 0, '', '', '兰溪农商行营业部', '', '自助存款现金存入', '自助存款现金存入', 'P40-0013', 'LXNRCB', '0', 0, '', 0, 40001, 13, '2026-03-16 16:00:00', 1, NULL, 0, 0, 0, 0, NULL, '330101198801010011'),
(40, 0, 4000101, 40, '模型测试员工', '6222024000000001', 400014, '2026-03-10', '2026-03-10 11:16:00', 'CNY', 0.00, 2300000.00, 14198000.00, '现金存款', 0, '', '', '兰溪农商行营业部', '', '柜面现金存款', '柜面现金存款', 'P40-0014', 'LXNRCB', '0', 0, '', 0, 40001, 14, '2026-03-16 16:00:00', 1, NULL, 0, 0, 0, 0, NULL, '330101198801010011'),
(40, 0, 4000101, 40, '模型测试员工', '6222024000000001', 400015, '2026-03-10', '2026-03-10 13:02:00', 'CNY', 0.00, 2400000.00, 16598000.00, '现金存款', 0, '', '', '兰溪农商行营业部', '', 'CRS存款', 'CRS存款', 'P40-0015', 'LXNRCB', '0', 0, '', 0, 40001, 15, '2026-03-16 16:00:00', 1, NULL, 0, 0, 0, 0, NULL, '330101198801010011'),
(40, 0, 4000101, 40, '模型测试员工', '6222024000000001', 400016, '2026-03-10', '2026-03-10 15:08:00', 'CNY', 0.00, 2350000.00, 18948000.00, '现金存款', 0, '', '', '兰溪农商行营业部', '', '本行ATM存款', '本行ATM存款', 'P40-0016', 'LXNRCB', '0', 0, '', 0, 40001, 16, '2026-03-16 16:00:00', 1, NULL, 0, 0, 0, 0, NULL, '330101198801010011'),
(40, 0, 4000103, 40, '模型二测试员工', '6222024000000003', 400017, '2026-03-11', '2026-03-11 10:33:00', 'CNY', 360000.00, 0.00, 50620000.00, '转账', 0, '杭州弘信商贸有限公司', '913301001234000017', '浦发银行杭州分行', '', '手机银行转账', '手机银行转账', 'P40-0017', 'SPDB', '0', 0, '', 0, 40001, 17, '2026-03-16 16:00:00', 1, NULL, 0, 0, 0, 0, NULL, '330101198802020033'),
(40, 0, 4000101, 40, '模型测试员工', '6222024000000001', 400018, '2026-03-12', '2026-03-12 08:30:00', 'CNY', 0.00, 15000.00, 18963000.00, '代发工资', 0, '浙江兰溪农村商业银行股份有限公司', 'PAYROLL0000000018', '兰溪农商行营业部', '', '代发工资', '代发工资', 'P40-0018', 'LXNRCB', '0', 0, '', 0, 40001, 18, '2026-03-16 16:00:00', 1, NULL, 0, 0, 0, 0, NULL, '330101198801010011'),
(40, 0, 4000101, 40, '模型测试员工', '6222024000000001', 400019, '2026-03-12', '2026-03-12 18:15:00', 'CNY', 368.00, 0.00, 18962632.00, '消费', 0, '兰溪市银泰超市', 'SHOP000000000019', '工商银行兰溪支行', '', '超市消费', '超市消费', 'P40-0019', 'ICBC', '0', 0, '', 0, 40001, 19, '2026-03-16 16:00:00', 1, NULL, 0, 0, 0, 0, NULL, '330101198801010011'),
(40, 0, 4000102, 40, '模型测试家属', '6222024000000002', 400020, '2026-03-13', '2026-03-13 19:10:00', 'CNY', 220.00, 0.00, 3164780.00, '生活缴费', 0, '兰溪市供电公司', 'UTIL000000000020', '建设银行兰溪支行', '', '水电费', '水电费', 'P40-0020', 'CCB', '0', 0, '', 0, 40001, 20, '2026-03-16 16:00:00', 1, NULL, 0, 0, 0, 0, NULL, '330101199001010022'),
(40, 0, 4000103, 40, '模型二测试员工', '6222024000000003', 400021, '2026-03-14', '2026-03-14 09:55:00', 'CNY', 0.00, 500000.00, 51120000.00, '内部划转', 0, '模型二测试员工', 'SELF000000000021', '兰溪农商行营业部', '', '本人账户划转', '本人账户划转', 'P40-0021', 'LXNRCB', '0', 0, '', 0, 40001, 21, '2026-03-16 16:00:00', 1, NULL, 0, 0, 0, 0, NULL, '330101198802020033');
SELECT 'project_40_total' AS check_name, COUNT(*) AS hit_count
FROM ccdi_bank_statement
WHERE project_id = 40;
SELECT 'house_or_car_expense' AS check_name, COUNT(*) AS hit_count
FROM (
SELECT t2.bank_statement_id
FROM ccdi_base_staff t1
INNER JOIN ccdi_bank_statement t2 ON t1.id_card = t2.cret_no
WHERE t2.project_id = 40
AND (
t2.user_memo LIKE '%购%房%'
OR t2.user_memo LIKE '%买%房%'
OR t2.user_memo LIKE '%购%车%'
OR t2.user_memo LIKE '%买%车%'
OR t2.user_memo LIKE '%车款%'
OR t2.user_memo LIKE '%房款%'
OR t2.user_memo LIKE '%首付%'
OR t2.user_memo LIKE '%房贷%'
OR t2.user_memo LIKE '%车贷%'
OR t2.customer_account_name LIKE '%汽车销售%'
OR t2.customer_account_name LIKE '%汽车金融%'
OR t2.customer_account_name LIKE '%4S店%'
OR t2.customer_account_name LIKE '%汽贸%'
OR t2.customer_account_name LIKE '%车行%'
OR t2.customer_account_name LIKE '%房地产%'
OR t2.customer_account_name LIKE '%置业%'
OR t2.customer_account_name LIKE '%置地%'
OR t2.customer_account_name LIKE '%地产%'
OR t2.customer_account_name LIKE '%房产%'
OR t2.customer_account_name LIKE '%不动产%'
OR t2.customer_account_name LIKE '%链家%'
OR t2.customer_account_name LIKE '%贝壳%'
OR t2.customer_account_name LIKE '%我爱我家%'
OR t2.customer_account_name LIKE '%房管局%'
)
AND t2.amount_dr > 0
UNION ALL
SELECT t2.bank_statement_id
FROM ccdi_staff_fmy_relation t1
INNER JOIN ccdi_bank_statement t2 ON t1.relation_cert_no = t2.cret_no
WHERE t1.status = 1
AND t2.project_id = 40
AND (
t2.user_memo LIKE '%购%房%'
OR t2.user_memo LIKE '%买%房%'
OR t2.user_memo LIKE '%购%车%'
OR t2.user_memo LIKE '%买%车%'
OR t2.user_memo LIKE '%车款%'
OR t2.user_memo LIKE '%房款%'
OR t2.user_memo LIKE '%首付%'
OR t2.user_memo LIKE '%房贷%'
OR t2.user_memo LIKE '%车贷%'
OR t2.customer_account_name LIKE '%汽车销售%'
OR t2.customer_account_name LIKE '%汽车金融%'
OR t2.customer_account_name LIKE '%4S店%'
OR t2.customer_account_name LIKE '%汽贸%'
OR t2.customer_account_name LIKE '%车行%'
OR t2.customer_account_name LIKE '%房地产%'
OR t2.customer_account_name LIKE '%置业%'
OR t2.customer_account_name LIKE '%置地%'
OR t2.customer_account_name LIKE '%地产%'
OR t2.customer_account_name LIKE '%房产%'
OR t2.customer_account_name LIKE '%不动产%'
OR t2.customer_account_name LIKE '%链家%'
OR t2.customer_account_name LIKE '%贝壳%'
OR t2.customer_account_name LIKE '%我爱我家%'
OR t2.customer_account_name LIKE '%房管局%'
)
AND t2.amount_dr > 0
) s;
SELECT 'tax_expense' AS check_name, COUNT(*) AS hit_count
FROM (
SELECT t2.bank_statement_id
FROM ccdi_base_staff t1
INNER JOIN ccdi_bank_statement t2 ON t1.id_card = t2.cret_no
WHERE t2.project_id = 40
AND (
t2.user_memo LIKE '%税务%'
OR t2.user_memo LIKE '%缴税%'
OR t2.user_memo LIKE '%税款%'
OR t2.customer_account_name LIKE '%税务%'
OR t2.customer_account_name LIKE '%税务局%'
OR t2.customer_account_name LIKE '%国库%'
OR t2.customer_account_name LIKE '%国家金库%'
OR t2.customer_account_name LIKE '%财政%'
)
AND t2.amount_dr > 0
UNION ALL
SELECT t2.bank_statement_id
FROM ccdi_staff_fmy_relation t1
INNER JOIN ccdi_bank_statement t2 ON t1.relation_cert_no = t2.cret_no
WHERE t1.status = 1
AND t2.project_id = 40
AND (
t2.user_memo LIKE '%税务%'
OR t2.user_memo LIKE '%缴税%'
OR t2.user_memo LIKE '%税款%'
OR t2.customer_account_name LIKE '%税务%'
OR t2.customer_account_name LIKE '%税务局%'
OR t2.customer_account_name LIKE '%国库%'
OR t2.customer_account_name LIKE '%国家金库%'
OR t2.customer_account_name LIKE '%财政%'
)
AND t2.amount_dr > 0
) s;
SELECT 'single_large_income' AS check_name, COUNT(*) AS hit_count
FROM (
SELECT t2.bank_statement_id
FROM ccdi_base_staff t1
INNER JOIN ccdi_bank_statement t2 ON t1.id_card = t2.cret_no
LEFT JOIN ccdi_staff_fmy_relation t3
ON t1.id_card = t3.person_id
AND t2.customer_account_name = t3.relation_name
WHERE t2.project_id = 40
AND t2.le_account_name <> t2.customer_account_name
AND NOT (
t2.customer_account_name = '浙江兰溪农村商业银行股份有限公司'
AND (
t2.user_memo LIKE '%代发%'
OR t2.user_memo LIKE '%工资%'
OR t2.user_memo LIKE '%奖金%'
OR t2.user_memo LIKE '%薪酬%'
OR t2.user_memo LIKE '%薪金%'
OR t2.user_memo LIKE '%补贴%'
OR t2.user_memo LIKE '%薪%'
OR t2.user_memo LIKE '%年终奖%'
OR t2.user_memo LIKE '%年金%'
OR t2.user_memo LIKE '%加班费%'
OR t2.user_memo LIKE '%劳务费%'
OR t2.user_memo LIKE '%劳务外包%'
OR t2.user_memo LIKE '%提成%'
OR t2.user_memo LIKE '%劳务派遣%'
OR t2.user_memo LIKE '%绩效%'
OR t2.user_memo LIKE '%酬劳%'
OR t2.cash_type LIKE '%代发%'
OR t2.cash_type LIKE '%工资%'
OR t2.cash_type LIKE '%劳务费%'
)
)
AND t2.amount_cr > 100000
AND t3.person_id IS NULL
) s;
SELECT 'cumulative_large_income' AS check_name, COUNT(*) AS hit_count
FROM (
SELECT t1.id_card, t2.customer_account_name, SUM(t2.amount_cr) AS sum_amount_cr
FROM ccdi_base_staff t1
INNER JOIN ccdi_bank_statement t2 ON t1.id_card = t2.cret_no
LEFT JOIN ccdi_staff_fmy_relation t3
ON t1.id_card = t3.person_id
AND t2.customer_account_name = t3.relation_name
WHERE t2.project_id = 40
AND t2.le_account_name <> t2.customer_account_name
AND NOT (
t2.customer_account_name = '浙江兰溪农村商业银行股份有限公司'
AND (
t2.user_memo LIKE '%代发%'
OR t2.user_memo LIKE '%工资%'
OR t2.cash_type LIKE '%代发%'
OR t2.cash_type LIKE '%工资%'
)
)
AND t2.amount_cr > 0
AND t3.person_id IS NULL
GROUP BY t1.id_card, t2.customer_account_name
HAVING SUM(t2.amount_cr) > 50000001
) s;
SELECT 'annual_turnover' AS check_name, COUNT(*) AS hit_count
FROM (
SELECT t1.id_card, SUM(t2.amount_dr + t2.amount_cr) AS annual_trans_amount
FROM ccdi_base_staff t1
INNER JOIN ccdi_bank_statement t2 ON t1.id_card = t2.cret_no
WHERE t2.project_id = 40
AND STR_TO_DATE(LEFT(t2.TRX_DATE, 10), '%Y-%m-%d') >= DATE_SUB(CURDATE(), INTERVAL 12 MONTH)
AND t2.le_account_name <> t2.customer_account_name
GROUP BY t1.id_card
HAVING SUM(t2.amount_dr + t2.amount_cr) > 50000001
) s;
SELECT 'large_cash_deposit' AS check_name, COUNT(*) AS hit_count
FROM (
SELECT t2.bank_statement_id
FROM ccdi_base_staff t1
INNER JOIN ccdi_bank_statement t2 ON t1.id_card = t2.cret_no
WHERE t2.project_id = 40
AND t2.amount_cr > 2000001
AND (
(
(
(t2.user_memo LIKE '%现金%' AND t2.user_memo NOT LIKE '%金管理%' AND t2.user_memo NOT LIKE '%金添利%' AND t2.user_memo NOT LIKE '%现金利%' AND t2.user_memo NOT LIKE '%现金宝%' AND t2.user_memo NOT LIKE '%金分析%')
OR t2.user_memo LIKE '%存现%'
OR t2.user_memo LIKE '%现存%'
OR t2.cash_type LIKE '%现金%'
OR t2.cash_type LIKE '%存现%'
OR t2.cash_type LIKE '%现存%'
OR t2.cash_type LIKE '%金存入%'
OR t2.user_memo LIKE '%金存入%'
OR (t2.user_memo LIKE '%ATM%' AND (t2.user_memo LIKE '%存款%' OR t2.user_memo LIKE '%转入%'))
OR (t2.cash_type LIKE '%ATM%' AND (t2.cash_type LIKE '%存款%' OR t2.cash_type LIKE '%转入%'))
)
AND (t2.customer_account_name = '' OR t2.customer_account_name = '' OR t2.customer_account_name LIKE '%存现%')
)
OR t2.user_memo LIKE '%DEPOSIT%'
OR (
t2.customer_account_name = '库存现金'
OR (((t2.user_memo LIKE '%现金存款%' OR t2.user_memo LIKE '%自助存款%' OR t2.user_memo LIKE '%CRS存款%' OR t2.cash_type LIKE '%现金存款%' OR t2.cash_type LIKE '%自助存款%' OR t2.cash_type LIKE '%本行CRS存款%' OR t2.cash_type LIKE '%柜面%' OR t2.user_memo LIKE '%柜面%') AND t2.customer_account_name = ''))
OR (t2.customer_account_name = '现金' AND t2.user_memo NOT LIKE '%借款%')
OR t2.user_memo LIKE '%本行ATM%'
)
)
) s;
SELECT 'frequent_cash_deposit' AS check_name, COUNT(*) AS hit_count
FROM (
SELECT t1.id_card, LEFT(t2.trx_date, 10) AS cash_trans_date, COUNT(1) AS cash_count
FROM ccdi_base_staff t1
INNER JOIN ccdi_bank_statement t2 ON t1.id_card = t2.cret_no
WHERE t2.project_id = 40
AND t2.amount_cr > 2000001
AND (
(
(
(t2.user_memo LIKE '%现金%' AND t2.user_memo NOT LIKE '%金管理%' AND t2.user_memo NOT LIKE '%金添利%' AND t2.user_memo NOT LIKE '%现金利%' AND t2.user_memo NOT LIKE '%现金宝%' AND t2.user_memo NOT LIKE '%金分析%')
OR t2.user_memo LIKE '%存现%'
OR t2.user_memo LIKE '%现存%'
OR t2.cash_type LIKE '%现金%'
OR t2.cash_type LIKE '%存现%'
OR t2.cash_type LIKE '%现存%'
OR t2.cash_type LIKE '%金存入%'
OR t2.user_memo LIKE '%金存入%'
OR (t2.user_memo LIKE '%ATM%' AND (t2.user_memo LIKE '%存款%' OR t2.user_memo LIKE '%转入%'))
OR (t2.cash_type LIKE '%ATM%' AND (t2.cash_type LIKE '%存款%' OR t2.cash_type LIKE '%转入%'))
)
AND (t2.customer_account_name = '' OR t2.customer_account_name = '' OR t2.customer_account_name LIKE '%存现%')
)
OR t2.user_memo LIKE '%DEPOSIT%'
OR (
t2.customer_account_name = '库存现金'
OR (((t2.user_memo LIKE '%现金存款%' OR t2.user_memo LIKE '%自助存款%' OR t2.user_memo LIKE '%CRS存款%' OR t2.cash_type LIKE '%现金存款%' OR t2.cash_type LIKE '%自助存款%' OR t2.cash_type LIKE '%本行CRS存款%' OR t2.cash_type LIKE '%柜面%' OR t2.user_memo LIKE '%柜面%') AND t2.customer_account_name = ''))
OR (t2.customer_account_name = '现金' AND t2.user_memo NOT LIKE '%借款%')
OR t2.user_memo LIKE '%本行ATM%'
)
)
GROUP BY t1.id_card, LEFT(t2.trx_date, 10)
HAVING COUNT(1) > 5
) s;
SELECT 'large_transfer' AS check_name, COUNT(*) AS hit_count
FROM (
SELECT t2.bank_statement_id
FROM ccdi_base_staff t1
INNER JOIN ccdi_bank_statement t2 ON t1.id_card = t2.cret_no
WHERE t2.project_id = 40
AND t2.amount_dr > 100001
AND (
t2.customer_account_name LIKE '%转账%'
OR t2.user_memo LIKE '%转帐%'
OR t2.user_memo LIKE '%转账%'
OR t2.user_memo LIKE '%汇入%'
OR t2.user_memo LIKE '%转存%'
OR t2.user_memo LIKE '%红包%'
OR t2.user_memo LIKE '%汇款%'
OR t2.user_memo LIKE '%网转%'
OR t2.user_memo LIKE '%转入%'
OR t2.cash_type LIKE '%转帐%'
OR t2.cash_type LIKE '%转账%'
OR t2.cash_type LIKE '%汇入%'
OR t2.cash_type LIKE '%转存%'
OR t2.cash_type LIKE '%红包%'
OR t2.cash_type LIKE '%汇款%'
OR t2.cash_type LIKE '%网转%'
OR t2.cash_type LIKE '%转入%'
)
AND t2.user_memo NOT LIKE '%款%'
AND t2.le_account_name <> t2.customer_account_name
) s;

View File

@@ -1,996 +0,0 @@
可疑行为排查模型,,,,,,,,,
序号,模型名称,描述,业务口径,代码,,,,,
1,大额交易,"关注账户(包括本人、亲属、注册主体等账户),房、车采购等大额消费,异常纳税支出等。
除工资收入外的大额流入,大额的额度可在排查参数输入页面进行设置
修改默认限额,且年流水交易额超过默认限额。
大额存现或短时间多次存现
大额转账或频繁转账,大额的定义数字可在排查参数输入页面进行设置","1.备注或对交易对手是房产公司、二手房、车辆销售公司、物业公司等。
2.有税务支出记录
3.同一交易对手(除家庭成员外、本单位代发工资)单笔超过 设置限额或累计交易金额超过 设置限额的资金流入;
4.年流水交易额超过  设置限额;
5.大额存现或短时间多次存现,单笔超过 设置限额;
6.大额转账或频繁转账,单笔超过 设置限额。","---员工及其亲属购买车房支出金额
select id_card
,sum(amount_dr) as amount_dr
from
(
select t1.id_card
,amount_dr
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and (user_memo rlike '(购|买).*房|(购|买).*车|车款|房款|首付|(房|车).*贷'
or customer_account_name rlike '汽车销售|汽车金融|4S店|汽贸|车行|房地产|置业|置地|地产|房产|不动产|链家|贝壳|我爱我家|房管局')
and amount_dr > 0
union all
select t1.person_id
,amount_dr
from
ccdi_staff_fmy_relation t1
inner join
ccdi_bank_statement t2
on t1.relation_cert_no = t2.cret_no
where t1.status = 1
and t2.project_id = PROJECT_ID
and (user_memo rlike '(购|买).*房|(购|买).*车|车款|房款|首付|(房|车).*贷'
or customer_account_name rlike '汽车销售|汽车金融|4S店|汽贸|车行|房地产|置业|置地|地产|房产|不动产|链家|贝壳|我爱我家|房管局')
and amount_dr > 0
)
group by id_card;
----员工及其亲属税务支出金额
select id_card
,sum(amount_dr) as amount_dr
from
(
select t1.id_card
,amount_dr
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and (user_memo rlike '税务|缴税|税款'
or customer_account_name rlike '税务|税务局|国库|国家金库|财政')
and amount_dr > 0
union all
select t1.person_id
,amount_dr
from
ccdi_staff_fmy_relation t1
inner join
ccdi_bank_statement t2
on t1.relation_cert_no = t2.cret_no
where t1.status = 1
and t2.project_id = PROJECT_ID
and (user_memo rlike '税务|缴税|税款'
or customer_account_name rlike '税务|税务局|国库|国家金库|财政')
and amount_dr > 0
)
group by id_card;
--员工与同一交易对手(非亲属)的最大一笔收入交易金额
select id_card
,max(max_amount_cr) as max_amount_cr
from
(
select
t1.id_card
,t1.customer_account_name
,t1.max_amount_cr
from
(
select t1.id_card
,customer_account_name
,max(amount_cr) as max_amount_cr
from ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and t2.le_account_name <> t2.customer_account_name
and customer_account_name not like '%代发%'
and customer_account_name not like '%工资%'
and user_memo not like '%代发%'
and user_memo not like '%工资%'
and user_memo not like '%奖金%'
and user_memo not like '%薪酬%'
and user_memo not like '%薪金%'
and user_memo not like '%补贴%'
and user_memo not like '%薪%'
and user_memo not like '%年终奖%'
and user_memo not like '%年金%'
and user_memo not like '%加班费%'
and user_memo not like '%劳务费%'
and user_memo not like '%劳务外包%'
and user_memo not like '%提成%'
and user_memo not like '%劳务派遣%'
and user_memo not like '%绩效%'
and user_memo not like '%酬劳%'
and user_memo not like '%PAYROLL%'
and user_memo not like '%SALA%'
and user_memo not like '%CPF%'
and user_memo not like '%directors%fees%'
and user_memo not like '%批量代付%'
and cash_type not like '%代发%'
and cash_type not like '%工资%'
and cash_type not like '%劳务费%'
and amount_cr > 0
group by id_card,customer_account_name
) t1
left join ccdi_staff_fmy_relation t2
on t1.id_card = t2.person_id
and t1.customer_account_name = t2.relation_name
where t2.person_id is null;
) group by id_card;
--员工与同一交易对手(非亲属)的累计收入交易金额
select
t1.id_card
,t1.customer_account_name
,t1.amount_cr
from
(
select t1.id_card
,customer_account_name
,sum(amount_cr) as amount_cr
from ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and t2.le_account_name <> t2.customer_account_name
and customer_account_name not like '%代发%'
and customer_account_name not like '%工资%'
and user_memo not like '%代发%'
and user_memo not like '%工资%'
and user_memo not like '%奖金%'
and user_memo not like '%薪酬%'
and user_memo not like '%薪金%'
and user_memo not like '%补贴%'
and user_memo not like '%薪%'
and user_memo not like '%年终奖%'
and user_memo not like '%年金%'
and user_memo not like '%加班费%'
and user_memo not like '%劳务费%'
and user_memo not like '%劳务外包%'
and user_memo not like '%提成%'
and user_memo not like '%劳务派遣%'
and user_memo not like '%绩效%'
and user_memo not like '%酬劳%'
and user_memo not like '%PAYROLL%'
and user_memo not like '%SALA%'
and user_memo not like '%CPF%'
and user_memo not like '%directors%fees%'
and user_memo not like '%批量代付%'
and cash_type not like '%代发%'
and cash_type not like '%工资%'
and cash_type not like '%劳务费%'
group by id_card,customer_account_name
having sum(amount_cr)>0
) t1
left join ccdi_staff_fmy_relation t2
on t1.id_card = t2.person_id
and t1.customer_account_name = t2.relation_name
where t2.person_id is null;
--员工及其亲属 年交易金额
select id_card
,sum(trans_amount) as trans_amount
from
(
select t1.id_card
,amount_dr + amount_cr as trans_amount
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and TRX_DATE >= $$$ --近一年
and t2.le_account_name <> t2.customer_account_name --排除同名交易
union all
select t1.person_id
,amount_dr + amount_cr as trans_amount
from
ccdi_staff_fmy_relation t1
inner join
ccdi_bank_statement t2
on t1.relation_cert_no = t2.cret_no
where t1.status = 1
and t2.project_id = PROJECT_ID
and TRX_DATE >= $$$ --近一年
and t2.le_account_name <> t2.customer_account_name --排除同名交易
)
group by id_card;
---员工及其亲属 最大一笔存现单笔金额
select id_card
,max(amount_cr) as amount_cr
FROM
(
select t1.id_card
,max(amount_cr) as amount_cr
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and amount_cr>0
and (
(((user_memo like '%现金%' and user_memo not like '%金管理%' and user_memo not like '%金添利%' and user_memo not like '%现金利%' and user_memo not like '%现金宝%' and user_memo not like '%金分析%' ) or user_memo like '%存现%' or user_memo like '%现存%' or cash_type like '%现金%' or cash_type like '%存现%' or cash_type like '%现存%' or cash_type like '%金存入%' or user_memo like '%金存入%' or (user_memo like '%ATM%' and (user_memo like '%存款%' or user_memo like '%转入%')) or (cash_type like '%ATM%' and (cash_type like '%存款%' or cash_type like '%转入%'))) and (customer_account_name = '' or customer_account_name = '无' or customer_account_name like '%存现%') or user_memo like '%DEPOSIT%') or
((customer_account_name = '库存现金' or ((user_memo like '%现金存款%' or user_memo like '%自助存款%' or user_memo like '%CRS存款%' or cash_type like '%现金存款%' or cash_type like '%自助存款%' or cash_type like '%本行CRS存款%' or cash_type like '%柜面%' or user_memo like '%柜面%') and customer_account_name = '' )) or (customer_account_name = '现金' and user_memo not like '%借款%') or user_memo like '%本行ATM%')
)
group by t1.id_card
union all
select t1.person_id
,max(amount_cr) as amount_cr
from
ccdi_staff_fmy_relation t1
inner join
ccdi_bank_statement t2
on t1.relation_cert_no = t2.cret_no
where t1.status = 1
and t2.project_id = PROJECT_ID
and amount_cr>0
and (
(((user_memo like '%现金%' and user_memo not like '%金管理%' and user_memo not like '%金添利%' and user_memo not like '%现金利%' and user_memo not like '%现金宝%' and user_memo not like '%金分析%' ) or user_memo like '%存现%' or user_memo like '%现存%' or cash_type like '%现金%' or cash_type like '%存现%' or cash_type like '%现存%' or cash_type like '%金存入%' or user_memo like '%金存入%' or (user_memo like '%ATM%' and (user_memo like '%存款%' or user_memo like '%转入%')) or (cash_type like '%ATM%' and (cash_type like '%存款%' or cash_type like '%转入%'))) and (customer_account_name = '' or customer_account_name = '无' or customer_account_name like '%存现%') or user_memo like '%DEPOSIT%') or
((customer_account_name = '库存现金' or ((user_memo like '%现金存款%' or user_memo like '%自助存款%' or user_memo like '%CRS存款%' or cash_type like '%现金存款%' or cash_type like '%自助存款%' or cash_type like '%本行CRS存款%' or cash_type like '%柜面%' or user_memo like '%柜面%') and customer_account_name = '' )) or (customer_account_name = '现金' and user_memo not like '%借款%') or user_memo like '%本行ATM%')
)
group by t1.person_id
)
group by id_card
;
--员工及其亲属 存现总金额
select id_card,sum(amount_cr) as amount_cr
from
(
select t1.id_card
,amount_cr
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and amount_cr>0
and (
(((user_memo like '%现金%' and user_memo not like '%金管理%' and user_memo not like '%金添利%' and user_memo not like '%现金利%' and user_memo not like '%现金宝%' and user_memo not like '%金分析%' ) or user_memo like '%存现%' or user_memo like '%现存%' or cash_type like '%现金%' or cash_type like '%存现%' or cash_type like '%现存%' or cash_type like '%金存入%' or user_memo like '%金存入%' or (user_memo like '%ATM%' and (user_memo like '%存款%' or user_memo like '%转入%')) or (cash_type like '%ATM%' and (cash_type like '%存款%' or cash_type like '%转入%'))) and (customer_account_name = '' or customer_account_name = '无' or customer_account_name like '%存现%') or user_memo like '%DEPOSIT%') or
((customer_account_name = '库存现金' or ((user_memo like '%现金存款%' or user_memo like '%自助存款%' or user_memo like '%CRS存款%' or cash_type like '%现金存款%' or cash_type like '%自助存款%' or cash_type like '%本行CRS存款%' or cash_type like '%柜面%' or user_memo like '%柜面%') and customer_account_name = '' )) or (customer_account_name = '现金' and user_memo not like '%借款%') or user_memo like '%本行ATM%')
)
union all
select t1.person_id
,amount_cr
from
ccdi_staff_fmy_relation t1
inner join
ccdi_bank_statement t2
on t1.relation_cert_no = t2.cret_no
where t1.status = 1
and t2.project_id = PROJECT_ID
and amount_cr>0
and (
(((user_memo like '%现金%' and user_memo not like '%金管理%' and user_memo not like '%金添利%' and user_memo not like '%现金利%' and user_memo not like '%现金宝%' and user_memo not like '%金分析%' ) or user_memo like '%存现%' or user_memo like '%现存%' or cash_type like '%现金%' or cash_type like '%存现%' or cash_type like '%现存%' or cash_type like '%金存入%' or user_memo like '%金存入%' or (user_memo like '%ATM%' and (user_memo like '%存款%' or user_memo like '%转入%')) or (cash_type like '%ATM%' and (cash_type like '%存款%' or cash_type like '%转入%'))) and (customer_account_name = '' or customer_account_name = '无' or customer_account_name like '%存现%') or user_memo like '%DEPOSIT%') or
((customer_account_name = '库存现金' or ((user_memo like '%现金存款%' or user_memo like '%自助存款%' or user_memo like '%CRS存款%' or cash_type like '%现金存款%' or cash_type like '%自助存款%' or cash_type like '%本行CRS存款%' or cash_type like '%柜面%' or user_memo like '%柜面%') and customer_account_name = '' )) or (customer_account_name = '现金' and user_memo not like '%借款%') or user_memo like '%本行ATM%')
)
)group by id_card
;
--员工及其亲属 大额现金存入次数
select id_card,count(1)
from
(
select t1.id_card
,amount_cr
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and amount_cr> $$$$$$
and (
(((user_memo like '%现金%' and user_memo not like '%金管理%' and user_memo not like '%金添利%' and user_memo not like '%现金利%' and user_memo not like '%现金宝%' and user_memo not like '%金分析%' ) or user_memo like '%存现%' or user_memo like '%现存%' or cash_type like '%现金%' or cash_type like '%存现%' or cash_type like '%现存%' or cash_type like '%金存入%' or user_memo like '%金存入%' or (user_memo like '%ATM%' and (user_memo like '%存款%' or user_memo like '%转入%')) or (cash_type like '%ATM%' and (cash_type like '%存款%' or cash_type like '%转入%'))) and (customer_account_name = '' or customer_account_name = '无' or customer_account_name like '%存现%') or user_memo like '%DEPOSIT%') or
((customer_account_name = '库存现金' or ((user_memo like '%现金存款%' or user_memo like '%自助存款%' or user_memo like '%CRS存款%' or cash_type like '%现金存款%' or cash_type like '%自助存款%' or cash_type like '%本行CRS存款%' or cash_type like '%柜面%' or user_memo like '%柜面%') and customer_account_name = '' )) or (customer_account_name = '现金' and user_memo not like '%借款%') or user_memo like '%本行ATM%')
)
union all
select t1.person_id
,amount_cr
from
ccdi_staff_fmy_relation t1
inner join
ccdi_bank_statement t2
on t1.relation_cert_no = t2.cret_no
where t1.status = 1
and t2.project_id = PROJECT_ID
and amount_cr> $$$$$$$
and (
(((user_memo like '%现金%' and user_memo not like '%金管理%' and user_memo not like '%金添利%' and user_memo not like '%现金利%' and user_memo not like '%现金宝%' and user_memo not like '%金分析%' ) or user_memo like '%存现%' or user_memo like '%现存%' or cash_type like '%现金%' or cash_type like '%存现%' or cash_type like '%现存%' or cash_type like '%金存入%' or user_memo like '%金存入%' or (user_memo like '%ATM%' and (user_memo like '%存款%' or user_memo like '%转入%')) or (cash_type like '%ATM%' and (cash_type like '%存款%' or cash_type like '%转入%'))) and (customer_account_name = '' or customer_account_name = '无' or customer_account_name like '%存现%') or user_memo like '%DEPOSIT%') or
((customer_account_name = '库存现金' or ((user_memo like '%现金存款%' or user_memo like '%自助存款%' or user_memo like '%CRS存款%' or cash_type like '%现金存款%' or cash_type like '%自助存款%' or cash_type like '%本行CRS存款%' or cash_type like '%柜面%' or user_memo like '%柜面%') and customer_account_name = '' )) or (customer_account_name = '现金' and user_memo not like '%借款%') or user_memo like '%本行ATM%')
)
)group by id_card
;
--员工及其亲属 大额转账次数
select id_card,count(1)
from
(
select t1.id_card
,amount_dr
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and amount_dr> $$$$$$
and (customer_account_name rlike '转账' or user_memo rlike '转帐|转账|汇入|转存|红包|汇款|网转|转入' or cash_type rlike '转帐|转账|汇入|转存|红包|汇款|网转|转入')
and user_memo not like '%款%'
and t2.le_account_name <> t2.customer_account_name --排除同名交易
union all
select t1.person_id
,amount_dr
from
ccdi_staff_fmy_relation t1
inner join
ccdi_bank_statement t2
on t1.relation_cert_no = t2.cret_no
where t1.status = 1
and t2.project_id = PROJECT_ID
and amount_dr> $$$$$$
and (customer_account_name rlike '转账' or user_memo rlike '转帐|转账|汇入|转存|红包|汇款|网转|转入' or cash_type rlike '转帐|转账|汇入|转存|红包|汇款|网转|转入')
and user_memo not like '%款%'
and t2.le_account_name <> t2.customer_account_name --排除同名交易
)
group by id_card
;",,,,,
2,异常交易,"员工及关系人与客户之间非正常资金往来
关系人中没有收入或低收入人员有大额资金交易","1.员工及关系人与客户及关系人之间有超过1000元以上的资金往来客户指信贷类客户包括贷款户、担保人中介库人员包括中介注册的主体及主体关系人。
2.关系人中没有收入或月收入低于 3000元 的人员单笔或累计交易金额超过 10万元。","--员工及其亲属与贷款客户、担保户、中介有异常交易
with loan_cust_acct as (
select t2.aa01ac15
from
(
select substr(nfabcsid,4) as nfabcsid
from odsdb.blfmconf --贷款合同文件
where nfaacost in ('3','5','7') --合同状态
and substr(nfaabrno,1,3) = '902' --机构
and del_f = '0'
group by substr(nfabcsid,4)
) t1
inner join
(
select
aa01ac15 --账号
,aa62cfno
from sjfx_pro.bdfmhqaa_orc
where del_f = 0
and substr(trim(aa47brno),1,3) = '902' --机构号修改
and rcstrs1b <>'9'
AND aa15zhzt ='1' -- 账户状态 1-正常 2-销户 3-新开户 4-结清
group by aa01ac15 ,aa62cfno
) t2
on t1.nfabcsid = t2.aa62cfno
) ,
assure_cust_acct as (
select t2.aa01ac15
from
(
select asseure_sign
from xdzx.assure_infomation
where del_f= '0'
and assure_state <> '2'
and substr(create_org,1,3)='902'
group by asseure_sign
) t1
inner join
(
select
aa01ac15 --账号
,aa03csno
from sjfx_pro.bdfmhqaa_orc
where del_f = 0
and substr(trim(aa47brno),1,3) = '902' --机构号修改
and rcstrs1b <>'9'
AND aa15zhzt ='1' -- 账户状态 1-正常 2-销户 3-新开户 4-结清
group by aa01ac15 ,aa03csno
) t2
on t1.asseure_sign = t2.aa03csno
)
select distinct id_card
from
(
select id_card
,customer_account_no
from
(
select t1.id_card
,customer_account_no
,amount_dr + amount_cr as trans_amount
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and customer_account_no is not null
union all
select t1.person_id
,customer_account_no
,amount_dr + amount_cr as trans_amount
from
ccdi_staff_fmy_relation t1
inner join
ccdi_bank_statement t2
on t1.relation_cert_no = t2.cret_no
where t1.status = 1
and t2.project_id = PROJECT_ID
and customer_account_no is not null
) group by id_card,customer_account_no
having sum(customer_account_no)>1000
) a
where exists (select 1 from loan_cust_acct b on a.customer_account_no = b.aa01ac15)
or exists (select 1 from assure_cust_acct c on a.customer_account_no = c.aa01ac15)
or exists (select 1 from 中介名单 d on a.customer_account_no = d.中介账号);
--员工亲属低收入但交易金额高
select distinct person_id
from
(
select person_id
,relation_cert_no
,avg(amount_cr) as avg_amount_cr
from
(
select t1.person_id
,t1.relation_cert_no
,substr(trx_time,1,7)
,sum(amount_cr) as amount_cr--收入金额
from
ccdi_staff_fmy_relation t1
inner join
ccdi_bank_statement t2
on t1.relation_cert_no = t2.cret_no
where t1.status = 1
and t2.project_id = PROJECT_ID
and t2.le_account_name <> t2.customer_account_name --排除同名交易
group by t1.person_id,t1.relation_cert_no,substr(trx_time,1,7)
)
group by person_id,relation_cert_no
having avg(amount_cr)<=3000 ---月均收入不超过3000
) t1
left join
(
select t1.relation_cert_no
,sum(amount_cr + amount_dr) as trans_amount
from
ccdi_staff_fmy_relation t1
inner join
ccdi_bank_statement t2
on t1.relation_cert_no = t2.cret_no
where t1.status = 1
and t2.project_id = PROJECT_ID
group by t1.relation_cert_no
having sum(amount_cr + amount_dr) >= 100000
or max(amount_cr) >= 100000
or max(amount_dr) >= 100000
) t2
on t1.relation_cert_no = t2.relation_cert_no
where t2.relation_cert_no is not null
;
",,,,,
3,疑似赌博,"通过多人多次在相近时间有转账、微信转账、支付宝转账发生,且额度在可疑区间。金额区间可在排查设置页面进行设置
大额购买彩票行为
疑似赌球行为
疑似网络赌博行为","1.多人只2人及以上多次指2次以上相近时间指同一天。
2.备注或交易摘要、对手有“游戏、抖币、体彩、福彩”等字眼","--员工 疑似赌博
select distinct id_card
from
(
select t1.id_card,trx_time
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and ((amount_dr>= $$$ and amount_dr<=$$$) or (amount_cr>= $$$ and amount_cr<=$$$)) -----转入转出金额区间
and (customer_account_name rlike '转账' or user_memo rlike '转帐|转账|红包|网转|转入' or cash_type rlike '转帐|转账|红包|网转|转入')
and user_memo not like '%款%'
and t2.le_account_name <> t2.customer_account_name --排除同名交易
group by t1.id_card,trx_time
having count(distinct customer_account_name)>=2
and count(1)>=2
);
--员工 网络赌博、体彩
select t1.id_card
,amount_dr
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and amount_dr> 0
and (user_memo rlike '游戏|抖币|体彩|福彩|彩票|赌|球|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|捕鱼|电子游艺|投注'
or customer_account_name rlike '游戏|抖币|体彩|福彩|彩票|赌|球|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|捕鱼|电子游艺|投注')
;
",,,,,
4,可疑关系,除与配偶、子女外发生特殊金额交易如1314、520。可在排查参数输入页面进行设置,除与配偶、子女外发生特殊金额交易 1314元、520元 等具有特殊含义的金额。,"--员工 可疑关系
select distinct t1.id_card
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
left join
ccdi_staff_fmy_relation t3
on t1.id_card = t3.person_id
and t2.customer_account_name = t3.relation_name
where t2.project_id = PROJECT_ID
and t3.relation_type not in (配偶,子女) --关系类型按实际数据的码值确定
and (amount_dr in (520,1314) or amount_cr in (520,1314))
;",,,,,
5,可疑兼职,除本行工资收入外,有固定收入,"1.除本行工资收入外,每月有固定收入,固定收入金额自行设置。
2.每季或每年从固定交易对手转入金额金额可设区间值如5000-10000。
3.转入资金摘要有“工资”、“分红”、“红利”、“利息(非银行结息)”等收入","--员工 可疑兼职
select t1.id_card
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and bank <> 'ZJRCU'
and (customer_account_name rlike '代发|工资'
or user_memo rlike '代发|工资|分红|红利|奖金|薪酬|薪金|补贴|薪|年终奖|年金|加班费|劳务费|劳务外包|提成|劳务派遣|绩效|酬劳|批量代付|PAYROLL|SALA|CPF|directors.*fees'
or cash_type rlike '代发|工资|劳务费'
)
group by t1.id_card
having sum(amount_cr)>0
;
--员工 可疑固定收入
select distinct id_card
from
(
select id_card
,customer_account_name
,count(1) as income_qrt
,stddev(amount_total) as stddev_amount
from
(
select
id_card
,customer_account_name
,trans_qrt
,count(1) as trans_cnt
,sum(amount_cr) as amount_total
from
(
select t1.id_card
,customer_account_name
,amount_cr
,concat(year(trx_time),'-Q',quarter(trx_time)) as trans_qrt --每季度的固定收入
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and amount_cr>0 --每笔收入金额
and t2.le_account_name <> t2.customer_account_name --排除同名交易
)
group by id_card,customer_account_name,trans_qrt
having count(1) > 3 --每季度大于3笔
and sum(amount_cr) between 3000 and 10000 ---该季度总收入,区间自行设置
)
group by id_card,customer_account_name
having count(1) >= 2 --2个及以上季度
and stddev(amount_total)<2000 --标准差小于2000标识稳定收入
);",,,,,
6,可疑财产,"通过分析车险、房屋险、水电费、燃气费、物业费、车位费、租金、卫生费等缴纳判断车产、房产信息
有转出到售房公司交易,但本人及亲属名下无新增房产
新增住房信息(或有入住新房),但无购房交易、无定金、房款、装修款、设计费记录等
购房资金溯源,是否存在异常收入或向客户借入行为
与家庭收入不匹配的豪华房产","1.购房资金溯源,购房前账户资金来源构成。
2.员工及关系人有购房交易,但名下房产无新增登记。
3.员工及关系人有物业缴费记录,但名下房产无新增登记。
4.员工及关系人有5000元以上的纳税记录但名下无房产车产新增登记。
5.有新增登记购房,但无相关购房交易记录。
6.入信新房但近期无购房、装修等支出。
7.与家庭年收入不匹配的豪华房产其评估价值超过家庭年收入的 10倍。","--员工及其亲属购买房产但无资产登记
select t1.id_card
from
(
select id_card
,min(trx_time) as trx_time
from
(
select t1.id_card
,trx_time
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and (user_memo rlike '(购|买).*房|房款|首付'
or customer_account_name rlike '房地产|置业|置地|地产|房产|不动产|链家|贝壳|我爱我家|房管局')
and amount_dr > 0
union all
select t1.person_id
,trx_time
from
ccdi_staff_fmy_relation t1
inner join
ccdi_bank_statement t2
on t1.relation_cert_no = t2.cret_no
where t1.status = 1
and t2.project_id = PROJECT_ID
and (user_memo rlike '(购|买).*房|房款|首付'
or customer_account_name rlike '房地产|置业|置地|地产|房产|不动产|链家|贝壳|我爱我家|房管局')
and amount_dr > 0
)
group by id_card
) t1
left join
(
select person_id
,max(updated_at) as updated_at
from ccdi_asset_info
where asset_main_type = 不动产 --根据具体数据确定码值
and asset_sub_type in (住宅,商铺) --根据具体数据确定码值
and asset_status = 正常 --根据具体数据确定码值
group by person_id
) t2
on t1.id_card = t2.person_id
where t1.trx_time > t2.updated_at --购买时间大于最近一次资产更新时间
or t2.person_id is null;
--有物业缴费记录但无房产登记
select t1.id_card
FROM
(
select id_card
from
(
select t1.id_card
,trx_time
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and (user_memo rlike '物业|物业费|管理费|物业服务|综合服务'
or customer_account_name rlike '物业|小区|花园|苑|中心|大厦|业委会|业主委员会|置业|房地产|服务中心|管理处|社区')
and amount_dr > 0
union all
select t1.person_id
,trx_time
from
ccdi_staff_fmy_relation t1
inner join
ccdi_bank_statement t2
on t1.relation_cert_no = t2.cret_no
where t1.status = 1
and t2.project_id = PROJECT_ID
and (user_memo rlike '物业|物业费|管理费|物业服务|综合服务'
or customer_account_name rlike '物业|小区|花园|苑|中心|大厦|业委会|业主委员会|置业|房地产|服务中心|管理处|社区')
and amount_dr > 0
)
group by id_card
) t1
left join
(
select person_id
,max(updated_at) as updated_at
from ccdi_asset_info
where asset_main_type = 不动产
and asset_sub_type in (住宅,商铺)
and asset_status = 正常
group by person_id
) t2
on t1.id_card = t2.person_id
where t2.person_id is null;
----有5000元以上的纳税记录但无房产登记
select t1.id_card
FROM
(
select id_card
from
(
select t1.id_card
,trx_time
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and (user_memo rlike '税务|缴税|税款'
or customer_account_name rlike '税务|税务局|国库|国家金库|财政')
and amount_dr >= 5000
union all
select t1.person_id
,trx_time
from
ccdi_staff_fmy_relation t1
inner join
ccdi_bank_statement t2
on t1.relation_cert_no = t2.cret_no
where t1.status = 1
and t2.project_id = PROJECT_ID
and (user_memo rlike '税务|缴税|税款'
or customer_account_name rlike '税务|税务局|国库|国家金库|财政')
and amount_dr >= 5000
)
group by id_card
) t1
left join
(
select person_id
,max(updated_at) as updated_at
from ccdi_asset_info
where asset_main_type = 不动产
and asset_sub_type in (住宅,商铺)
and asset_status = 正常
group by person_id
) t2
on t1.id_card = t2.person_id
where t2.person_id is null;
",,,,,
7,可疑外汇交易,异常购汇、结汇、跨境结汇,单笔购汇、结汇或跨境结汇金额超过限额。,"--员工 可疑外汇交易
select t1.id_card
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and customer_account_name rlike '银行|外汇|售汇|结汇|国家外汇管理局|外汇'
user_memo rlike '购汇|结汇|换汇|外汇|汇率|外币|现汇|结汇水单|外币兑换人民币|结汇入账|外汇结汇|外汇买入|购外币|购买外汇|外币'
group by t1.id_card
having sum(amount_cr)>100000
or sum(amount_dr)>100000 --限额自行设置
;",,,,,
8,可疑付息,客户经理管户的客户在智柜、柜面连续代交利息人数比较多,客户经理管户的客户在智柜、柜面连续 代交利息且代交人数超过 2人。,"---管户经理与贷款客户有交易往来
with cust_loan_duty as (
select t1.id_card
,t2.customer_isn
from
ccdi_base_staff t1
left join
(
select customer_isn,creditor_code
from xdzx.loan_liability
where status = '1'
and product_code <> '15AC'
group by customer_isn,creditor_code
UNION
select t2.nfaacsno,t1.primary_principal
from xdzx.loan_duty t1
left join odsdb.blfmconf t2
on t1.contract_no = t2.nfaacono
where t1.status='1'
and t1.product_code ='15AC'
group by t2.nfaacsno,t1.primary_principal
) t2
on t1.staff_id = t2.creditor_code
)
select cret_no
FROM
(
select t1.cret_no,t2.customer_isn
from ccdi_bank_statement t1
inner join cust_loan_duty t2
on t1.cret_no = t2.id_card
left join
(
select CINOCSNO
,DFANAC19
,CDNOAC19
from odsdb.BWFMDCIM
where rcstrs1b <> '9'
and del_f = '0'
and OWONBRNO like '902%'
group by CINOCSNO,DFANAC19,CDNOAC19
) t3
on t2.customer_isn = t3.CINOCSNO
where project_id = PROJECT_ID
and (t1.customer_account_no = t3.DFANAC19--账号
or t1.customer_account_no = t3.CDNOAC19)--卡号
group by t1.cret_no,t2.customer_isn
having t1.amount_cr +t1.amount_dr > 0
)
group by cret_no
having count(customer_isn) > 2 --交易贷款客户超过两人
;",,,,,
9,可疑采购,"1.提示可能化整为零的采购
2.提示向同一企业或同一人实控的企业采购集中度过高的情况",单笔采购金额超过 10万元提示向同一企业或同一人实控的企业采购集中度过高单个供应商采购额占总采购额比例超过 70%。,"--单笔采购金额大于10万
select t1.id_card
from ccdi_base_staff t1
inner join
(
select applicant_id
from ccdi_purchase_transaction
where actual_amount>100000
UNION
select purchase_leader_id
from ccdi_purchase_transaction
where actual_amount>100000
) t2
on t1.staff_id = t2.applicant_id
;
----单个供应商采购金额占项目总采购额超百分之70
with project_total as (
select purchase_id
,sum(actual_amount) as total_amount
from ccdi_purchase_transaction
group by purchase_id
),
supplier_project AS (
select t1.applicant_id,t1.purchase_leader_id
from
(
select applicant_id
,purchase_leader_id
,purchase_id
,supplier_uscc
,sum(actual_amount) as supply_amount
from ccdi_purchase_transaction
group by applicant_id,purchase_leader_id,purchase_id,supplier_uscc
) t1
left join project_total t2
ON t1.purchase_id = t2.purchase_id
where t1.supply_amount / t2.total_amount > 0.7
)
select t2.id_card
(
select applicant_id
from supplier_project
union
select purchase_leader_id
from supplier_project
) t1
inner join
ccdi_base_staff t2
on t1.applicant_id = t2.staff_id;",,,,,
10,异常行为,"1.每天长时间电话、频繁电话、微信电话
2.丰收互联交易IP地址与属地IP地址段不匹配
3.家庭老人、非家庭关系人银证大额转账
4.微信支付宝频繁提现
5.工资发放后立即转出大部分资金的行为
6.工资发放后除代扣项目外,几乎不使用的情况
7.涉诉情况
8.大额炒股
9.操控他人账户交易
","1.每天电话、微信通话时长超过 2小时 或同一对像通话次数超过5次。
2.丰收互联交易IP地址与属地IP地址段不匹配。
3.家庭老人、非家庭关系人银证大额转账,单笔超过设置限额。
4.微信、支付宝单日提现次数超过设置次 或单日累计提现金额超过 设置限额。
5.工资发放后 24小时内 转出超过 80% 的资金。
6.工资发放后除代扣项目外连续30天 无任何消费或转账记录。
7.大额炒股单次三方资管交易金额超过 100万元。
8.多次代理他人账户交易,或登录员工手机操作他人丰收互联交易。
","--支付宝微信单日提现金额和笔数
select t1.id_card
,t2.trx_time
,count(1) as trans_cnt
,sum(amount_cr) as trans_amt
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and (user_memo rlike '财付通|微信零钱|微信|wechat|WeChat|Tenpay|支付宝|Alipay|提现'
or customer_account_name rlike '财付通|微信零钱|微信|wechat|WeChat|Tenpay|支付宝|Alipay|提现')
and amount_cr >= 0
group by t1.id_card,t2.trx_time;
--员工 发工资后1天内转出80%以上
select t1.id_card
from
(
select t1.id_card
,t2.trx_time as sala_time
,t2.amount_cr
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and bank = 'ZJRCU'
and (customer_account_name rlike '代发|工资'
or user_memo rlike '代发|工资|分红|红利|奖金|薪酬|薪金|补贴|薪|年终奖|年金|加班费|劳务费|劳务外包|提成|劳务派遣|绩效|酬劳|批量代付|PAYROLL|SALA|CPF|directors.*fees'
or cash_type rlike '代发|工资|劳务费'
and amount_cr > 0
)
) t1
left join
(
select t1.id_card
,t2.trx_time
,t2.amount_dr
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and bank = 'ZJRCU'
and amount_dr > 0
) t2
on t1.id_card = t2.id_card
where t2.trx_time between t1.sala_time and date_add(t1.sala_time,interval 1 day)
group by t1.id_card
having sum(t2.amount_dr) / sum(t1.amount_cr) >0.8
;
--炒股、单次三方资管交易金额超100万
select distinct id_card
FROM
(
select t1.id_card
,t2.amount_cr
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and (customer_account_name rlike '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|理财'
or user_memo rlike '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|理财'
or cash_type rlike '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|理财'
)
and amount_dr > 1000000
);",,,,,
,,,,,,,,,
,,,,,,,,,
,,,,,,,,,
,,,,,,,,,
,,,,,,,,,
,,,,,,,,,
,,,,,,,,,
,,,,,,,,,
,,,,,,,,,
,,,,,,,,,
1 可疑行为排查模型
2 序号 模型名称 描述 业务口径 代码
3 1 大额交易 关注账户(包括本人、亲属、注册主体等账户),房、车采购等大额消费,异常纳税支出等。 除工资收入外的大额流入,大额的额度可在排查参数输入页面进行设置 修改默认限额,且年流水交易额超过默认限额。 大额存现或短时间多次存现 大额转账或频繁转账,大额的定义数字可在排查参数输入页面进行设置 1.备注或对交易对手是房产公司、二手房、车辆销售公司、物业公司等。 2.有税务支出记录 3.同一交易对手(除家庭成员外、本单位代发工资)单笔超过 设置限额或累计交易金额超过 设置限额的资金流入; 4.年流水交易额超过  设置限额; 5.大额存现或短时间多次存现,单笔超过 设置限额; 6.大额转账或频繁转账,单笔超过 设置限额。 ---员工及其亲属购买车房支出金额 select id_card ,sum(amount_dr) as amount_dr from ( select t1.id_card ,amount_dr from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and (user_memo rlike '(购|买).*房|(购|买).*车|车款|房款|首付|(房|车).*贷' or customer_account_name rlike '汽车销售|汽车金融|4S店|汽贸|车行|房地产|置业|置地|地产|房产|不动产|链家|贝壳|我爱我家|房管局') and amount_dr > 0 union all select t1.person_id ,amount_dr from ccdi_staff_fmy_relation t1 inner join ccdi_bank_statement t2 on t1.relation_cert_no = t2.cret_no where t1.status = 1 and t2.project_id = PROJECT_ID and (user_memo rlike '(购|买).*房|(购|买).*车|车款|房款|首付|(房|车).*贷' or customer_account_name rlike '汽车销售|汽车金融|4S店|汽贸|车行|房地产|置业|置地|地产|房产|不动产|链家|贝壳|我爱我家|房管局') and amount_dr > 0 ) group by id_card; ----员工及其亲属税务支出金额 select id_card ,sum(amount_dr) as amount_dr from ( select t1.id_card ,amount_dr from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and (user_memo rlike '税务|缴税|税款' or customer_account_name rlike '税务|税务局|国库|国家金库|财政') and amount_dr > 0 union all select t1.person_id ,amount_dr from ccdi_staff_fmy_relation t1 inner join ccdi_bank_statement t2 on t1.relation_cert_no = t2.cret_no where t1.status = 1 and t2.project_id = PROJECT_ID and (user_memo rlike '税务|缴税|税款' or customer_account_name rlike '税务|税务局|国库|国家金库|财政') and amount_dr > 0 ) group by id_card; --员工与同一交易对手(非亲属)的最大一笔收入交易金额 select id_card ,max(max_amount_cr) as max_amount_cr from ( select t1.id_card ,t1.customer_account_name ,t1.max_amount_cr from ( select t1.id_card ,customer_account_name ,max(amount_cr) as max_amount_cr from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and t2.le_account_name <> t2.customer_account_name and customer_account_name not like '%代发%' and customer_account_name not like '%工资%' and user_memo not like '%代发%' and user_memo not like '%工资%' and user_memo not like '%奖金%' and user_memo not like '%薪酬%' and user_memo not like '%薪金%' and user_memo not like '%补贴%' and user_memo not like '%薪%' and user_memo not like '%年终奖%' and user_memo not like '%年金%' and user_memo not like '%加班费%' and user_memo not like '%劳务费%' and user_memo not like '%劳务外包%' and user_memo not like '%提成%' and user_memo not like '%劳务派遣%' and user_memo not like '%绩效%' and user_memo not like '%酬劳%' and user_memo not like '%PAYROLL%' and user_memo not like '%SALA%' and user_memo not like '%CPF%' and user_memo not like '%directors%fees%' and user_memo not like '%批量代付%' and cash_type not like '%代发%' and cash_type not like '%工资%' and cash_type not like '%劳务费%' and amount_cr > 0 group by id_card,customer_account_name ) t1 left join ccdi_staff_fmy_relation t2 on t1.id_card = t2.person_id and t1.customer_account_name = t2.relation_name where t2.person_id is null; ) group by id_card; --员工与同一交易对手(非亲属)的累计收入交易金额 select t1.id_card ,t1.customer_account_name ,t1.amount_cr from ( select t1.id_card ,customer_account_name ,sum(amount_cr) as amount_cr from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and t2.le_account_name <> t2.customer_account_name and customer_account_name not like '%代发%' and customer_account_name not like '%工资%' and user_memo not like '%代发%' and user_memo not like '%工资%' and user_memo not like '%奖金%' and user_memo not like '%薪酬%' and user_memo not like '%薪金%' and user_memo not like '%补贴%' and user_memo not like '%薪%' and user_memo not like '%年终奖%' and user_memo not like '%年金%' and user_memo not like '%加班费%' and user_memo not like '%劳务费%' and user_memo not like '%劳务外包%' and user_memo not like '%提成%' and user_memo not like '%劳务派遣%' and user_memo not like '%绩效%' and user_memo not like '%酬劳%' and user_memo not like '%PAYROLL%' and user_memo not like '%SALA%' and user_memo not like '%CPF%' and user_memo not like '%directors%fees%' and user_memo not like '%批量代付%' and cash_type not like '%代发%' and cash_type not like '%工资%' and cash_type not like '%劳务费%' group by id_card,customer_account_name having sum(amount_cr)>0 ) t1 left join ccdi_staff_fmy_relation t2 on t1.id_card = t2.person_id and t1.customer_account_name = t2.relation_name where t2.person_id is null; --员工及其亲属 年交易金额 select id_card ,sum(trans_amount) as trans_amount from ( select t1.id_card ,amount_dr + amount_cr as trans_amount from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and TRX_DATE >= $$$ --近一年 and t2.le_account_name <> t2.customer_account_name --排除同名交易 union all select t1.person_id ,amount_dr + amount_cr as trans_amount from ccdi_staff_fmy_relation t1 inner join ccdi_bank_statement t2 on t1.relation_cert_no = t2.cret_no where t1.status = 1 and t2.project_id = PROJECT_ID and TRX_DATE >= $$$ --近一年 and t2.le_account_name <> t2.customer_account_name --排除同名交易 ) group by id_card; ---员工及其亲属 最大一笔存现单笔金额 select id_card ,max(amount_cr) as amount_cr FROM ( select t1.id_card ,max(amount_cr) as amount_cr from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and amount_cr>0 and ( (((user_memo like '%现金%' and user_memo not like '%金管理%' and user_memo not like '%金添利%' and user_memo not like '%现金利%' and user_memo not like '%现金宝%' and user_memo not like '%金分析%' ) or user_memo like '%存现%' or user_memo like '%现存%' or cash_type like '%现金%' or cash_type like '%存现%' or cash_type like '%现存%' or cash_type like '%金存入%' or user_memo like '%金存入%' or (user_memo like '%ATM%' and (user_memo like '%存款%' or user_memo like '%转入%')) or (cash_type like '%ATM%' and (cash_type like '%存款%' or cash_type like '%转入%'))) and (customer_account_name = '' or customer_account_name = '无' or customer_account_name like '%存现%') or user_memo like '%DEPOSIT%') or ((customer_account_name = '库存现金' or ((user_memo like '%现金存款%' or user_memo like '%自助存款%' or user_memo like '%CRS存款%' or cash_type like '%现金存款%' or cash_type like '%自助存款%' or cash_type like '%本行CRS存款%' or cash_type like '%柜面%' or user_memo like '%柜面%') and customer_account_name = '' )) or (customer_account_name = '现金' and user_memo not like '%借款%') or user_memo like '%本行ATM%') ) group by t1.id_card union all select t1.person_id ,max(amount_cr) as amount_cr from ccdi_staff_fmy_relation t1 inner join ccdi_bank_statement t2 on t1.relation_cert_no = t2.cret_no where t1.status = 1 and t2.project_id = PROJECT_ID and amount_cr>0 and ( (((user_memo like '%现金%' and user_memo not like '%金管理%' and user_memo not like '%金添利%' and user_memo not like '%现金利%' and user_memo not like '%现金宝%' and user_memo not like '%金分析%' ) or user_memo like '%存现%' or user_memo like '%现存%' or cash_type like '%现金%' or cash_type like '%存现%' or cash_type like '%现存%' or cash_type like '%金存入%' or user_memo like '%金存入%' or (user_memo like '%ATM%' and (user_memo like '%存款%' or user_memo like '%转入%')) or (cash_type like '%ATM%' and (cash_type like '%存款%' or cash_type like '%转入%'))) and (customer_account_name = '' or customer_account_name = '无' or customer_account_name like '%存现%') or user_memo like '%DEPOSIT%') or ((customer_account_name = '库存现金' or ((user_memo like '%现金存款%' or user_memo like '%自助存款%' or user_memo like '%CRS存款%' or cash_type like '%现金存款%' or cash_type like '%自助存款%' or cash_type like '%本行CRS存款%' or cash_type like '%柜面%' or user_memo like '%柜面%') and customer_account_name = '' )) or (customer_account_name = '现金' and user_memo not like '%借款%') or user_memo like '%本行ATM%') ) group by t1.person_id ) group by id_card ; --员工及其亲属 存现总金额 select id_card,sum(amount_cr) as amount_cr from ( select t1.id_card ,amount_cr from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and amount_cr>0 and ( (((user_memo like '%现金%' and user_memo not like '%金管理%' and user_memo not like '%金添利%' and user_memo not like '%现金利%' and user_memo not like '%现金宝%' and user_memo not like '%金分析%' ) or user_memo like '%存现%' or user_memo like '%现存%' or cash_type like '%现金%' or cash_type like '%存现%' or cash_type like '%现存%' or cash_type like '%金存入%' or user_memo like '%金存入%' or (user_memo like '%ATM%' and (user_memo like '%存款%' or user_memo like '%转入%')) or (cash_type like '%ATM%' and (cash_type like '%存款%' or cash_type like '%转入%'))) and (customer_account_name = '' or customer_account_name = '无' or customer_account_name like '%存现%') or user_memo like '%DEPOSIT%') or ((customer_account_name = '库存现金' or ((user_memo like '%现金存款%' or user_memo like '%自助存款%' or user_memo like '%CRS存款%' or cash_type like '%现金存款%' or cash_type like '%自助存款%' or cash_type like '%本行CRS存款%' or cash_type like '%柜面%' or user_memo like '%柜面%') and customer_account_name = '' )) or (customer_account_name = '现金' and user_memo not like '%借款%') or user_memo like '%本行ATM%') ) union all select t1.person_id ,amount_cr from ccdi_staff_fmy_relation t1 inner join ccdi_bank_statement t2 on t1.relation_cert_no = t2.cret_no where t1.status = 1 and t2.project_id = PROJECT_ID and amount_cr>0 and ( (((user_memo like '%现金%' and user_memo not like '%金管理%' and user_memo not like '%金添利%' and user_memo not like '%现金利%' and user_memo not like '%现金宝%' and user_memo not like '%金分析%' ) or user_memo like '%存现%' or user_memo like '%现存%' or cash_type like '%现金%' or cash_type like '%存现%' or cash_type like '%现存%' or cash_type like '%金存入%' or user_memo like '%金存入%' or (user_memo like '%ATM%' and (user_memo like '%存款%' or user_memo like '%转入%')) or (cash_type like '%ATM%' and (cash_type like '%存款%' or cash_type like '%转入%'))) and (customer_account_name = '' or customer_account_name = '无' or customer_account_name like '%存现%') or user_memo like '%DEPOSIT%') or ((customer_account_name = '库存现金' or ((user_memo like '%现金存款%' or user_memo like '%自助存款%' or user_memo like '%CRS存款%' or cash_type like '%现金存款%' or cash_type like '%自助存款%' or cash_type like '%本行CRS存款%' or cash_type like '%柜面%' or user_memo like '%柜面%') and customer_account_name = '' )) or (customer_account_name = '现金' and user_memo not like '%借款%') or user_memo like '%本行ATM%') ) )group by id_card ; --员工及其亲属 大额现金存入次数 select id_card,count(1) from ( select t1.id_card ,amount_cr from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and amount_cr> $$$$$$ and ( (((user_memo like '%现金%' and user_memo not like '%金管理%' and user_memo not like '%金添利%' and user_memo not like '%现金利%' and user_memo not like '%现金宝%' and user_memo not like '%金分析%' ) or user_memo like '%存现%' or user_memo like '%现存%' or cash_type like '%现金%' or cash_type like '%存现%' or cash_type like '%现存%' or cash_type like '%金存入%' or user_memo like '%金存入%' or (user_memo like '%ATM%' and (user_memo like '%存款%' or user_memo like '%转入%')) or (cash_type like '%ATM%' and (cash_type like '%存款%' or cash_type like '%转入%'))) and (customer_account_name = '' or customer_account_name = '无' or customer_account_name like '%存现%') or user_memo like '%DEPOSIT%') or ((customer_account_name = '库存现金' or ((user_memo like '%现金存款%' or user_memo like '%自助存款%' or user_memo like '%CRS存款%' or cash_type like '%现金存款%' or cash_type like '%自助存款%' or cash_type like '%本行CRS存款%' or cash_type like '%柜面%' or user_memo like '%柜面%') and customer_account_name = '' )) or (customer_account_name = '现金' and user_memo not like '%借款%') or user_memo like '%本行ATM%') ) union all select t1.person_id ,amount_cr from ccdi_staff_fmy_relation t1 inner join ccdi_bank_statement t2 on t1.relation_cert_no = t2.cret_no where t1.status = 1 and t2.project_id = PROJECT_ID and amount_cr> $$$$$$$ and ( (((user_memo like '%现金%' and user_memo not like '%金管理%' and user_memo not like '%金添利%' and user_memo not like '%现金利%' and user_memo not like '%现金宝%' and user_memo not like '%金分析%' ) or user_memo like '%存现%' or user_memo like '%现存%' or cash_type like '%现金%' or cash_type like '%存现%' or cash_type like '%现存%' or cash_type like '%金存入%' or user_memo like '%金存入%' or (user_memo like '%ATM%' and (user_memo like '%存款%' or user_memo like '%转入%')) or (cash_type like '%ATM%' and (cash_type like '%存款%' or cash_type like '%转入%'))) and (customer_account_name = '' or customer_account_name = '无' or customer_account_name like '%存现%') or user_memo like '%DEPOSIT%') or ((customer_account_name = '库存现金' or ((user_memo like '%现金存款%' or user_memo like '%自助存款%' or user_memo like '%CRS存款%' or cash_type like '%现金存款%' or cash_type like '%自助存款%' or cash_type like '%本行CRS存款%' or cash_type like '%柜面%' or user_memo like '%柜面%') and customer_account_name = '' )) or (customer_account_name = '现金' and user_memo not like '%借款%') or user_memo like '%本行ATM%') ) )group by id_card ; --员工及其亲属 大额转账次数 select id_card,count(1) from ( select t1.id_card ,amount_dr from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and amount_dr> $$$$$$ and (customer_account_name rlike '转账' or user_memo rlike '转帐|转账|汇入|转存|红包|汇款|网转|转入' or cash_type rlike '转帐|转账|汇入|转存|红包|汇款|网转|转入') and user_memo not like '%款%' and t2.le_account_name <> t2.customer_account_name --排除同名交易 union all select t1.person_id ,amount_dr from ccdi_staff_fmy_relation t1 inner join ccdi_bank_statement t2 on t1.relation_cert_no = t2.cret_no where t1.status = 1 and t2.project_id = PROJECT_ID and amount_dr> $$$$$$ and (customer_account_name rlike '转账' or user_memo rlike '转帐|转账|汇入|转存|红包|汇款|网转|转入' or cash_type rlike '转帐|转账|汇入|转存|红包|汇款|网转|转入') and user_memo not like '%款%' and t2.le_account_name <> t2.customer_account_name --排除同名交易 ) group by id_card ;
4 2 异常交易 员工及关系人与客户之间非正常资金往来 关系人中没有收入或低收入人员有大额资金交易 1.员工及关系人与客户及关系人之间有超过1000元以上的资金往来;客户指信贷类客户包括贷款户、担保人,中介库人员,包括中介注册的主体及主体关系人。 2.关系人中没有收入或月收入低于 3000元 的人员,单笔或累计交易金额超过 10万元。 --员工及其亲属与贷款客户、担保户、中介有异常交易 with loan_cust_acct as ( select t2.aa01ac15 from ( select substr(nfabcsid,4) as nfabcsid from odsdb.blfmconf --贷款合同文件 where nfaacost in ('3','5','7') --合同状态 and substr(nfaabrno,1,3) = '902' --机构 and del_f = '0' group by substr(nfabcsid,4) ) t1 inner join ( select aa01ac15 --账号 ,aa62cfno from sjfx_pro.bdfmhqaa_orc where del_f = 0 and substr(trim(aa47brno),1,3) = '902' --机构号修改 and rcstrs1b <>'9' AND aa15zhzt ='1' -- 账户状态 1-正常 2-销户 3-新开户 4-结清 group by aa01ac15 ,aa62cfno ) t2 on t1.nfabcsid = t2.aa62cfno ) , assure_cust_acct as ( select t2.aa01ac15 from ( select asseure_sign from xdzx.assure_infomation where del_f= '0' and assure_state <> '2' and substr(create_org,1,3)='902' group by asseure_sign ) t1 inner join ( select aa01ac15 --账号 ,aa03csno from sjfx_pro.bdfmhqaa_orc where del_f = 0 and substr(trim(aa47brno),1,3) = '902' --机构号修改 and rcstrs1b <>'9' AND aa15zhzt ='1' -- 账户状态 1-正常 2-销户 3-新开户 4-结清 group by aa01ac15 ,aa03csno ) t2 on t1.asseure_sign = t2.aa03csno ) select distinct id_card from ( select id_card ,customer_account_no from ( select t1.id_card ,customer_account_no ,amount_dr + amount_cr as trans_amount from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and customer_account_no is not null union all select t1.person_id ,customer_account_no ,amount_dr + amount_cr as trans_amount from ccdi_staff_fmy_relation t1 inner join ccdi_bank_statement t2 on t1.relation_cert_no = t2.cret_no where t1.status = 1 and t2.project_id = PROJECT_ID and customer_account_no is not null ) group by id_card,customer_account_no having sum(customer_account_no)>1000 ) a where exists (select 1 from loan_cust_acct b on a.customer_account_no = b.aa01ac15) or exists (select 1 from assure_cust_acct c on a.customer_account_no = c.aa01ac15) or exists (select 1 from 中介名单 d on a.customer_account_no = d.中介账号); --员工亲属低收入但交易金额高 select distinct person_id from ( select person_id ,relation_cert_no ,avg(amount_cr) as avg_amount_cr from ( select t1.person_id ,t1.relation_cert_no ,substr(trx_time,1,7) ,sum(amount_cr) as amount_cr--收入金额 from ccdi_staff_fmy_relation t1 inner join ccdi_bank_statement t2 on t1.relation_cert_no = t2.cret_no where t1.status = 1 and t2.project_id = PROJECT_ID and t2.le_account_name <> t2.customer_account_name --排除同名交易 group by t1.person_id,t1.relation_cert_no,substr(trx_time,1,7) ) group by person_id,relation_cert_no having avg(amount_cr)<=3000 ---月均收入不超过3000 ) t1 left join ( select t1.relation_cert_no ,sum(amount_cr + amount_dr) as trans_amount from ccdi_staff_fmy_relation t1 inner join ccdi_bank_statement t2 on t1.relation_cert_no = t2.cret_no where t1.status = 1 and t2.project_id = PROJECT_ID group by t1.relation_cert_no having sum(amount_cr + amount_dr) >= 100000 or max(amount_cr) >= 100000 or max(amount_dr) >= 100000 ) t2 on t1.relation_cert_no = t2.relation_cert_no where t2.relation_cert_no is not null ;
5 3 疑似赌博 通过多人多次在相近时间有转账、微信转账、支付宝转账发生,且额度在可疑区间。金额区间可在排查设置页面进行设置 大额购买彩票行为 疑似赌球行为 疑似网络赌博行为 1.多人只2人及以上,多次指2次以上,相近时间指同一天。 2.备注或交易摘要、对手有“游戏、抖币、体彩、福彩”等字眼 --员工 疑似赌博 select distinct id_card from ( select t1.id_card,trx_time from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and ((amount_dr>= $$$ and amount_dr<=$$$) or (amount_cr>= $$$ and amount_cr<=$$$)) -----转入转出金额区间 and (customer_account_name rlike '转账' or user_memo rlike '转帐|转账|红包|网转|转入' or cash_type rlike '转帐|转账|红包|网转|转入') and user_memo not like '%款%' and t2.le_account_name <> t2.customer_account_name --排除同名交易 group by t1.id_card,trx_time having count(distinct customer_account_name)>=2 and count(1)>=2 ); --员工 网络赌博、体彩 select t1.id_card ,amount_dr from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and amount_dr> 0 and (user_memo rlike '游戏|抖币|体彩|福彩|彩票|赌|球|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|捕鱼|电子游艺|投注' or customer_account_name rlike '游戏|抖币|体彩|福彩|彩票|赌|球|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|捕鱼|电子游艺|投注') ;
6 4 可疑关系 除与配偶、子女外发生特殊金额交易,如1314、520。可在排查参数输入页面进行设置 除与配偶、子女外,发生特殊金额交易,如 1314元、520元 等具有特殊含义的金额。 --员工 可疑关系 select distinct t1.id_card from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no left join ccdi_staff_fmy_relation t3 on t1.id_card = t3.person_id and t2.customer_account_name = t3.relation_name where t2.project_id = PROJECT_ID and t3.relation_type not in (配偶,子女) --关系类型按实际数据的码值确定 and (amount_dr in (520,1314) or amount_cr in (520,1314)) ;
7 5 可疑兼职 除本行工资收入外,有固定收入 1.除本行工资收入外,每月有固定收入,固定收入金额自行设置。 2.每季或每年从固定交易对手转入金额,金额可设区间值,如5000-10000。 3.转入资金摘要有“工资”、“分红”、“红利”、“利息(非银行结息)”等收入 --员工 可疑兼职 select t1.id_card from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and bank <> 'ZJRCU' and (customer_account_name rlike '代发|工资' or user_memo rlike '代发|工资|分红|红利|奖金|薪酬|薪金|补贴|薪|年终奖|年金|加班费|劳务费|劳务外包|提成|劳务派遣|绩效|酬劳|批量代付|PAYROLL|SALA|CPF|directors.*fees' or cash_type rlike '代发|工资|劳务费' ) group by t1.id_card having sum(amount_cr)>0 ; --员工 可疑固定收入 select distinct id_card from ( select id_card ,customer_account_name ,count(1) as income_qrt ,stddev(amount_total) as stddev_amount from ( select id_card ,customer_account_name ,trans_qrt ,count(1) as trans_cnt ,sum(amount_cr) as amount_total from ( select t1.id_card ,customer_account_name ,amount_cr ,concat(year(trx_time),'-Q',quarter(trx_time)) as trans_qrt --每季度的固定收入 from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and amount_cr>0 --每笔收入金额 and t2.le_account_name <> t2.customer_account_name --排除同名交易 ) group by id_card,customer_account_name,trans_qrt having count(1) > 3 --每季度大于3笔 and sum(amount_cr) between 3000 and 10000 ---该季度总收入,区间自行设置 ) group by id_card,customer_account_name having count(1) >= 2 --2个及以上季度 and stddev(amount_total)<2000 --标准差小于2000,标识稳定收入 );
8 6 可疑财产 通过分析车险、房屋险、水电费、燃气费、物业费、车位费、租金、卫生费等缴纳判断车产、房产信息 有转出到售房公司交易,但本人及亲属名下无新增房产 新增住房信息(或有入住新房),但无购房交易、无定金、房款、装修款、设计费记录等 购房资金溯源,是否存在异常收入或向客户借入行为 与家庭收入不匹配的豪华房产 1.购房资金溯源,购房前账户资金来源构成。 2.员工及关系人有购房交易,但名下房产无新增登记。 3.员工及关系人有物业缴费记录,但名下房产无新增登记。 4.员工及关系人有5000元以上的纳税记录,但名下无房产车产新增登记。 5.有新增登记购房,但无相关购房交易记录。 6.入信新房但近期无购房、装修等支出。 7.与家庭年收入不匹配的豪华房产,其评估价值超过家庭年收入的 10倍。 --员工及其亲属购买房产但无资产登记 select t1.id_card from ( select id_card ,min(trx_time) as trx_time from ( select t1.id_card ,trx_time from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and (user_memo rlike '(购|买).*房|房款|首付' or customer_account_name rlike '房地产|置业|置地|地产|房产|不动产|链家|贝壳|我爱我家|房管局') and amount_dr > 0 union all select t1.person_id ,trx_time from ccdi_staff_fmy_relation t1 inner join ccdi_bank_statement t2 on t1.relation_cert_no = t2.cret_no where t1.status = 1 and t2.project_id = PROJECT_ID and (user_memo rlike '(购|买).*房|房款|首付' or customer_account_name rlike '房地产|置业|置地|地产|房产|不动产|链家|贝壳|我爱我家|房管局') and amount_dr > 0 ) group by id_card ) t1 left join ( select person_id ,max(updated_at) as updated_at from ccdi_asset_info where asset_main_type = 不动产 --根据具体数据确定码值 and asset_sub_type in (住宅,商铺) --根据具体数据确定码值 and asset_status = 正常 --根据具体数据确定码值 group by person_id ) t2 on t1.id_card = t2.person_id where t1.trx_time > t2.updated_at --购买时间大于最近一次资产更新时间 or t2.person_id is null; --有物业缴费记录但无房产登记 select t1.id_card FROM ( select id_card from ( select t1.id_card ,trx_time from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and (user_memo rlike '物业|物业费|管理费|物业服务|综合服务' or customer_account_name rlike '物业|小区|花园|苑|中心|大厦|业委会|业主委员会|置业|房地产|服务中心|管理处|社区') and amount_dr > 0 union all select t1.person_id ,trx_time from ccdi_staff_fmy_relation t1 inner join ccdi_bank_statement t2 on t1.relation_cert_no = t2.cret_no where t1.status = 1 and t2.project_id = PROJECT_ID and (user_memo rlike '物业|物业费|管理费|物业服务|综合服务' or customer_account_name rlike '物业|小区|花园|苑|中心|大厦|业委会|业主委员会|置业|房地产|服务中心|管理处|社区') and amount_dr > 0 ) group by id_card ) t1 left join ( select person_id ,max(updated_at) as updated_at from ccdi_asset_info where asset_main_type = 不动产 and asset_sub_type in (住宅,商铺) and asset_status = 正常 group by person_id ) t2 on t1.id_card = t2.person_id where t2.person_id is null; ----有5000元以上的纳税记录但无房产登记 select t1.id_card FROM ( select id_card from ( select t1.id_card ,trx_time from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and (user_memo rlike '税务|缴税|税款' or customer_account_name rlike '税务|税务局|国库|国家金库|财政') and amount_dr >= 5000 union all select t1.person_id ,trx_time from ccdi_staff_fmy_relation t1 inner join ccdi_bank_statement t2 on t1.relation_cert_no = t2.cret_no where t1.status = 1 and t2.project_id = PROJECT_ID and (user_memo rlike '税务|缴税|税款' or customer_account_name rlike '税务|税务局|国库|国家金库|财政') and amount_dr >= 5000 ) group by id_card ) t1 left join ( select person_id ,max(updated_at) as updated_at from ccdi_asset_info where asset_main_type = 不动产 and asset_sub_type in (住宅,商铺) and asset_status = 正常 group by person_id ) t2 on t1.id_card = t2.person_id where t2.person_id is null;
9 7 可疑外汇交易 异常购汇、结汇、跨境结汇 单笔购汇、结汇或跨境结汇金额超过限额。 --员工 可疑外汇交易 select t1.id_card from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and customer_account_name rlike '银行|外汇|售汇|结汇|国家外汇管理局|外汇' user_memo rlike '购汇|结汇|换汇|外汇|汇率|外币|现汇|结汇水单|外币兑换人民币|结汇入账|外汇结汇|外汇买入|购外币|购买外汇|外币' group by t1.id_card having sum(amount_cr)>100000 or sum(amount_dr)>100000 --限额自行设置 ;
10 8 可疑付息 客户经理管户的客户在智柜、柜面连续代交利息人数比较多 客户经理管户的客户在智柜、柜面连续 代交利息,且代交人数超过 2人。 ---管户经理与贷款客户有交易往来 with cust_loan_duty as ( select t1.id_card ,t2.customer_isn from ccdi_base_staff t1 left join ( select customer_isn,creditor_code from xdzx.loan_liability where status = '1' and product_code <> '15AC' group by customer_isn,creditor_code UNION select t2.nfaacsno,t1.primary_principal from xdzx.loan_duty t1 left join odsdb.blfmconf t2 on t1.contract_no = t2.nfaacono where t1.status='1' and t1.product_code ='15AC' group by t2.nfaacsno,t1.primary_principal ) t2 on t1.staff_id = t2.creditor_code ) select cret_no FROM ( select t1.cret_no,t2.customer_isn from ccdi_bank_statement t1 inner join cust_loan_duty t2 on t1.cret_no = t2.id_card left join ( select CINOCSNO ,DFANAC19 ,CDNOAC19 from odsdb.BWFMDCIM where rcstrs1b <> '9' and del_f = '0' and OWONBRNO like '902%' group by CINOCSNO,DFANAC19,CDNOAC19 ) t3 on t2.customer_isn = t3.CINOCSNO where project_id = PROJECT_ID and (t1.customer_account_no = t3.DFANAC19--账号 or t1.customer_account_no = t3.CDNOAC19)--卡号 group by t1.cret_no,t2.customer_isn having t1.amount_cr +t1.amount_dr > 0 ) group by cret_no having count(customer_isn) > 2 --交易贷款客户超过两人 ;
11 9 可疑采购 1.提示可能化整为零的采购 2.提示向同一企业或同一人实控的企业采购集中度过高的情况 单笔采购金额超过 10万元;提示向同一企业或同一人实控的企业采购集中度过高,单个供应商采购额占总采购额比例超过 70%。 --单笔采购金额大于10万 select t1.id_card from ccdi_base_staff t1 inner join ( select applicant_id from ccdi_purchase_transaction where actual_amount>100000 UNION select purchase_leader_id from ccdi_purchase_transaction where actual_amount>100000 ) t2 on t1.staff_id = t2.applicant_id ; ----单个供应商采购金额占项目总采购额超百分之70 with project_total as ( select purchase_id ,sum(actual_amount) as total_amount from ccdi_purchase_transaction group by purchase_id ), supplier_project AS ( select t1.applicant_id,t1.purchase_leader_id from ( select applicant_id ,purchase_leader_id ,purchase_id ,supplier_uscc ,sum(actual_amount) as supply_amount from ccdi_purchase_transaction group by applicant_id,purchase_leader_id,purchase_id,supplier_uscc ) t1 left join project_total t2 ON t1.purchase_id = t2.purchase_id where t1.supply_amount / t2.total_amount > 0.7 ) select t2.id_card ( select applicant_id from supplier_project union select purchase_leader_id from supplier_project ) t1 inner join ccdi_base_staff t2 on t1.applicant_id = t2.staff_id;
12 10 异常行为 1.每天长时间电话、频繁电话、微信电话 2.丰收互联交易IP地址与属地IP地址段不匹配 3.家庭老人、非家庭关系人银证大额转账 4.微信支付宝频繁提现 5.工资发放后立即转出大部分资金的行为 6.工资发放后除代扣项目外,几乎不使用的情况 7.涉诉情况 8.大额炒股 9.操控他人账户交易 1.每天电话、微信通话时长超过 2小时 或同一对像通话次数超过5次。 2.丰收互联交易IP地址与属地IP地址段不匹配。 3.家庭老人、非家庭关系人银证大额转账,单笔超过设置限额。 4.微信、支付宝单日提现次数超过设置次 或单日累计提现金额超过 设置限额。 5.工资发放后 24小时内 转出超过 80% 的资金。 6.工资发放后除代扣项目外,连续30天 无任何消费或转账记录。 7.大额炒股,单次三方资管交易金额超过 100万元。 8.多次代理他人账户交易,或登录员工手机操作他人丰收互联交易。 --支付宝微信单日提现金额和笔数 select t1.id_card ,t2.trx_time ,count(1) as trans_cnt ,sum(amount_cr) as trans_amt from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and (user_memo rlike '财付通|微信零钱|微信|wechat|WeChat|Tenpay|支付宝|Alipay|提现' or customer_account_name rlike '财付通|微信零钱|微信|wechat|WeChat|Tenpay|支付宝|Alipay|提现') and amount_cr >= 0 group by t1.id_card,t2.trx_time; --员工 发工资后1天内转出80%以上 select t1.id_card from ( select t1.id_card ,t2.trx_time as sala_time ,t2.amount_cr from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and bank = 'ZJRCU' and (customer_account_name rlike '代发|工资' or user_memo rlike '代发|工资|分红|红利|奖金|薪酬|薪金|补贴|薪|年终奖|年金|加班费|劳务费|劳务外包|提成|劳务派遣|绩效|酬劳|批量代付|PAYROLL|SALA|CPF|directors.*fees' or cash_type rlike '代发|工资|劳务费' and amount_cr > 0 ) ) t1 left join ( select t1.id_card ,t2.trx_time ,t2.amount_dr from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and bank = 'ZJRCU' and amount_dr > 0 ) t2 on t1.id_card = t2.id_card where t2.trx_time between t1.sala_time and date_add(t1.sala_time,interval 1 day) group by t1.id_card having sum(t2.amount_dr) / sum(t1.amount_cr) >0.8 ; --炒股、单次三方资管交易金额超100万 select distinct id_card FROM ( select t1.id_card ,t2.amount_cr from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and (customer_account_name rlike '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|理财' or user_memo rlike '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|理财' or cash_type rlike '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|理财' ) and amount_dr > 1000000 );
13
14
15
16
17
18
19
20
21
22

209
assets/大额交易.csv Normal file
View File

@@ -0,0 +1,209 @@
序号,模型名称,核心异常点(展示在前端页面),业务口径,相关指标,指标英文名,风险筛查对象,技术口径,代码,限制阈值指标,可疑结果返回,风险等级
1.1,大额交易,房车消费支出交易,备注或对交易对手是房产公司、二手房、车辆销售公司、物业公司等。,购买车房支出金额,prop_exp_amt,员工本人及亲属,关联员工及其亲属所有账户ccdi_account_info 关联 ccdi_fmy_relation_person在 ccdi_bank_statement 中筛选 amount_dr>0 且对手方/摘要含房产/车产关键词,"sql<br>---员工及其亲属购买车房支出流水id
select t2.bank_statement_id
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and (user_memo rlike '(购|买).*房|(购|买).*车|车款|房款|首付|(房|车).*贷'
or customer_account_name rlike '汽车销售|汽车金融|4S店|汽贸|车行|房地产|置业|置地|地产|房产|不动产|链家|贝壳|我爱我家|房管局')
and amount_dr > 0
union all
select t2.bank_statement_id
from
ccdi_staff_fmy_relation t1
inner join
ccdi_bank_statement t2
on t1.relation_cert_no = t2.cret_no
where t1.status = 1
and t2.project_id = PROJECT_ID
and (user_memo rlike '(购|买).*房|(购|买).*车|车款|房款|首付|(房|车).*贷'
or customer_account_name rlike '汽车销售|汽车金融|4S店|汽贸|车行|房地产|置业|置地|地产|房产|不动产|链家|贝壳|我爱我家|房管局')
and amount_dr > 0
;",/,流水明细,一般
1.2,,税务支出交易,有税务支出记录,税务支出金额,tax_exp_amt,员工本人及亲属,员工及其亲属账户中,筛选 amount_dr>0 且摘要含税务关键词,"sql<br>----员工及其亲属税务支出流水id
select t2.bank_statement_id
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and (user_memo rlike '税务|缴税|税款'
or customer_account_name rlike '税务|税务局|国库|国家金库|财政')
and amount_dr > 0
union all
select t2.bank_statement_id
from
ccdi_staff_fmy_relation t1
inner join
ccdi_bank_statement t2
on t1.relation_cert_no = t2.cret_no
where t1.status = 1
and t2.project_id = PROJECT_ID
and (user_memo rlike '税务|缴税|税款'
or customer_account_name rlike '税务|税务局|国库|国家金库|财政')
and amount_dr > 0
;",/,流水明细,一般
1.3,,大额单笔收入,同一交易对手(除本人、家庭成员外、本单位代发工资)单笔超过设置限额超过设置限额的资金流入;,大额流入金额(单笔),SINGLE_TRANSACTION_AMOUNT,员工本人,员工账户中,筛选 amount_cr>0对手方名称不在该员工的家庭关系内排除工资代发按员工和对手方汇总金额判断单笔是否超限,"sql<br>--员工与同一交易对手非亲属的最大一笔收入交易流水id
select t1.bank_statement_id
from
(
select t1.id_card
,t2.bank_statement_id
,t2.customer_account_name
from ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and t2.le_account_name <> t2.customer_account_name
and not (customer_account_name = '浙江兰溪农村商业银行股份有限公司'
and (user_memo like '%代发%'
or user_memo like '%工资%'
or user_memo like '%奖金%'
or user_memo like '%薪酬%'
or user_memo like '%薪金%'
or user_memo like '%补贴%'
or user_memo like '%薪%'
or user_memo like '%年终奖%'
or user_memo like '%年金%'
or user_memo like '%加班费%'
or user_memo like '%劳务费%'
or user_memo like '%劳务外包%'
or user_memo like '%提成%'
or user_memo like '%劳务派遣%'
or user_memo like '%绩效%'
or user_memo like '%酬劳%'
or user_memo like '%PAYROLL%'
or user_memo like '%SALA%'
or user_memo like '%CPF%'
or user_memo like '%directors%fees%'
or user_memo like '%批量代付%'
or cash_type like '%代发%'
or cash_type like '%工资%'
or cash_type like '%劳务费%' ))
and amount_cr > 0
) t1
left join ccdi_staff_fmy_relation t2
on t1.id_card = t2.person_id
and t1.customer_account_name = t2.relation_name
where t2.person_id is null;",大额流入金额,流水明细,一般
新增,,累计收入超限,同一交易对手(除本人、家庭成员外、本单位代发工资)累计交易金额超过设置限额的资金流入;,累计流入金额(所有累计),CUMULATIVE_TRANSACTION_AMOUNT,员工本人,员工账户中,筛选 amount_cr>0对手方名称不在该员工的家庭关系内排除工资代发按员工和对手方汇总金额判断累计是否超限,"sql<br>--员工与同一交易对手(非亲属)的累计收入交易金额
select
t1.id_card
,t1.customer_account_name
,t1.sum_amount_cr
from
(
select t1.id_card
,customer_account_name
,sum(amount_cr) as sum_amount_cr
from ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and t2.le_account_name <> t2.customer_account_name
and not (customer_account_name = '浙江兰溪农村商业银行股份有限公司'
and (user_memo like '%代发%'
or user_memo like '%工资%'
or user_memo like '%奖金%'
or user_memo like '%薪酬%'
or user_memo like '%薪金%'
or user_memo like '%补贴%'
or user_memo like '%薪%'
or user_memo like '%年终奖%'
or user_memo like '%年金%'
or user_memo like '%加班费%'
or user_memo like '%劳务费%'
or user_memo like '%劳务外包%'
or user_memo like '%提成%'
or user_memo like '%劳务派遣%'
or user_memo like '%绩效%'
or user_memo like '%酬劳%'
or user_memo like '%PAYROLL%'
or user_memo like '%SALA%'
or user_memo like '%CPF%'
or user_memo like '%directors%fees%'
or user_memo like '%批量代付%'
or cash_type like '%代发%'
or cash_type like '%工资%'
or cash_type like '%劳务费%' ))
group by id_card,customer_account_name
having sum(amount_cr)>0
) t1
left join ccdi_staff_fmy_relation t2
on t1.id_card = t2.person_id
and t1.customer_account_name = t2.relation_name
where t2.person_id is null;
",累计流入金额,个人、累积金额,一般
1.4,,年流水交易额超限,年流水交易额超过设置限额,年交易金额,annual_turnover,员工本人,员工账户中,排除本人及亲属名称,统计一年内 amount_cr+amount_dr 总额,"sql<br>--员工年交易金额
select t1.id_card
,sum(trans_amount) as annual_trans_amount
from
(
select t1.id_card
,amount_dr + amount_cr as trans_amount
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and left(TRX_DATE,10) >= add_months(current_date(),-12) --近一年
and t2.le_account_name <> t2.customer_account_name --排除同名交易
) t1
left join ccdi_staff_fmy_relation t2
on t1.id_card = t2.person_id
and t1.customer_account_name = t2.relation_name
where t2.person_id is NULL
group by t1.id_card;",年交易金额,个人、累积金额,一般
1.5,,大额存现交易,大额存现,单笔超过设置限额;,大额存现金额(单笔),LARGE_CASH_DEPOSIT,员工本人,员工及其亲属账户中,筛选 现金存入,且单笔 amount_cr 超阈值,按员工汇总,"sql<br>---员工大额存现单流水id
select t2.bank_statement_id
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and amount_cr> $$$$ ---大额存现阈值参数
and (
(((user_memo like '%现金%' and user_memo not like '%金管理%' and user_memo not like '%金添利%' and user_memo not like '%现金利%' and user_memo not like '%现金宝%' and user_memo not like '%金分析%' ) or user_memo like '%存现%' or user_memo like '%现存%' or cash_type like '%现金%' or cash_type like '%存现%' or cash_type like '%现存%' or cash_type like '%金存入%' or user_memo like '%金存入%' or (user_memo like '%ATM%' and (user_memo like '%存款%' or user_memo like '%转入%')) or (cash_type like '%ATM%' and (cash_type like '%存款%' or cash_type like '%转入%'))) and (customer_account_name = '' or customer_account_name = '无' or customer_account_name like '%存现%') or user_memo like '%DEPOSIT%') or
((customer_account_name = '库存现金' or ((user_memo like '%现金存款%' or user_memo like '%自助存款%' or user_memo like '%CRS存款%' or cash_type like '%现金存款%' or cash_type like '%自助存款%' or cash_type like '%本行CRS存款%' or cash_type like '%柜面%' or user_memo like '%柜面%') and customer_account_name = '' )) or (customer_account_name = '现金' and user_memo not like '%借款%') or user_memo like '%本行ATM%')
)
;
",大额存现金额,流水明细,一般
,,短时间多次存现,短时间多次存现,次数超过设置限额,单日存现总次数,FREQUENT_CASH_DEPOSIT,员工本人,员工及其亲属账户中,按日统计现金存入次数超阈值,"sql<br>--员工单日大额存现次数
select t1.id_card
,left(t2.trx_time,10) as cash_trans_date
,count(1) as cash_count
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and amount_cr>$$$$ ---大额存现阈值参数
and (
(((user_memo like '%现金%' and user_memo not like '%金管理%' and user_memo not like '%金添利%' and user_memo not like '%现金利%' and user_memo not like '%现金宝%' and user_memo not like '%金分析%' ) or user_memo like '%存现%' or user_memo like '%现存%' or cash_type like '%现金%' or cash_type like '%存现%' or cash_type like '%现存%' or cash_type like '%金存入%' or user_memo like '%金存入%' or (user_memo like '%ATM%' and (user_memo like '%存款%' or user_memo like '%转入%')) or (cash_type like '%ATM%' and (cash_type like '%存款%' or cash_type like '%转入%'))) and (customer_account_name = '' or customer_account_name = '无' or customer_account_name like '%存现%') or user_memo like '%DEPOSIT%') or
((customer_account_name = '库存现金' or ((user_memo like '%现金存款%' or user_memo like '%自助存款%' or user_memo like '%CRS存款%' or cash_type like '%现金存款%' or cash_type like '%自助存款%' or cash_type like '%本行CRS存款%' or cash_type like '%柜面%' or user_memo like '%柜面%') and customer_account_name = '' )) or (customer_account_name = '现金' and user_memo not like '%借款%') or user_memo like '%本行ATM%')
)
group by t1.id_card,left(t2.trx_time,10)
;
",单日存现总次数,个人、日期、次数,一般
1.6,,大额转账交易,大额转账单笔超过设置限额,大额转账金额(单笔),large_tfr_cnt,员工本人,员工及其亲属账户中,筛选单笔 amount_dr 超金额阈值的数据,"--员工大额转账(排除同名转账)转出超阈值 流水id
select t2.bank_statement_id
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and amount_dr> $$$$$$ --大额转账阈值
and (customer_account_name rlike '转账' or user_memo rlike '转帐|转账|汇入|转存|红包|汇款|网转|转入' or cash_type rlike '转帐|转账|汇入|转存|红包|汇款|网转|转入')
and user_memo not like '%款%'
and t2.le_account_name <> t2.customer_account_name --排除同名交易
;",大额转账金额,流水明细,一般
1 序号 模型名称 核心异常点(展示在前端页面) 业务口径 相关指标 指标英文名 风险筛查对象 技术口径 代码 限制阈值指标 可疑结果返回 风险等级
2 1.1 大额交易 房车消费支出交易 备注或对交易对手是房产公司、二手房、车辆销售公司、物业公司等。 购买车房支出金额 prop_exp_amt 员工本人及亲属 关联员工及其亲属所有账户(ccdi_account_info 关联 ccdi_fmy_relation_person),在 ccdi_bank_statement 中筛选 amount_dr>0 且对手方/摘要含房产/车产关键词 sql<br>---员工及其亲属购买车房支出流水id select t2.bank_statement_id from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and (user_memo rlike '(购|买).*房|(购|买).*车|车款|房款|首付|(房|车).*贷' or customer_account_name rlike '汽车销售|汽车金融|4S店|汽贸|车行|房地产|置业|置地|地产|房产|不动产|链家|贝壳|我爱我家|房管局') and amount_dr > 0 union all select t2.bank_statement_id from ccdi_staff_fmy_relation t1 inner join ccdi_bank_statement t2 on t1.relation_cert_no = t2.cret_no where t1.status = 1 and t2.project_id = PROJECT_ID and (user_memo rlike '(购|买).*房|(购|买).*车|车款|房款|首付|(房|车).*贷' or customer_account_name rlike '汽车销售|汽车金融|4S店|汽贸|车行|房地产|置业|置地|地产|房产|不动产|链家|贝壳|我爱我家|房管局') and amount_dr > 0 ; / 流水明细 一般
3 1.2 税务支出交易 有税务支出记录 税务支出金额 tax_exp_amt 员工本人及亲属 员工及其亲属账户中,筛选 amount_dr>0 且摘要含税务关键词 sql<br>----员工及其亲属税务支出流水id select t2.bank_statement_id from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and (user_memo rlike '税务|缴税|税款' or customer_account_name rlike '税务|税务局|国库|国家金库|财政') and amount_dr > 0 union all select t2.bank_statement_id from ccdi_staff_fmy_relation t1 inner join ccdi_bank_statement t2 on t1.relation_cert_no = t2.cret_no where t1.status = 1 and t2.project_id = PROJECT_ID and (user_memo rlike '税务|缴税|税款' or customer_account_name rlike '税务|税务局|国库|国家金库|财政') and amount_dr > 0 ; / 流水明细 一般
4 1.3 大额单笔收入 同一交易对手(除本人、家庭成员外、本单位代发工资)单笔超过设置限额超过设置限额的资金流入; 大额流入金额(单笔) SINGLE_TRANSACTION_AMOUNT 员工本人 员工账户中,筛选 amount_cr>0,对手方名称不在该员工的家庭关系内,排除工资代发,按员工和对手方汇总金额,判断单笔是否超限 sql<br>--员工与同一交易对手(非亲属)的最大一笔收入交易流水id select t1.bank_statement_id from ( select t1.id_card ,t2.bank_statement_id ,t2.customer_account_name from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and t2.le_account_name <> t2.customer_account_name and not (customer_account_name = '浙江兰溪农村商业银行股份有限公司' and (user_memo like '%代发%' or user_memo like '%工资%' or user_memo like '%奖金%' or user_memo like '%薪酬%' or user_memo like '%薪金%' or user_memo like '%补贴%' or user_memo like '%薪%' or user_memo like '%年终奖%' or user_memo like '%年金%' or user_memo like '%加班费%' or user_memo like '%劳务费%' or user_memo like '%劳务外包%' or user_memo like '%提成%' or user_memo like '%劳务派遣%' or user_memo like '%绩效%' or user_memo like '%酬劳%' or user_memo like '%PAYROLL%' or user_memo like '%SALA%' or user_memo like '%CPF%' or user_memo like '%directors%fees%' or user_memo like '%批量代付%' or cash_type like '%代发%' or cash_type like '%工资%' or cash_type like '%劳务费%' )) and amount_cr > 0 ) t1 left join ccdi_staff_fmy_relation t2 on t1.id_card = t2.person_id and t1.customer_account_name = t2.relation_name where t2.person_id is null; 大额流入金额 流水明细 一般
5 新增 累计收入超限 同一交易对手(除本人、家庭成员外、本单位代发工资)累计交易金额超过设置限额的资金流入; 累计流入金额(所有累计) CUMULATIVE_TRANSACTION_AMOUNT 员工本人 员工账户中,筛选 amount_cr>0,对手方名称不在该员工的家庭关系内,排除工资代发,按员工和对手方汇总金额,判断累计是否超限 sql<br>--员工与同一交易对手(非亲属)的累计收入交易金额 select t1.id_card ,t1.customer_account_name ,t1.sum_amount_cr from ( select t1.id_card ,customer_account_name ,sum(amount_cr) as sum_amount_cr from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and t2.le_account_name <> t2.customer_account_name and not (customer_account_name = '浙江兰溪农村商业银行股份有限公司' and (user_memo like '%代发%' or user_memo like '%工资%' or user_memo like '%奖金%' or user_memo like '%薪酬%' or user_memo like '%薪金%' or user_memo like '%补贴%' or user_memo like '%薪%' or user_memo like '%年终奖%' or user_memo like '%年金%' or user_memo like '%加班费%' or user_memo like '%劳务费%' or user_memo like '%劳务外包%' or user_memo like '%提成%' or user_memo like '%劳务派遣%' or user_memo like '%绩效%' or user_memo like '%酬劳%' or user_memo like '%PAYROLL%' or user_memo like '%SALA%' or user_memo like '%CPF%' or user_memo like '%directors%fees%' or user_memo like '%批量代付%' or cash_type like '%代发%' or cash_type like '%工资%' or cash_type like '%劳务费%' )) group by id_card,customer_account_name having sum(amount_cr)>0 ) t1 left join ccdi_staff_fmy_relation t2 on t1.id_card = t2.person_id and t1.customer_account_name = t2.relation_name where t2.person_id is null; 累计流入金额 个人、累积金额 一般
6 1.4 年流水交易额超限 年流水交易额超过设置限额 年交易金额 annual_turnover 员工本人 员工账户中,排除本人及亲属名称,统计一年内 amount_cr+amount_dr 总额 sql<br>--员工年交易金额 select t1.id_card ,sum(trans_amount) as annual_trans_amount from ( select t1.id_card ,amount_dr + amount_cr as trans_amount from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and left(TRX_DATE,10) >= add_months(current_date(),-12) --近一年 and t2.le_account_name <> t2.customer_account_name --排除同名交易 ) t1 left join ccdi_staff_fmy_relation t2 on t1.id_card = t2.person_id and t1.customer_account_name = t2.relation_name where t2.person_id is NULL group by t1.id_card; 年交易金额 个人、累积金额 一般
7 1.5 大额存现交易 大额存现,单笔超过设置限额; 大额存现金额(单笔) LARGE_CASH_DEPOSIT 员工本人 员工及其亲属账户中,筛选 现金存入,且单笔 amount_cr 超阈值,按员工汇总 sql<br>---员工大额存现单流水id select t2.bank_statement_id from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and amount_cr> $$$$ ---大额存现阈值参数 and ( (((user_memo like '%现金%' and user_memo not like '%金管理%' and user_memo not like '%金添利%' and user_memo not like '%现金利%' and user_memo not like '%现金宝%' and user_memo not like '%金分析%' ) or user_memo like '%存现%' or user_memo like '%现存%' or cash_type like '%现金%' or cash_type like '%存现%' or cash_type like '%现存%' or cash_type like '%金存入%' or user_memo like '%金存入%' or (user_memo like '%ATM%' and (user_memo like '%存款%' or user_memo like '%转入%')) or (cash_type like '%ATM%' and (cash_type like '%存款%' or cash_type like '%转入%'))) and (customer_account_name = '' or customer_account_name = '无' or customer_account_name like '%存现%') or user_memo like '%DEPOSIT%') or ((customer_account_name = '库存现金' or ((user_memo like '%现金存款%' or user_memo like '%自助存款%' or user_memo like '%CRS存款%' or cash_type like '%现金存款%' or cash_type like '%自助存款%' or cash_type like '%本行CRS存款%' or cash_type like '%柜面%' or user_memo like '%柜面%') and customer_account_name = '' )) or (customer_account_name = '现金' and user_memo not like '%借款%') or user_memo like '%本行ATM%') ) ; 大额存现金额 流水明细 一般
8 短时间多次存现 短时间多次存现,次数超过设置限额 单日存现总次数 FREQUENT_CASH_DEPOSIT 员工本人 员工及其亲属账户中,按日统计现金存入次数超阈值 sql<br>--员工单日大额存现次数 select t1.id_card ,left(t2.trx_time,10) as cash_trans_date ,count(1) as cash_count from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and amount_cr>$$$$ ---大额存现阈值参数 and ( (((user_memo like '%现金%' and user_memo not like '%金管理%' and user_memo not like '%金添利%' and user_memo not like '%现金利%' and user_memo not like '%现金宝%' and user_memo not like '%金分析%' ) or user_memo like '%存现%' or user_memo like '%现存%' or cash_type like '%现金%' or cash_type like '%存现%' or cash_type like '%现存%' or cash_type like '%金存入%' or user_memo like '%金存入%' or (user_memo like '%ATM%' and (user_memo like '%存款%' or user_memo like '%转入%')) or (cash_type like '%ATM%' and (cash_type like '%存款%' or cash_type like '%转入%'))) and (customer_account_name = '' or customer_account_name = '无' or customer_account_name like '%存现%') or user_memo like '%DEPOSIT%') or ((customer_account_name = '库存现金' or ((user_memo like '%现金存款%' or user_memo like '%自助存款%' or user_memo like '%CRS存款%' or cash_type like '%现金存款%' or cash_type like '%自助存款%' or cash_type like '%本行CRS存款%' or cash_type like '%柜面%' or user_memo like '%柜面%') and customer_account_name = '' )) or (customer_account_name = '现金' and user_memo not like '%借款%') or user_memo like '%本行ATM%') ) group by t1.id_card,left(t2.trx_time,10) ; 单日存现总次数 个人、日期、次数 一般
9 1.6 大额转账交易 大额转账单笔超过设置限额 大额转账金额(单笔) large_tfr_cnt 员工本人 员工及其亲属账户中,筛选单笔 amount_dr 超金额阈值的数据 --员工大额转账(排除同名转账)转出超阈值 流水id select t2.bank_statement_id from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and amount_dr> $$$$$$ --大额转账阈值 and (customer_account_name rlike '转账' or user_memo rlike '转帐|转账|汇入|转存|红包|汇款|网转|转入' or cash_type rlike '转帐|转账|汇入|转存|红包|汇款|网转|转入') and user_memo not like '%款%' and t2.le_account_name <> t2.customer_account_name --排除同名交易 ; 大额转账金额 流水明细 一般

View File

@@ -0,0 +1,20 @@
id,project_id,model_code,model_name,param_code,param_name,param_desc,param_value,param_unit
1,0,LARGE_TRANSACTION,大额交易模型,SINGLE_TRANSACTION_AMOUNT,单笔大额收入金额,单笔收入超过该金额,1111,
2,0,LARGE_TRANSACTION,大额交易模型,CUMULATIVE_TRANSACTION_AMOUNT,累计大额收入金额,年累计收入超过该金额,50000001,
3,0,LARGE_TRANSACTION,大额交易模型,annual_turnover,年累计交易额,年累计交易额超过该金额,50000001,
4,0,LARGE_TRANSACTION,大额交易模型,LARGE_CASH_DEPOSIT,单笔大额存现金额,单笔存现金额超过,2000001,
5,0,LARGE_TRANSACTION,大额交易模型,FREQUENT_CASH_DEPOSIT,单日多次存现次数,24小时内累计存现超过,5,
6,0,LARGE_TRANSACTION,大额交易模型,FREQUENT_TRANSFER,单笔大额转账金额,单日转账次数超过,100001,次/日
,,,,,,,,
7,0,SUSPICIOUS_PART_TIME,可疑兼职模型,MONTHLY_FIXED_INCOME,月度非本行工资收入金额,"除本行工资外,每月固定收入超过",5000,元/月
8,0,SUSPICIOUS_PART_TIME,可疑兼职模型,FIXED_COUNTERPARTY_TRANSFER,季度稳定收入金额,每季从固定交易对手转入金额,15000,元/季
,,,,,,,,
10,0,SUSPICIOUS_FOREIGN_EXCHANGE,可疑外汇交易模型,SINGLE_PURCHASE_AMOUNT,单笔购汇金额,单笔购汇超过该金额,50000,美元/笔
11,0,SUSPICIOUS_FOREIGN_EXCHANGE,可疑外汇交易模型,SINGLE_SETTLEMENT_AMOUNT,单笔结汇金额,单笔结汇超过该金额,50000,美元/笔
12,0,SUSPICIOUS_FOREIGN_EXCHANGE,可疑外汇交易模型,CROSS_BORDER_REMITTANCE,跨境汇款金额,跨境汇款金额超过,200000,美元/笔
,,,,,,,,
,,ABNORMAL_BEHAVIOR,异常行为模型,stock_tfr_large,银证转账大额金额,,1000000,
,,ABNORMAL_BEHAVIOR,异常行为模型,withdraw_cnt,微信、支付宝单日提现次数,,3,次/日
,,ABNORMAL_BEHAVIOR,异常行为模型,withdraw_amt,微信、支付宝单日提现金额,,50000,元/日
,,SUSPICIOUS_GAMBLING,疑似赌博交易模型,multi_party_amt_min,疑似赌博金额下限,,500,
,,SUSPICIOUS_GAMBLING,疑似赌博交易模型,multi_party_amt_max,疑似赌博金额上限,,5000,
1 id project_id model_code model_name param_code param_name param_desc param_value param_unit
2 1 0 LARGE_TRANSACTION 大额交易模型 SINGLE_TRANSACTION_AMOUNT 单笔大额收入金额 单笔收入超过该金额 1111
3 2 0 LARGE_TRANSACTION 大额交易模型 CUMULATIVE_TRANSACTION_AMOUNT 累计大额收入金额 年累计收入超过该金额 50000001
4 3 0 LARGE_TRANSACTION 大额交易模型 annual_turnover 年累计交易额 年累计交易额超过该金额 50000001
5 4 0 LARGE_TRANSACTION 大额交易模型 LARGE_CASH_DEPOSIT 单笔大额存现金额 单笔存现金额超过 2000001
6 5 0 LARGE_TRANSACTION 大额交易模型 FREQUENT_CASH_DEPOSIT 单日多次存现次数 24小时内累计存现超过 5
7 6 0 LARGE_TRANSACTION 大额交易模型 FREQUENT_TRANSFER 单笔大额转账金额 单日转账次数超过 100001 次/日
8
9 7 0 SUSPICIOUS_PART_TIME 可疑兼职模型 MONTHLY_FIXED_INCOME 月度非本行工资收入金额 除本行工资外,每月固定收入超过 5000 元/月
10 8 0 SUSPICIOUS_PART_TIME 可疑兼职模型 FIXED_COUNTERPARTY_TRANSFER 季度稳定收入金额 每季从固定交易对手转入金额 15000 元/季
11
12 10 0 SUSPICIOUS_FOREIGN_EXCHANGE 可疑外汇交易模型 SINGLE_PURCHASE_AMOUNT 单笔购汇金额 单笔购汇超过该金额 50000 美元/笔
13 11 0 SUSPICIOUS_FOREIGN_EXCHANGE 可疑外汇交易模型 SINGLE_SETTLEMENT_AMOUNT 单笔结汇金额 单笔结汇超过该金额 50000 美元/笔
14 12 0 SUSPICIOUS_FOREIGN_EXCHANGE 可疑外汇交易模型 CROSS_BORDER_REMITTANCE 跨境汇款金额 跨境汇款金额超过 200000 美元/笔
15
16 ABNORMAL_BEHAVIOR 异常行为模型 stock_tfr_large 银证转账大额金额 1000000
17 ABNORMAL_BEHAVIOR 异常行为模型 withdraw_cnt 微信、支付宝单日提现次数 3 次/日
18 ABNORMAL_BEHAVIOR 异常行为模型 withdraw_amt 微信、支付宝单日提现金额 50000 元/日
19 SUSPICIOUS_GAMBLING 疑似赌博交易模型 multi_party_amt_min 疑似赌博金额下限 500
20 SUSPICIOUS_GAMBLING 疑似赌博交易模型 multi_party_amt_max 疑似赌博金额上限 5000

View File

@@ -0,0 +1,30 @@
package com.ruoyi.ccdi.project.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
/**
* 流水标签线程池配置
*/
@Configuration
public class BankTagThreadPoolConfig {
/**
* 规则级并行执行线程池
*
* @return 线程池执行器
*/
@Bean("tagRuleExecutor")
public Executor tagRuleExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("bank-tag-rule-");
executor.initialize();
return executor;
}
}

View File

@@ -0,0 +1,37 @@
package com.ruoyi.ccdi.project.controller;
import com.ruoyi.ccdi.project.domain.dto.CcdiBankTagRebuildDTO;
import com.ruoyi.ccdi.project.service.ICcdiBankTagService;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.utils.SecurityUtils;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 项目流水标签控制器
*/
@Tag(name = "项目流水标签")
@RestController
@RequestMapping("/ccdi/project/tags")
public class CcdiBankTagController extends BaseController {
@Resource
private ICcdiBankTagService bankTagService;
/**
* 手动提交流水标签重算任务
*/
@Operation(summary = "手动重算项目流水标签")
@PostMapping("/rebuild")
public AjaxResult rebuild(@Validated @RequestBody CcdiBankTagRebuildDTO dto) {
String operator = SecurityUtils.getUsername();
return success(bankTagService.submitRebuild(dto, operator));
}
}

View File

@@ -163,4 +163,15 @@ public class CcdiFileUploadController extends BaseController {
CcdiFileUploadRecord record = fileUploadService.getById(id);
return AjaxResult.success(record);
}
/**
* 删除上传记录
*/
@DeleteMapping("/{id}")
@Operation(summary = "删除上传文件", description = "按上传记录ID删除文件并清理流水")
public AjaxResult deleteFile(@PathVariable Long id) {
Long userId = SecurityUtils.getUserId();
String message = fileUploadService.deleteFileUploadRecord(id, userId);
return AjaxResult.success(message);
}
}

View File

@@ -0,0 +1,18 @@
package com.ruoyi.ccdi.project.domain.dto;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 流水标签重算请求参数
*/
@Data
public class CcdiBankTagRebuildDTO {
/** 项目ID */
@NotNull(message = "项目ID不能为空")
private Long projectId;
/** 模型编码 */
private String modelCode;
}

View File

@@ -0,0 +1,66 @@
package com.ruoyi.ccdi.project.domain.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
/**
* 流水标签结果实体
*/
@Data
@TableName("ccdi_bank_statement_tag_result")
public class CcdiBankTagResult implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
private Long id;
private Long projectId;
private String modelCode;
private String modelName;
private String ruleCode;
private String ruleName;
private String indicatorCode;
private String resultType;
private String riskLevel;
private Long bankStatementId;
private String objectType;
private String objectKey;
private Integer groupId;
private Integer logId;
private String reasonDetail;
private String businessCaliberSnapshot;
private String hitValueSnapshot;
private String createBy;
private Date createTime;
private String updateBy;
private Date updateTime;
private String remark;
}

View File

@@ -0,0 +1,54 @@
package com.ruoyi.ccdi.project.domain.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
/**
* 流水标签规则定义实体
*/
@Data
@TableName("ccdi_bank_tag_rule")
public class CcdiBankTagRule implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
private Long id;
private String modelCode;
private String modelName;
private String ruleCode;
private String ruleName;
private String indicatorCode;
private String resultType;
private String riskLevel;
private String businessCaliber;
private Integer enabled;
private Integer sortOrder;
private String createBy;
private Date createTime;
private String updateBy;
private Date updateTime;
private String remark;
}

View File

@@ -0,0 +1,54 @@
package com.ruoyi.ccdi.project.domain.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
/**
* 流水标签任务实体
*/
@Data
@TableName("ccdi_bank_tag_task")
public class CcdiBankTagTask implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
private Long id;
private Long projectId;
private String triggerType;
private String modelCode;
private String status;
private Integer needRerun;
private Integer successRuleCount;
private Integer failedRuleCount;
private Integer hitCount;
private String errorMessage;
private Date startTime;
private Date endTime;
private String createBy;
private Date createTime;
private String updateBy;
private Date updateTime;
}

View File

@@ -0,0 +1,16 @@
package com.ruoyi.ccdi.project.domain.enums;
/**
* 流水标签触发类型
*/
public enum TriggerType {
/** 自动批量上传 */
AUTO_BATCH_UPLOAD,
/** 自动拉取本行信息 */
AUTO_PULL_BANK_INFO,
/** 手动触发 */
MANUAL
}

View File

@@ -0,0 +1,19 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
/**
* 对象级标签命中结果
*/
@Data
public class BankTagObjectHitVO {
/** 对象类型 */
private String objectType;
/** 对象主键 */
private String objectKey;
/** 异常原因摘要 */
private String reasonDetail;
}

View File

@@ -0,0 +1,36 @@
package com.ruoyi.ccdi.project.domain.vo;
import com.ruoyi.ccdi.project.domain.entity.CcdiBankTagRule;
import lombok.Data;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 流水标签规则执行配置
*/
@Data
public class BankTagRuleExecutionConfig {
/** 项目ID */
private Long projectId;
/** 生效参数项目ID */
private Long effectiveProjectId;
/** 规则元数据 */
private CcdiBankTagRule ruleMeta;
/** 阈值配置 */
private Map<String, String> thresholdValues = new LinkedHashMap<>();
/**
* 获取阈值
*
* @param paramCode 参数编码
* @return 参数值
*/
public String getThresholdValue(String paramCode) {
return thresholdValues.get(paramCode);
}
}

View File

@@ -0,0 +1,22 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
/**
* 流水级标签命中结果
*/
@Data
public class BankTagStatementHitVO {
/** 流水ID */
private Long bankStatementId;
/** 项目分组ID */
private Integer groupId;
/** 上传日志ID */
private Integer logId;
/** 异常原因摘要 */
private String reasonDetail;
}

View File

@@ -29,6 +29,9 @@ public class CcdiFileUploadStatisticsVO implements Serializable {
/** 解析失败数量 */
private Long parsedFailed;
/** 已删除数量 */
private Long deleted;
/** 总数量 */
private Long total;
}

View File

@@ -0,0 +1,92 @@
package com.ruoyi.ccdi.project.mapper;
import com.ruoyi.ccdi.project.domain.vo.BankTagStatementHitVO;
import com.ruoyi.ccdi.project.domain.vo.BankTagObjectHitVO;
import org.apache.ibatis.annotations.Param;
import java.math.BigDecimal;
import java.util.List;
/**
* 流水标签分析 Mapper
*/
public interface CcdiBankTagAnalysisMapper {
/**
* 房车消费支出交易
*
* @param projectId 项目ID
* @return 流水命中结果
*/
List<BankTagStatementHitVO> selectHouseOrCarExpenseStatements(@Param("projectId") Long projectId);
/**
* 税务支出交易
*
* @param projectId 项目ID
* @return 流水命中结果
*/
List<BankTagStatementHitVO> selectTaxExpenseStatements(@Param("projectId") Long projectId);
/**
* 大额单笔收入
*
* @param projectId 项目ID
* @param threshold 单笔阈值
* @return 流水命中结果
*/
List<BankTagStatementHitVO> selectSingleLargeIncomeStatements(@Param("projectId") Long projectId,
@Param("threshold") BigDecimal threshold);
/**
* 累计收入超限对象
*
* @param projectId 项目ID
* @param threshold 累计阈值
* @return 对象命中结果
*/
List<BankTagObjectHitVO> selectCumulativeIncomeObjects(@Param("projectId") Long projectId,
@Param("threshold") BigDecimal threshold);
/**
* 年流水交易额超限对象
*
* @param projectId 项目ID
* @param threshold 年交易额阈值
* @return 对象命中结果
*/
List<BankTagObjectHitVO> selectAnnualTurnoverObjects(@Param("projectId") Long projectId,
@Param("threshold") BigDecimal threshold);
/**
* 大额存现交易
*
* @param projectId 项目ID
* @param threshold 存现阈值
* @return 流水命中结果
*/
List<BankTagStatementHitVO> selectLargeCashDepositStatements(@Param("projectId") Long projectId,
@Param("threshold") BigDecimal threshold);
/**
* 短时间多次存现对象
*
* @param projectId 项目ID
* @param amountThreshold 单笔存现阈值
* @param frequencyThreshold 频次阈值
* @return 对象命中结果
*/
List<BankTagObjectHitVO> selectFrequentCashDepositObjects(@Param("projectId") Long projectId,
@Param("amountThreshold") BigDecimal amountThreshold,
@Param("frequencyThreshold") Integer frequencyThreshold);
/**
* 大额转账交易
*
* @param projectId 项目ID
* @param threshold 转账阈值
* @return 流水命中结果
*/
List<BankTagStatementHitVO> selectLargeTransferStatements(@Param("projectId") Long projectId,
@Param("threshold") BigDecimal threshold);
}

View File

@@ -0,0 +1,30 @@
package com.ruoyi.ccdi.project.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.ccdi.project.domain.entity.CcdiBankTagResult;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 流水标签结果 Mapper
*/
public interface CcdiBankTagResultMapper extends BaseMapper<CcdiBankTagResult> {
/**
* 按项目和可选模型删除历史结果
*
* @param projectId 项目ID
* @param modelCode 模型编码
* @return 删除条数
*/
int deleteByProjectAndModel(@Param("projectId") Long projectId, @Param("modelCode") String modelCode);
/**
* 批量插入结果
*
* @param list 结果列表
* @return 插入条数
*/
int insertBatch(@Param("list") List<CcdiBankTagResult> list);
}

View File

@@ -0,0 +1,21 @@
package com.ruoyi.ccdi.project.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.ccdi.project.domain.entity.CcdiBankTagRule;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 流水标签规则 Mapper
*/
public interface CcdiBankTagRuleMapper extends BaseMapper<CcdiBankTagRule> {
/**
* 查询启用规则
*
* @param modelCode 模型编码
* @return 规则列表
*/
List<CcdiBankTagRule> selectEnabledRules(@Param("modelCode") String modelCode);
}

View File

@@ -0,0 +1,35 @@
package com.ruoyi.ccdi.project.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.ccdi.project.domain.entity.CcdiBankTagTask;
import org.apache.ibatis.annotations.Param;
/**
* 流水标签任务 Mapper
*/
public interface CcdiBankTagTaskMapper extends BaseMapper<CcdiBankTagTask> {
/**
* 新增任务
*
* @param task 任务实体
* @return 影响行数
*/
int insertTask(CcdiBankTagTask task);
/**
* 更新任务
*
* @param task 任务实体
* @return 影响行数
*/
int updateTask(CcdiBankTagTask task);
/**
* 查询项目运行中的任务
*
* @param projectId 项目ID
* @return 任务实体
*/
CcdiBankTagTask selectRunningTaskByProjectId(@Param("projectId") Long projectId);
}

View File

@@ -0,0 +1,27 @@
package com.ruoyi.ccdi.project.service;
import com.ruoyi.ccdi.project.domain.dto.CcdiBankTagRebuildDTO;
import com.ruoyi.ccdi.project.domain.enums.TriggerType;
/**
* 流水标签服务接口
*/
public interface ICcdiBankTagService {
/**
* 提交手动重算任务
*
* @param dto 重算请求
* @param operator 操作人
* @return 提示信息
*/
String submitRebuild(CcdiBankTagRebuildDTO dto, String operator);
/**
* 提交自动重算
*
* @param projectId 项目ID
* @param triggerType 触发方式
*/
void submitAutoRebuild(Long projectId, TriggerType triggerType);
}

View File

@@ -52,6 +52,15 @@ public interface ICcdiFileUploadService {
Long userId,
String username);
/**
* 删除上传记录并清理关联数据
*
* @param id 上传记录ID
* @param operatorUserId 当前操作用户ID
* @return 删除结果
*/
String deleteFileUploadRecord(Long id, Long operatorUserId);
/**
* 查询上传记录列表
*

View File

@@ -0,0 +1,77 @@
package com.ruoyi.ccdi.project.service.impl;
import com.ruoyi.ccdi.project.domain.CcdiModelParam;
import com.ruoyi.ccdi.project.domain.CcdiProject;
import com.ruoyi.ccdi.project.domain.entity.CcdiBankTagRule;
import com.ruoyi.ccdi.project.domain.vo.BankTagRuleExecutionConfig;
import com.ruoyi.ccdi.project.mapper.CcdiModelParamMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
import com.ruoyi.common.exception.ServiceException;
import org.springframework.stereotype.Component;
import jakarta.annotation.Resource;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 流水标签规则执行参数解析器
*/
@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")
);
@Resource
private CcdiProjectMapper projectMapper;
@Resource
private CcdiModelParamMapper modelParamMapper;
/**
* 解析规则执行配置
*
* @param projectId 项目ID
* @param ruleMeta 规则元数据
* @return 执行配置
*/
public BankTagRuleExecutionConfig resolve(Long projectId, CcdiBankTagRule ruleMeta) {
if (projectId == null) {
throw new ServiceException("项目ID不能为空");
}
if (ruleMeta == null) {
throw new ServiceException("规则信息不能为空");
}
CcdiProject project = projectMapper.selectById(projectId);
if (project == null) {
throw new ServiceException("项目不存在");
}
Long effectiveProjectId = "default".equals(project.getConfigType()) ? 0L : projectId;
List<CcdiModelParam> params = modelParamMapper.selectByProjectAndModel(effectiveProjectId, ruleMeta.getModelCode());
Map<String, String> thresholdValues = new LinkedHashMap<>();
Set<String> requiredParamCodes = RULE_PARAM_MAPPING.getOrDefault(ruleMeta.getRuleCode(), Set.of());
for (CcdiModelParam param : params) {
if (requiredParamCodes.contains(param.getParamCode())) {
thresholdValues.put(param.getParamCode(), param.getParamValue());
}
}
BankTagRuleExecutionConfig config = new BankTagRuleExecutionConfig();
config.setProjectId(projectId);
config.setEffectiveProjectId(effectiveProjectId);
config.setRuleMeta(ruleMeta);
config.setThresholdValues(thresholdValues);
return config;
}
}

View File

@@ -0,0 +1,265 @@
package com.ruoyi.ccdi.project.service.impl;
import com.ruoyi.ccdi.project.domain.dto.CcdiBankTagRebuildDTO;
import com.ruoyi.ccdi.project.domain.entity.CcdiBankTagResult;
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;
import com.ruoyi.ccdi.project.mapper.CcdiBankTagResultMapper;
import com.ruoyi.ccdi.project.mapper.CcdiBankTagRuleMapper;
import com.ruoyi.ccdi.project.mapper.CcdiBankTagTaskMapper;
import com.ruoyi.ccdi.project.service.ICcdiBankTagService;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import jakarta.annotation.Resource;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
/**
* 流水标签服务实现
*/
@Service
public class CcdiBankTagServiceImpl implements ICcdiBankTagService {
private static final String STATUS_RUNNING = "RUNNING";
private static final String STATUS_SUCCESS = "SUCCESS";
private static final String STATUS_FAILED = "FAILED";
private static final String RESULT_TYPE_STATEMENT = "STATEMENT";
private static final String OBJECT_TYPE_STAFF_ID_CARD = "STAFF_ID_CARD";
@Resource
private CcdiBankTagRuleMapper ruleMapper;
@Resource
private CcdiBankTagResultMapper resultMapper;
@Resource
private CcdiBankTagTaskMapper taskMapper;
@Resource
private CcdiBankTagAnalysisMapper analysisMapper;
@Resource
private BankTagRuleConfigResolver configResolver;
@Resource
@Qualifier("tagRuleExecutor")
private Executor tagRuleExecutor;
@Lazy
@Resource
private ProjectBankTagRebuildCoordinator coordinator;
@Override
public String submitRebuild(CcdiBankTagRebuildDTO dto, String operator) {
coordinator.submitManual(dto.getProjectId(), dto.getModelCode(), operator);
return "标签重算任务已提交";
}
/**
* 提交自动重算
*
* @param projectId 项目ID
* @param triggerType 触发方式
*/
public void submitAutoRebuild(Long projectId, TriggerType triggerType) {
coordinator.submitAuto(projectId, triggerType);
}
/**
* 执行项目标签重算
*
* @param projectId 项目ID
* @param modelCode 模型编码
* @param operator 操作人
* @param triggerType 触发方式
*/
public Long rebuildProject(Long projectId, String modelCode, String operator, TriggerType triggerType) {
CcdiBankTagTask task = buildRunningTask(projectId, modelCode, operator, triggerType);
taskMapper.insertTask(task);
try {
List<CcdiBankTagRule> rules = ruleMapper.selectEnabledRules(modelCode);
resultMapper.deleteByProjectAndModel(projectId, modelCode);
List<CompletableFuture<List<CcdiBankTagResult>>> futures = rules.stream()
.map(rule -> CompletableFuture.supplyAsync(
() -> executeRule(projectId, rule, operator),
tagRuleExecutor
))
.toList();
List<CcdiBankTagResult> allResults = futures.stream()
.map(CompletableFuture::join)
.flatMap(List::stream)
.toList();
if (!allResults.isEmpty()) {
resultMapper.insertBatch(allResults);
}
task.setStatus(STATUS_SUCCESS);
task.setSuccessRuleCount(rules.size());
task.setFailedRuleCount(0);
task.setHitCount(allResults.size());
task.setEndTime(new Date());
task.setNeedRerun(null);
task.setUpdateBy(operator);
task.setUpdateTime(new Date());
taskMapper.updateTask(task);
return task.getId();
} catch (Exception ex) {
task.setStatus(STATUS_FAILED);
task.setErrorMessage(ex.getMessage());
task.setEndTime(new Date());
task.setNeedRerun(null);
task.setUpdateBy(operator);
task.setUpdateTime(new Date());
taskMapper.updateTask(task);
throw ex;
}
}
private CcdiBankTagTask buildRunningTask(Long projectId, String modelCode, String operator, TriggerType triggerType) {
Date now = new Date();
CcdiBankTagTask task = new CcdiBankTagTask();
task.setProjectId(projectId);
task.setModelCode(modelCode);
task.setTriggerType(triggerType.name());
task.setStatus(STATUS_RUNNING);
task.setNeedRerun(0);
task.setSuccessRuleCount(0);
task.setFailedRuleCount(0);
task.setHitCount(0);
task.setStartTime(now);
task.setCreateBy(operator);
task.setCreateTime(now);
task.setUpdateBy(operator);
task.setUpdateTime(now);
return task;
}
private List<CcdiBankTagResult> executeRule(Long projectId, CcdiBankTagRule rule, String operator) {
BankTagRuleExecutionConfig config = configResolver.resolve(projectId, rule);
if (RESULT_TYPE_STATEMENT.equals(rule.getResultType())) {
List<BankTagStatementHitVO> hits = executeStatementRule(projectId, rule, config);
return buildStatementResults(projectId, rule, hits, operator);
}
List<BankTagObjectHitVO> hits = executeObjectRule(projectId, rule, config);
return buildObjectResults(projectId, rule, hits, operator);
}
private List<BankTagStatementHitVO> executeStatementRule(Long projectId,
CcdiBankTagRule rule,
BankTagRuleExecutionConfig config) {
return switch (rule.getRuleCode()) {
case "HOUSE_OR_CAR_EXPENSE" -> analysisMapper.selectHouseOrCarExpenseStatements(projectId);
case "TAX_EXPENSE" -> analysisMapper.selectTaxExpenseStatements(projectId);
case "SINGLE_LARGE_INCOME" -> analysisMapper.selectSingleLargeIncomeStatements(
projectId, toBigDecimal(config.getThresholdValue("SINGLE_TRANSACTION_AMOUNT"))
);
case "LARGE_CASH_DEPOSIT" -> analysisMapper.selectLargeCashDepositStatements(
projectId, toBigDecimal(config.getThresholdValue("LARGE_CASH_DEPOSIT"))
);
case "LARGE_TRANSFER" -> analysisMapper.selectLargeTransferStatements(
projectId, toBigDecimal(config.getThresholdValue("FREQUENT_TRANSFER"))
);
default -> List.of();
};
}
private List<BankTagObjectHitVO> executeObjectRule(Long projectId,
CcdiBankTagRule rule,
BankTagRuleExecutionConfig config) {
return switch (rule.getRuleCode()) {
case "CUMULATIVE_INCOME" -> analysisMapper.selectCumulativeIncomeObjects(
projectId, toBigDecimal(config.getThresholdValue("CUMULATIVE_TRANSACTION_AMOUNT"))
);
case "ANNUAL_TURNOVER" -> analysisMapper.selectAnnualTurnoverObjects(
projectId, toBigDecimal(config.getThresholdValue("annual_turnover"))
);
case "FREQUENT_CASH_DEPOSIT" -> analysisMapper.selectFrequentCashDepositObjects(
projectId,
toBigDecimal(config.getThresholdValue("LARGE_CASH_DEPOSIT")),
toInteger(config.getThresholdValue("FREQUENT_CASH_DEPOSIT"))
);
default -> List.of();
};
}
private List<CcdiBankTagResult> buildStatementResults(Long projectId,
CcdiBankTagRule rule,
List<BankTagStatementHitVO> hits,
String operator) {
List<CcdiBankTagResult> results = new ArrayList<>();
Date now = new Date();
for (BankTagStatementHitVO hit : hits) {
CcdiBankTagResult result = buildBaseResult(projectId, rule, operator, now);
result.setBankStatementId(hit.getBankStatementId());
result.setGroupId(hit.getGroupId());
result.setLogId(hit.getLogId());
result.setReasonDetail(hit.getReasonDetail());
results.add(result);
}
return results;
}
private List<CcdiBankTagResult> buildObjectResults(Long projectId,
CcdiBankTagRule rule,
List<BankTagObjectHitVO> hits,
String operator) {
List<CcdiBankTagResult> results = new ArrayList<>();
Date now = new Date();
for (BankTagObjectHitVO hit : hits) {
CcdiBankTagResult result = buildBaseResult(projectId, rule, operator, now);
result.setObjectType(hit.getObjectType() != null ? hit.getObjectType() : OBJECT_TYPE_STAFF_ID_CARD);
result.setObjectKey(hit.getObjectKey());
result.setReasonDetail(hit.getReasonDetail());
results.add(result);
}
return results;
}
private CcdiBankTagResult buildBaseResult(Long projectId, CcdiBankTagRule rule, String operator, Date now) {
CcdiBankTagResult result = new CcdiBankTagResult();
result.setProjectId(projectId);
result.setModelCode(rule.getModelCode());
result.setModelName(rule.getModelName());
result.setRuleCode(rule.getRuleCode());
result.setRuleName(rule.getRuleName());
result.setIndicatorCode(rule.getIndicatorCode());
result.setResultType(rule.getResultType());
result.setRiskLevel(rule.getRiskLevel());
result.setBusinessCaliberSnapshot(rule.getBusinessCaliber());
result.setCreateBy(operator);
result.setCreateTime(now);
result.setUpdateBy(operator);
result.setUpdateTime(now);
return result;
}
private BigDecimal toBigDecimal(String value) {
if (value == null || value.isBlank()) {
return BigDecimal.ZERO;
}
return new BigDecimal(value);
}
private Integer toInteger(String value) {
if (value == null || value.isBlank()) {
return 0;
}
return Integer.parseInt(value);
}
}

View File

@@ -4,6 +4,7 @@ import com.alibaba.excel.EasyExcel;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.ccdi.project.domain.CcdiProject;
import com.ruoyi.ccdi.project.domain.enums.TriggerType;
import com.ruoyi.ccdi.project.domain.dto.CcdiFileUploadQueryDTO;
import com.ruoyi.ccdi.project.domain.entity.CcdiBankStatement;
import com.ruoyi.ccdi.project.domain.entity.CcdiFileUploadRecord;
@@ -12,12 +13,14 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiFileUploadStatisticsVO;
import com.ruoyi.ccdi.project.mapper.CcdiBankStatementMapper;
import com.ruoyi.ccdi.project.mapper.CcdiFileUploadRecordMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
import com.ruoyi.ccdi.project.service.ICcdiBankTagService;
import com.ruoyi.ccdi.project.service.ICcdiFileUploadService;
import com.ruoyi.lsfx.client.LsfxAnalysisClient;
import com.ruoyi.lsfx.constants.LsfxConstants;
import com.ruoyi.lsfx.domain.request.FetchInnerFlowRequest;
import com.ruoyi.lsfx.domain.request.GetBankStatementRequest;
import com.ruoyi.lsfx.domain.request.GetFileUploadStatusRequest;
import com.ruoyi.lsfx.domain.request.DeleteFilesRequest;
import com.ruoyi.lsfx.domain.response.*;
import jakarta.annotation.Resource;
import lombok.Data;
@@ -90,6 +93,9 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
@Resource
private CcdiBankStatementMapper bankStatementMapper;
@Resource
private ICcdiBankTagService bankTagService;
/**
* 获取临时文件存储目录
*/
@@ -207,6 +213,30 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
return batchId;
}
@Override
public String deleteFileUploadRecord(Long id, Long operatorUserId) {
CcdiFileUploadRecord record = recordMapper.selectById(id);
validateDeleteRecord(record);
DeleteFilesRequest request = new DeleteFilesRequest();
request.setGroupId(record.getLsfxProjectId());
request.setLogIds(new Integer[]{record.getLogId()});
request.setUserId(toUploadUserId(operatorUserId));
DeleteFilesResponse response = lsfxClient.deleteFiles(request);
if (response == null || Boolean.FALSE.equals(response.getSuccessResponse())) {
throw new RuntimeException("流水分析平台删除文件失败");
}
bankStatementMapper.deleteByProjectIdAndBatchId(record.getProjectId(), record.getLogId());
CcdiFileUploadRecord update = new CcdiFileUploadRecord();
update.setId(record.getId());
update.setFileStatus("deleted");
recordMapper.updateById(update);
return "删除成功";
}
@Override
public Page<CcdiFileUploadRecord> selectPage(Page<CcdiFileUploadRecord> page,
CcdiFileUploadQueryDTO queryDTO) {
@@ -249,6 +279,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
vo.setParsing(0L);
vo.setParsedSuccess(0L);
vo.setParsedFailed(0L);
vo.setDeleted(0L);
long total = 0L;
for (Map<String, Object> item : statusCounts) {
@@ -261,6 +292,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
case "parsing" -> vo.setParsing(count);
case "parsed_success" -> vo.setParsedSuccess(count);
case "parsed_failed" -> vo.setParsedFailed(count);
case "deleted" -> vo.setDeleted(count);
}
}
@@ -387,6 +419,8 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
String batchId) {
log.info("【文件上传】调度线程启动: projectId={}, batchId={}", projectId, batchId);
List<CompletableFuture<Boolean>> futures = new ArrayList<>();
// 循环提交任务
for (int i = 0; i < tempFilePaths.size(); i++) {
// Critical Fix #6: 检查线程中断状态
@@ -404,10 +438,11 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
while (!submitted && retryCount < 2) {
try {
// 尝试提交异步任务
CompletableFuture.runAsync(
CompletableFuture<Boolean> future = CompletableFuture.supplyAsync(
() -> processFileAsync(projectId, lsfxProjectId, tempFilePath, record.getId(), batchId, record),
fileUploadExecutor
);
futures.add(future);
submitted = true;
log.info("【文件上传】任务提交成功: fileName={}, recordId={}",
record.getFileName(), record.getId());
@@ -422,16 +457,24 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
Thread.currentThread().interrupt();
log.error("【文件上传】等待被中断: fileName={}", record.getFileName());
updateRecordStatus(record.getId(), "parsed_failed", "任务提交被中断");
futures.add(CompletableFuture.completedFuture(Boolean.FALSE));
break;
}
} else {
log.error("【文件上传】重试失败,放弃任务: fileName={}", record.getFileName());
updateRecordStatus(record.getId(), "parsed_failed", "系统繁忙,请稍后重试");
futures.add(CompletableFuture.completedFuture(Boolean.FALSE));
}
}
}
}
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
.whenComplete((unused, throwable) -> {
boolean anySuccess = futures.stream().anyMatch(future -> Boolean.TRUE.equals(future.getNow(Boolean.FALSE)));
handleTagRebuildAfterBatchCompletion(projectId, TriggerType.AUTO_BATCH_UPLOAD, anySuccess);
});
log.info("【文件上传】调度线程完成: projectId={}, batchId={}", projectId, batchId);
}
@@ -479,6 +522,8 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
String batchId) {
log.info("【拉取本行信息】调度线程启动: projectId={}, batchId={}", projectId, batchId);
List<CompletableFuture<Boolean>> futures = new ArrayList<>();
for (int i = 0; i < records.size(); i++) {
if (Thread.currentThread().isInterrupted()) {
log.warn("【拉取本行信息】调度线程被中断,停止提交剩余任务");
@@ -492,10 +537,11 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
while (!submitted && retryCount < 2) {
try {
CompletableFuture.runAsync(
CompletableFuture<Boolean> future = CompletableFuture.supplyAsync(
() -> processPullBankInfoAsync(projectId, lsfxProjectId, record, idCard, startDate, endDate),
fileUploadExecutor
);
futures.add(future);
submitted = true;
log.info("【拉取本行信息】任务提交成功: idCard={}, recordId={}", idCard, record.getId());
} catch (RejectedExecutionException e) {
@@ -508,23 +554,31 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
Thread.currentThread().interrupt();
log.error("【拉取本行信息】等待被中断: idCard={}", idCard);
updateRecordStatus(record.getId(), "parsed_failed", "任务提交被中断");
futures.add(CompletableFuture.completedFuture(Boolean.FALSE));
break;
}
} else {
log.error("【拉取本行信息】重试失败,放弃任务: idCard={}", idCard);
updateRecordStatus(record.getId(), "parsed_failed", "系统繁忙,请稍后重试");
futures.add(CompletableFuture.completedFuture(Boolean.FALSE));
}
}
}
}
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
.whenComplete((unused, throwable) -> {
boolean anySuccess = futures.stream().anyMatch(future -> Boolean.TRUE.equals(future.getNow(Boolean.FALSE)));
handleTagRebuildAfterBatchCompletion(projectId, TriggerType.AUTO_PULL_BANK_INFO, anySuccess);
});
}
public void processPullBankInfoAsync(Long projectId,
Integer lsfxProjectId,
CcdiFileUploadRecord record,
String idCard,
String startDate,
String endDate ) {
public boolean processPullBankInfoAsync(Long projectId,
Integer lsfxProjectId,
CcdiFileUploadRecord record,
String idCard,
String startDate,
String endDate ) {
try {
FetchInnerFlowRequest request = new FetchInnerFlowRequest();
request.setGroupId(lsfxProjectId);
@@ -546,9 +600,11 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
}
processRecordAfterLogIdReady(projectId, lsfxProjectId, record, logId);
return true;
} catch (Exception e) {
log.error("【拉取本行信息】处理失败: idCard={}, recordId={}", idCard, record.getId(), e);
updateFailedRecord(record, e.getMessage());
return false;
}
}
@@ -564,8 +620,8 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
* @param record 文件上传记录
*/
@Async("fileUploadExecutor")
public void processFileAsync(Long projectId, Integer lsfxProjectId, String tempFilePath,
Long recordId, String batchId, CcdiFileUploadRecord record) {
public boolean processFileAsync(Long projectId, Integer lsfxProjectId, String tempFilePath,
Long recordId, String batchId, CcdiFileUploadRecord record) {
log.info("【文件上传】开始处理文件: fileName={}, recordId={}, tempPath={}",
record.getFileName(), recordId, tempFilePath);
@@ -602,10 +658,12 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
log.info("【文件上传】文件上传成功: logId={}", logId);
processRecordAfterLogIdReady(projectId, lsfxProjectId, record, logId);
log.info("【文件上传】处理完成: fileName={}", record.getFileName());
return true;
} catch (Exception e) {
log.error("【文件上传】处理失败: fileName={}", record.getFileName(), e);
updateRecordStatus(recordId, "parsed_failed", e.getMessage());
return false;
} finally {
// 清理临时文件
try {
@@ -620,6 +678,13 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
}
}
private void handleTagRebuildAfterBatchCompletion(Long projectId, TriggerType triggerType, Boolean anySuccess) {
if (!Boolean.TRUE.equals(anySuccess)) {
return;
}
bankTagService.submitAutoRebuild(projectId, triggerType);
}
private void processRecordAfterLogIdReady(Long projectId,
Integer lsfxProjectId,
CcdiFileUploadRecord record,
@@ -861,6 +926,24 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
bankStatementMapper.deleteByProjectIdAndBatchId(projectId, logId);
}
private void validateDeleteRecord(CcdiFileUploadRecord record) {
if (record == null) {
throw new RuntimeException("上传记录不存在");
}
if (!"parsed_success".equals(record.getFileStatus())) {
if ("deleted".equals(record.getFileStatus())) {
throw new RuntimeException("文件已删除,请勿重复操作");
}
throw new RuntimeException("仅支持删除解析成功文件");
}
if (record.getLsfxProjectId() == null) {
throw new RuntimeException("缺少流水分析项目ID");
}
if (record.getLogId() == null) {
throw new RuntimeException("缺少文件logId");
}
}
private Integer toUploadUserId(Long userId) {
if (userId == null) {
throw new IllegalArgumentException("当前登录用户ID不能为空");

View File

@@ -27,7 +27,7 @@ import org.springframework.transaction.annotation.Transactional;
import jakarta.annotation.Resource;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@@ -49,25 +49,7 @@ public class CcdiModelParamServiceImpl implements ICcdiModelParamService {
@Override
public List<ModelListVO> selectModelList(Long projectId) {
log.info("selectModelList 被调用projectId={}", projectId);
if (projectId == null) {
projectId = 0L; // 默认查询系统级参数
}
// 如果是项目查询projectId > 0需要根据 configType 决定查询哪组参数
Long effectiveProjectId = projectId;
if (projectId > 0) {
// 查询项目信息
CcdiProject project = projectMapper.selectById(projectId);
log.info("查询到项目信息: projectId={}, configType={}", projectId,
project != null ? project.getConfigType() : "null");
if (project != null && "default".equals(project.getConfigType())) {
// 使用系统默认参数
effectiveProjectId = 0L;
log.info("项目使用默认配置切换到系统默认参数effectiveProjectId=0");
}
}
Long effectiveProjectId = resolveEffectiveProjectId(projectId, false);
log.info("准备查询模型列表effectiveProjectId={}", effectiveProjectId);
List<ModelListVO> result = new ArrayList<>();
@@ -86,38 +68,12 @@ public class CcdiModelParamServiceImpl implements ICcdiModelParamService {
@Override
public List<ModelParamVO> selectParamList(ModelParamQueryDTO queryDTO) {
// 1. 参数验证
Long projectId = queryDTO.getProjectId();
if (projectId == null) {
projectId = 0L;
}
// 2. 如果是项目查询projectId > 0需要根据 configType 决定查询哪组参数
Long effectiveProjectId = projectId;
if (projectId > 0) {
// 查询项目信息
CcdiProject project = projectMapper.selectById(projectId);
if (project == null) {
throw new ServiceException("项目不存在");
}
// 根据 configType 决定查询哪组参数
if ("default".equals(project.getConfigType())) {
// 使用系统默认参数
effectiveProjectId = 0L;
} else {
// 使用项目自定义参数
effectiveProjectId = projectId;
}
}
// 3. 查询参数列表
Long effectiveProjectId = resolveEffectiveProjectId(queryDTO.getProjectId(), true);
List<CcdiModelParam> params = modelParamMapper.selectByProjectAndModel(
effectiveProjectId,
queryDTO.getModelCode()
);
// 4. 转换为 VO
List<ModelParamVO> result = new ArrayList<>();
params.forEach(param -> {
ModelParamVO vo = new ModelParamVO();
@@ -145,31 +101,10 @@ public class CcdiModelParamServiceImpl implements ICcdiModelParamService {
Long projectId = saveDTO.getProjectId();
// 2. 如果是项目保存projectId > 0需要检查是否首次保存
if (projectId > 0) {
// 查询项目信息
CcdiProject project = projectMapper.selectById(projectId);
if (project == null) {
throw new ServiceException("项目不存在");
}
// 3. 如果是首次保存configType=default需要复制系统默认参数
if ("default".equals(project.getConfigType())) {
int copiedCount = copyDefaultParamsToProject(projectId, saveDTO.getModelCode());
if (copiedCount == 0) {
log.warn("系统默认参数为空projectId={}, modelCode={}",
projectId, saveDTO.getModelCode());
}
// 更新项目配置类型为 custom
project.setConfigType("custom");
projectMapper.updateById(project);
log.info("项目配置类型已更新为 customprojectId={}", projectId);
}
switchToCustomConfigIfNeeded(getRequiredProject(projectId));
}
// 4. 更新参数值
String username = SecurityUtils.getUsername();
for (ModelParamSaveDTO.ParamValueItem item : saveDTO.getParams()) {
int updated = modelParamMapper.updateParamValue(
@@ -194,74 +129,14 @@ public class CcdiModelParamServiceImpl implements ICcdiModelParamService {
}
}
/**
* 复制系统默认参数到项目
*
* @param projectId 项目ID
* @param modelCode 模型编码
* @return 复制的参数数量
*/
private int copyDefaultParamsToProject(Long projectId, String modelCode) {
// 查询系统默认参数
List<CcdiModelParam> defaultParams = modelParamMapper.selectByProjectAndModel(0L, modelCode);
if (defaultParams.isEmpty()) {
log.warn("系统默认参数为空modelCode={}", modelCode);
return 0;
}
// 复制到项目
String username = SecurityUtils.getUsername();
List<CcdiModelParam> projectParams = defaultParams.stream()
.map(param -> {
CcdiModelParam newParam = new CcdiModelParam();
BeanUtils.copyProperties(param, newParam);
newParam.setId(null); // 清空ID让数据库自动生成
newParam.setProjectId(projectId);
// 设置审计字段
newParam.setCreateBy(username);
newParam.setUpdateBy(username);
// create_time 和 update_time 由数据库 NOW() 自动设置
return newParam;
})
.collect(Collectors.toList());
// 批量插入
int count = modelParamMapper.insertBatch(projectParams);
log.info("复制系统默认参数到项目成功projectId={}, modelCode={}, count={}",
projectId, modelCode, count);
return count;
}
@Override
public ModelParamAllVO selectAllParams(Long projectId) {
// 1. 参数验证
if (projectId == null) {
projectId = 0L;
}
// 2. 如果是项目查询,根据 configType 决定查询哪组参数
Long effectiveProjectId = projectId;
if (projectId > 0) {
CcdiProject project = projectMapper.selectById(projectId);
if (project == null) {
throw new ServiceException("项目不存在");
}
if ("default".equals(project.getConfigType())) {
effectiveProjectId = 0L;
}
}
// 3. 查询所有模型的参数
Long effectiveProjectId = resolveEffectiveProjectId(projectId, true);
List<CcdiModelParam> allParams = modelParamMapper.selectByProjectId(effectiveProjectId);
// 4. 按模型分组
Map<String, List<CcdiModelParam>> groupedParams = allParams.stream()
.collect(Collectors.groupingBy(CcdiModelParam::getModelCode));
.collect(Collectors.groupingBy(CcdiModelParam::getModelCode, LinkedHashMap::new, Collectors.toList()));
// 5. 转换为VO
ModelParamAllVO result = new ModelParamAllVO();
List<ModelGroupVO> models = new ArrayList<>();
@@ -282,7 +157,6 @@ public class CcdiModelParamServiceImpl implements ICcdiModelParamService {
models.add(groupVO);
});
// 6. 按模型编码排序(保证固定顺序)
models.sort(Comparator.comparing(ModelGroupVO::getModelCode));
result.setModels(models);
@@ -303,63 +177,15 @@ public class CcdiModelParamServiceImpl implements ICcdiModelParamService {
Long projectId = saveAllDTO.getProjectId();
// 2. 如果是项目保存,检查是否需要复制默认参数
if (projectId > 0) {
CcdiProject project = projectMapper.selectById(projectId);
if (project == null) {
throw new ServiceException("项目不存在");
}
// 如果是首次保存configType=default),需要复制所有模型的系统默认参数
if ("default".equals(project.getConfigType())) {
// 1. 查询所有系统默认参数(所有模型的所有参数)
List<CcdiModelParam> allDefaultParams = modelParamMapper.selectByProjectId(0L);
if (allDefaultParams.isEmpty()) {
log.warn("系统默认参数为空");
return;
}
// 2. 批量复制所有默认参数到项目
String username = SecurityUtils.getUsername();
List<CcdiModelParam> projectParams = new ArrayList<>();
for (CcdiModelParam param : allDefaultParams) {
CcdiModelParam newParam = new CcdiModelParam();
BeanUtils.copyProperties(param, newParam);
newParam.setId(null);
newParam.setProjectId(projectId);
// 设置审计字段
newParam.setCreateBy(username);
newParam.setUpdateBy(username);
// create_time 和 update_time 由数据库 NOW() 自动设置
projectParams.add(newParam);
}
// 3. 批量插入
modelParamMapper.insertBatch(projectParams);
log.info("复制所有系统默认参数到项目成功, projectId={}, count={}",
projectId, projectParams.size());
// 更新项目配置类型为 custom
project.setConfigType("custom");
projectMapper.updateById(project);
}
switchToCustomConfigIfNeeded(getRequiredProject(projectId));
}
// 3. 批量更新所有模型的参数值(性能优化版本)
String username = SecurityUtils.getUsername();
List<CcdiModelParam> updateList = new ArrayList<>();
// 3.1 收集需要更新的参数
for (ModelParamGroupDTO modelGroup : saveAllDTO.getModels()) {
for (ParamValueItem item : modelGroup.getParams()) {
// 查询参数ID用于批量更新
CcdiModelParam queryParam = new CcdiModelParam();
queryParam.setProjectId(projectId);
queryParam.setModelCode(modelGroup.getModelCode());
queryParam.setParamCode(item.getParamCode());
// 使用 MyBatis Plus 查询
CcdiModelParam existingParam = modelParamMapper.selectOne(
new LambdaQueryWrapper<CcdiModelParam>()
.eq(CcdiModelParam::getProjectId, projectId)
@@ -378,7 +204,6 @@ public class CcdiModelParamServiceImpl implements ICcdiModelParamService {
}
}
// 3.2 批量更新(一次 SQL 执行)
if (!updateList.isEmpty()) {
modelParamMapper.batchUpdateParamValues(updateList);
log.info("批量更新参数成功, count={}", updateList.size());
@@ -390,4 +215,73 @@ public class CcdiModelParamServiceImpl implements ICcdiModelParamService {
throw new ServiceException("批量保存模型参数失败:" + e.getMessage());
}
}
private Long resolveEffectiveProjectId(Long projectId, boolean failWhenProjectMissing) {
if (projectId == null || projectId <= 0) {
return 0L;
}
CcdiProject project = projectMapper.selectById(projectId);
log.info("查询到项目信息: projectId={}, configType={}", projectId,
project != null ? project.getConfigType() : "null");
if (project == null) {
if (failWhenProjectMissing) {
throw new ServiceException("项目不存在");
}
return projectId;
}
return "default".equals(project.getConfigType()) ? 0L : projectId;
}
private CcdiProject getRequiredProject(Long projectId) {
CcdiProject project = projectMapper.selectById(projectId);
if (project == null) {
throw new ServiceException("项目不存在");
}
return project;
}
private void switchToCustomConfigIfNeeded(CcdiProject project) {
if (!"default".equals(project.getConfigType())) {
return;
}
int copiedCount = copyAllDefaultParamsToProject(project.getProjectId());
if (copiedCount == 0) {
log.warn("系统默认参数为空projectId={}", project.getProjectId());
return;
}
project.setConfigType("custom");
projectMapper.updateById(project);
log.info("项目配置类型已更新为 customprojectId={}", project.getProjectId());
}
private int copyAllDefaultParamsToProject(Long projectId) {
List<CcdiModelParam> defaultParams = modelParamMapper.selectByProjectId(0L);
if (defaultParams.isEmpty()) {
return 0;
}
String username = SecurityUtils.getUsername();
List<CcdiModelParam> projectParams = defaultParams.stream()
.map(param -> buildProjectParam(param, projectId, username))
.collect(Collectors.toList());
int count = modelParamMapper.insertBatch(projectParams);
log.info("复制所有系统默认参数到项目成功projectId={}, count={}", projectId, count);
return count;
}
private CcdiModelParam buildProjectParam(CcdiModelParam source, Long projectId, String username) {
CcdiModelParam target = new CcdiModelParam();
BeanUtils.copyProperties(source, target);
target.setId(null);
target.setProjectId(projectId);
target.setCreateBy(username);
target.setUpdateBy(username);
return target;
}
}

View File

@@ -0,0 +1,100 @@
package com.ruoyi.ccdi.project.service.impl;
import com.ruoyi.ccdi.project.domain.entity.CcdiBankTagTask;
import com.ruoyi.ccdi.project.domain.enums.TriggerType;
import com.ruoyi.ccdi.project.mapper.CcdiBankTagTaskMapper;
import com.ruoyi.common.exception.ServiceException;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;
import jakarta.annotation.Resource;
import java.util.concurrent.ConcurrentHashMap;
/**
* 项目级流水标签重算协调器
*/
@Component
public class ProjectBankTagRebuildCoordinator {
private final ConcurrentHashMap<Long, Boolean> runningProjects = new ConcurrentHashMap<>();
@Resource
private CcdiBankTagTaskMapper taskMapper;
@Lazy
@Resource
private CcdiBankTagServiceImpl bankTagService;
/**
* 提交手动重算
*
* @param projectId 项目ID
* @param modelCode 模型编码
* @param operator 操作人
*/
public void submitManual(Long projectId, String modelCode, String operator) {
if (isProjectRunning(projectId)) {
throw new ServiceException("当前项目标签正在重算中,请稍后再试");
}
executeWithLock(projectId, () -> bankTagService.rebuildProject(projectId, modelCode, operator, TriggerType.MANUAL));
}
/**
* 提交自动重算
*
* @param projectId 项目ID
* @param triggerType 触发类型
*/
public void submitAuto(Long projectId, TriggerType triggerType) {
CcdiBankTagTask runningTask = taskMapper.selectRunningTaskByProjectId(projectId);
if (runningTask != null || runningProjects.containsKey(projectId)) {
markNeedRerun(runningTask);
return;
}
executeWithLock(projectId, () -> {
boolean needRerun;
do {
Long taskId = bankTagService.rebuildProject(projectId, null, "system", triggerType);
needRerun = taskId != null && consumeNeedRerun(taskId);
} while (needRerun);
});
}
private void executeWithLock(Long projectId, Runnable action) {
if (runningProjects.putIfAbsent(projectId, Boolean.TRUE) != null) {
throw new ServiceException("当前项目标签正在重算中,请稍后再试");
}
try {
action.run();
} finally {
runningProjects.remove(projectId);
}
}
private boolean isProjectRunning(Long projectId) {
return runningProjects.containsKey(projectId) || taskMapper.selectRunningTaskByProjectId(projectId) != null;
}
private void markNeedRerun(CcdiBankTagTask runningTask) {
if (runningTask == null) {
return;
}
runningTask.setNeedRerun(1);
taskMapper.updateTask(runningTask);
}
private boolean consumeNeedRerun(Long taskId) {
CcdiBankTagTask finishedTask = taskMapper.selectById(taskId);
if (finishedTask == null || finishedTask.getNeedRerun() == null || finishedTask.getNeedRerun() == 0) {
return false;
}
CcdiBankTagTask update = new CcdiBankTagTask();
update.setId(taskId);
update.setNeedRerun(0);
taskMapper.updateTask(update);
return true;
}
}

View File

@@ -0,0 +1,366 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.ccdi.project.mapper.CcdiBankTagAnalysisMapper">
<resultMap id="BankTagStatementHitResultMap" type="com.ruoyi.ccdi.project.domain.vo.BankTagStatementHitVO">
<id property="bankStatementId" column="bankStatementId"/>
<result property="groupId" column="groupId"/>
<result property="logId" column="logId"/>
<result property="reasonDetail" column="reasonDetail"/>
</resultMap>
<resultMap id="BankTagObjectHitResultMap" type="com.ruoyi.ccdi.project.domain.vo.BankTagObjectHitVO">
<result property="objectType" column="objectType"/>
<result property="objectKey" column="objectKey"/>
<result property="reasonDetail" column="reasonDetail"/>
</resultMap>
<sql id="statementHitColumns">
NULL AS bankStatementId,
NULL AS groupId,
NULL AS logId,
NULL AS reasonDetail
</sql>
<sql id="objectHitColumns">
NULL AS objectType,
NULL AS objectKey,
NULL AS reasonDetail
</sql>
<sql id="cashDepositPredicate">
(
(
(
(
IFNULL(bs.USER_MEMO, '') LIKE '%现金%'
and IFNULL(bs.USER_MEMO, '') NOT LIKE '%金管理%'
and IFNULL(bs.USER_MEMO, '') NOT LIKE '%金添利%'
and IFNULL(bs.USER_MEMO, '') NOT LIKE '%现金利%'
and IFNULL(bs.USER_MEMO, '') NOT LIKE '%现金宝%'
and IFNULL(bs.USER_MEMO, '') NOT LIKE '%金分析%'
)
or IFNULL(bs.USER_MEMO, '') LIKE '%存现%'
or IFNULL(bs.USER_MEMO, '') LIKE '%现存%'
or IFNULL(bs.CASH_TYPE, '') LIKE '%现金%'
or IFNULL(bs.CASH_TYPE, '') LIKE '%存现%'
or IFNULL(bs.CASH_TYPE, '') LIKE '%现存%'
or IFNULL(bs.CASH_TYPE, '') LIKE '%金存入%'
or IFNULL(bs.USER_MEMO, '') LIKE '%金存入%'
or (
IFNULL(bs.USER_MEMO, '') LIKE '%ATM%'
and (
IFNULL(bs.USER_MEMO, '') LIKE '%存款%'
or IFNULL(bs.USER_MEMO, '') LIKE '%转入%'
)
)
or (
IFNULL(bs.CASH_TYPE, '') LIKE '%ATM%'
and (
IFNULL(bs.CASH_TYPE, '') LIKE '%存款%'
or IFNULL(bs.CASH_TYPE, '') LIKE '%转入%'
)
)
)
and (
IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') = ''
or bs.CUSTOMER_ACCOUNT_NAME = '无'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') LIKE '%存现%'
)
)
or IFNULL(bs.USER_MEMO, '') LIKE '%DEPOSIT%'
or (
bs.CUSTOMER_ACCOUNT_NAME = '库存现金'
or (
(
IFNULL(bs.USER_MEMO, '') LIKE '%现金存款%'
or IFNULL(bs.USER_MEMO, '') LIKE '%自助存款%'
or IFNULL(bs.USER_MEMO, '') LIKE '%CRS存款%'
or IFNULL(bs.CASH_TYPE, '') LIKE '%现金存款%'
or IFNULL(bs.CASH_TYPE, '') LIKE '%自助存款%'
or IFNULL(bs.CASH_TYPE, '') LIKE '%本行CRS存款%'
or IFNULL(bs.CASH_TYPE, '') LIKE '%柜面%'
or IFNULL(bs.USER_MEMO, '') LIKE '%柜面%'
)
and IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') = ''
)
or (bs.CUSTOMER_ACCOUNT_NAME = '现金' and IFNULL(bs.USER_MEMO, '') NOT LIKE '%借款%')
or IFNULL(bs.USER_MEMO, '') LIKE '%本行ATM%'
)
)
</sql>
<sql id="salaryExclusionPredicate">
not (
bs.CUSTOMER_ACCOUNT_NAME = '浙江兰溪农村商业银行股份有限公司'
and (
IFNULL(bs.USER_MEMO, '') LIKE '%代发%'
or IFNULL(bs.USER_MEMO, '') LIKE '%工资%'
or IFNULL(bs.USER_MEMO, '') LIKE '%奖金%'
or IFNULL(bs.USER_MEMO, '') LIKE '%薪酬%'
or IFNULL(bs.USER_MEMO, '') LIKE '%薪金%'
or IFNULL(bs.USER_MEMO, '') LIKE '%补贴%'
or IFNULL(bs.USER_MEMO, '') LIKE '%薪%'
or IFNULL(bs.USER_MEMO, '') LIKE '%年终奖%'
or IFNULL(bs.USER_MEMO, '') LIKE '%年金%'
or IFNULL(bs.USER_MEMO, '') LIKE '%加班费%'
or IFNULL(bs.USER_MEMO, '') LIKE '%劳务费%'
or IFNULL(bs.USER_MEMO, '') LIKE '%劳务外包%'
or IFNULL(bs.USER_MEMO, '') LIKE '%提成%'
or IFNULL(bs.USER_MEMO, '') LIKE '%劳务派遣%'
or IFNULL(bs.USER_MEMO, '') LIKE '%绩效%'
or IFNULL(bs.USER_MEMO, '') LIKE '%酬劳%'
or IFNULL(bs.USER_MEMO, '') LIKE '%PAYROLL%'
or IFNULL(bs.USER_MEMO, '') LIKE '%SALA%'
or IFNULL(bs.USER_MEMO, '') LIKE '%CPF%'
or IFNULL(bs.USER_MEMO, '') LIKE '%directors%fees%'
or IFNULL(bs.USER_MEMO, '') LIKE '%批量代付%'
or IFNULL(bs.CASH_TYPE, '') LIKE '%代发%'
or IFNULL(bs.CASH_TYPE, '') LIKE '%工资%'
or IFNULL(bs.CASH_TYPE, '') LIKE '%劳务费%'
)
)
</sql>
<select id="selectHouseOrCarExpenseStatements" resultMap="BankTagStatementHitResultMap">
select
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
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 bs.project_id = #{projectId}
and IFNULL(bs.AMOUNT_DR, 0) > 0
and (
IFNULL(bs.USER_MEMO, '') REGEXP '(购|买).*房|(购|买).*车|车款|房款|首付|(房|车).*贷'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '汽车销售|汽车金融|4S店|汽贸|车行|房地产|置业|置地|地产|房产|不动产|链家|贝壳|我爱我家|房管局'
)
and (
exists (
select 1
from ccdi_base_staff staff
where staff.id_card = bs.cret_no
)
or exists (
select 1
from ccdi_staff_fmy_relation relation
where relation.relation_cert_no = bs.cret_no
and relation.status = 1
)
)
</select>
<select id="selectTaxExpenseStatements" resultMap="BankTagStatementHitResultMap">
select
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
CONCAT(
'摘要命中税务关键词,对手方“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''),
'”,支出金额 ', CAST(IFNULL(bs.AMOUNT_DR, 0) AS CHAR), ' 元'
) AS reasonDetail
from ccdi_bank_statement bs
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 '税务|税务局|国库|国家金库|财政'
)
and (
exists (
select 1
from ccdi_base_staff staff
where staff.id_card = bs.cret_no
)
or exists (
select 1
from ccdi_staff_fmy_relation relation
where relation.relation_cert_no = bs.cret_no
and relation.status = 1
)
)
</select>
<select id="selectSingleLargeIncomeStatements" resultMap="BankTagStatementHitResultMap">
select
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
CONCAT(
'同一交易对手“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''),
'”单笔流入 ', CAST(IFNULL(bs.AMOUNT_CR, 0) AS CHAR),
' 元,超过阈值 ', CAST(#{threshold} AS CHAR), ' 元'
) AS reasonDetail
from ccdi_bank_statement bs
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.AMOUNT_CR, 0) > #{threshold}
and IFNULL(bs.AMOUNT_CR, 0) > 0
and IFNULL(bs.LE_ACCOUNT_NAME, '') <> IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '')
and relation.person_id is null
and <include refid="salaryExclusionPredicate"/>
</select>
<select id="selectCumulativeIncomeObjects" resultMap="BankTagObjectHitResultMap">
select
'STAFF_ID_CARD' AS objectType,
t.idCard AS objectKey,
CONCAT(
'同一交易对手累计流入 ', CAST(t.totalAmount AS CHAR),
' 元,超过阈值 ', CAST(#{threshold} AS CHAR),
' 元,对手方:', IFNULL(t.customerAccountName, '')
) AS reasonDetail
from (
select
staff.id_card AS idCard,
bs.CUSTOMER_ACCOUNT_NAME AS customerAccountName,
SUM(IFNULL(bs.AMOUNT_CR, 0)) AS totalAmount
from ccdi_bank_statement bs
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.AMOUNT_CR, 0) > 0
and IFNULL(bs.LE_ACCOUNT_NAME, '') <> IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '')
and relation.person_id is null
and <include refid="salaryExclusionPredicate"/>
group by staff.id_card, bs.CUSTOMER_ACCOUNT_NAME
having SUM(IFNULL(bs.AMOUNT_CR, 0)) > #{threshold}
) t
</select>
<select id="selectAnnualTurnoverObjects" resultMap="BankTagObjectHitResultMap">
select
'STAFF_ID_CARD' AS objectType,
t.idCard AS objectKey,
CONCAT(
'近一年交易额 ', CAST(t.annualAmount AS CHAR),
' 元,超过阈值 ', CAST(#{threshold} AS CHAR), ' 元'
) AS reasonDetail
from (
select
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 ccdi_base_staff staff on staff.id_card = bs.cret_no
where bs.project_id = #{projectId}
and IFNULL(bs.LE_ACCOUNT_NAME, '') <> IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '')
and STR_TO_DATE(LEFT(TRIM(bs.TRX_DATE), 10), '%Y-%m-%d') >= DATE_SUB(CURDATE(), INTERVAL 12 MONTH)
group by staff.id_card
having SUM(IFNULL(bs.AMOUNT_DR, 0) + IFNULL(bs.AMOUNT_CR, 0)) > #{threshold}
) t
</select>
<select id="selectLargeCashDepositStatements" resultMap="BankTagStatementHitResultMap">
select
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
CONCAT(
'现金存入金额 ', CAST(IFNULL(bs.AMOUNT_CR, 0) AS CHAR),
' 元,超过阈值 ', CAST(#{threshold} AS CHAR), ' 元'
) AS reasonDetail
from ccdi_bank_statement bs
where bs.project_id = #{projectId}
and IFNULL(bs.AMOUNT_CR, 0) > #{threshold}
and <include refid="cashDepositPredicate"/>
and (
exists (
select 1
from ccdi_base_staff staff
where staff.id_card = bs.cret_no
)
or exists (
select 1
from ccdi_staff_fmy_relation relation
where relation.relation_cert_no = bs.cret_no
and relation.status = 1
)
)
</select>
<select id="selectFrequentCashDepositObjects" resultMap="BankTagObjectHitResultMap">
select
'STAFF_ID_CARD' AS objectType,
t.objectKey AS objectKey,
CONCAT(
'单日存现次数 ', CAST(t.cashCount AS CHAR),
' 次,超过阈值 ', CAST(#{frequencyThreshold} AS CHAR),
' 次,交易日:', t.cashDate
) AS reasonDetail
from (
select
source.object_key AS objectKey,
source.cash_date AS cashDate,
COUNT(1) AS cashCount
from (
select
staff.id_card AS object_key,
LEFT(TRIM(bs.TRX_DATE), 10) AS cash_date
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) > #{amountThreshold}
and <include refid="cashDepositPredicate"/>
union all
select
relation.person_id AS object_key,
LEFT(TRIM(bs.TRX_DATE), 10) AS cash_date
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_CR, 0) > #{amountThreshold}
and <include refid="cashDepositPredicate"/>
) source
group by source.object_key, source.cash_date
having COUNT(1) > #{frequencyThreshold}
) t
</select>
<select id="selectLargeTransferStatements" resultMap="BankTagStatementHitResultMap">
select
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
CONCAT(
'大额转账支出 ', CAST(IFNULL(bs.AMOUNT_DR, 0) AS CHAR),
' 元,超过阈值 ', CAST(#{threshold} AS CHAR), ' 元'
) AS reasonDetail
from ccdi_bank_statement bs
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 '转帐|转账|汇入|转存|红包|汇款|网转|转入'
)
and IFNULL(bs.USER_MEMO, '') NOT LIKE '%款%'
and IFNULL(bs.LE_ACCOUNT_NAME, '') <> IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '')
and (
exists (
select 1
from ccdi_base_staff staff
where staff.id_card = bs.cret_no
)
or exists (
select 1
from ccdi_staff_fmy_relation relation
where relation.relation_cert_no = bs.cret_no
and relation.status = 1
)
)
</select>
</mapper>

View File

@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.ccdi.project.mapper.CcdiBankTagResultMapper">
<resultMap id="CcdiBankTagResultMap" type="com.ruoyi.ccdi.project.domain.entity.CcdiBankTagResult">
<id property="id" column="id"/>
<result property="projectId" column="project_id"/>
<result property="modelCode" column="model_code"/>
<result property="modelName" column="model_name"/>
<result property="ruleCode" column="rule_code"/>
<result property="ruleName" column="rule_name"/>
<result property="indicatorCode" column="indicator_code"/>
<result property="resultType" column="result_type"/>
<result property="riskLevel" column="risk_level"/>
<result property="bankStatementId" column="bank_statement_id"/>
<result property="objectType" column="object_type"/>
<result property="objectKey" column="object_key"/>
<result property="groupId" column="group_id"/>
<result property="logId" column="log_id"/>
<result property="reasonDetail" column="reason_detail"/>
<result property="businessCaliberSnapshot" column="business_caliber_snapshot"/>
<result property="hitValueSnapshot" column="hit_value_snapshot"/>
<result property="createBy" column="create_by"/>
<result property="createTime" column="create_time"/>
<result property="updateBy" column="update_by"/>
<result property="updateTime" column="update_time"/>
<result property="remark" column="remark"/>
</resultMap>
<delete id="deleteByProjectAndModel">
delete from ccdi_bank_statement_tag_result
where project_id = #{projectId}
<if test="modelCode != null and modelCode != ''">
and model_code = #{modelCode}
</if>
</delete>
<insert id="insertBatch" parameterType="java.util.List">
insert into ccdi_bank_statement_tag_result (
project_id, model_code, model_name, rule_code, rule_name, indicator_code,
result_type, risk_level, bank_statement_id, object_type, object_key, group_id,
log_id, reason_detail, business_caliber_snapshot, hit_value_snapshot,
create_by, create_time, update_by, update_time, remark
) values
<foreach collection="list" item="item" separator=",">
(
#{item.projectId}, #{item.modelCode}, #{item.modelName}, #{item.ruleCode}, #{item.ruleName}, #{item.indicatorCode},
#{item.resultType}, #{item.riskLevel}, #{item.bankStatementId}, #{item.objectType}, #{item.objectKey}, #{item.groupId},
#{item.logId}, #{item.reasonDetail}, #{item.businessCaliberSnapshot}, #{item.hitValueSnapshot},
#{item.createBy}, #{item.createTime}, #{item.updateBy}, #{item.updateTime}, #{item.remark}
)
</foreach>
on duplicate key update
reason_detail = values(reason_detail),
business_caliber_snapshot = values(business_caliber_snapshot),
hit_value_snapshot = values(hit_value_snapshot),
update_by = values(update_by),
update_time = values(update_time),
remark = values(remark)
</insert>
</mapper>

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.ccdi.project.mapper.CcdiBankTagRuleMapper">
<resultMap id="CcdiBankTagRuleResultMap" type="com.ruoyi.ccdi.project.domain.entity.CcdiBankTagRule">
<id property="id" column="id"/>
<result property="modelCode" column="model_code"/>
<result property="modelName" column="model_name"/>
<result property="ruleCode" column="rule_code"/>
<result property="ruleName" column="rule_name"/>
<result property="indicatorCode" column="indicator_code"/>
<result property="resultType" column="result_type"/>
<result property="riskLevel" column="risk_level"/>
<result property="businessCaliber" column="business_caliber"/>
<result property="enabled" column="enabled"/>
<result property="sortOrder" column="sort_order"/>
<result property="createBy" column="create_by"/>
<result property="createTime" column="create_time"/>
<result property="updateBy" column="update_by"/>
<result property="updateTime" column="update_time"/>
<result property="remark" column="remark"/>
</resultMap>
<select id="selectEnabledRules" resultMap="CcdiBankTagRuleResultMap">
select id, model_code, model_name, rule_code, rule_name, indicator_code, result_type,
risk_level, business_caliber, enabled, sort_order, create_by, create_time,
update_by, update_time, remark
from ccdi_bank_tag_rule
where enabled = 1
<if test="modelCode != null and modelCode != ''">
and model_code = #{modelCode}
</if>
order by sort_order asc, id asc
</select>
</mapper>

View File

@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.ccdi.project.mapper.CcdiBankTagTaskMapper">
<resultMap id="CcdiBankTagTaskResultMap" type="com.ruoyi.ccdi.project.domain.entity.CcdiBankTagTask">
<id property="id" column="id"/>
<result property="projectId" column="project_id"/>
<result property="triggerType" column="trigger_type"/>
<result property="modelCode" column="model_code"/>
<result property="status" column="status"/>
<result property="needRerun" column="need_rerun"/>
<result property="successRuleCount" column="success_rule_count"/>
<result property="failedRuleCount" column="failed_rule_count"/>
<result property="hitCount" column="hit_count"/>
<result property="errorMessage" column="error_message"/>
<result property="startTime" column="start_time"/>
<result property="endTime" column="end_time"/>
<result property="createBy" column="create_by"/>
<result property="createTime" column="create_time"/>
<result property="updateBy" column="update_by"/>
<result property="updateTime" column="update_time"/>
</resultMap>
<insert id="insertTask" parameterType="com.ruoyi.ccdi.project.domain.entity.CcdiBankTagTask" useGeneratedKeys="true" keyProperty="id">
insert into ccdi_bank_tag_task (
project_id, trigger_type, model_code, status, need_rerun, success_rule_count,
failed_rule_count, hit_count, error_message, start_time, end_time,
create_by, create_time, update_by, update_time
) values (
#{projectId}, #{triggerType}, #{modelCode}, #{status}, #{needRerun}, #{successRuleCount},
#{failedRuleCount}, #{hitCount}, #{errorMessage}, #{startTime}, #{endTime},
#{createBy}, #{createTime}, #{updateBy}, #{updateTime}
)
</insert>
<update id="updateTask" parameterType="com.ruoyi.ccdi.project.domain.entity.CcdiBankTagTask">
update ccdi_bank_tag_task
<set>
<if test="triggerType != null">trigger_type = #{triggerType},</if>
<if test="modelCode != null">model_code = #{modelCode},</if>
<if test="status != null">status = #{status},</if>
<if test="needRerun != null">need_rerun = #{needRerun},</if>
<if test="successRuleCount != null">success_rule_count = #{successRuleCount},</if>
<if test="failedRuleCount != null">failed_rule_count = #{failedRuleCount},</if>
<if test="hitCount != null">hit_count = #{hitCount},</if>
<if test="errorMessage != null">error_message = #{errorMessage},</if>
<if test="startTime != null">start_time = #{startTime},</if>
<if test="endTime != null">end_time = #{endTime},</if>
<if test="updateBy != null">update_by = #{updateBy},</if>
<if test="updateTime != null">update_time = #{updateTime},</if>
</set>
where id = #{id}
</update>
<select id="selectRunningTaskByProjectId" resultMap="CcdiBankTagTaskResultMap">
select id, project_id, trigger_type, model_code, status, need_rerun, success_rule_count,
failed_rule_count, hit_count, error_message, start_time, end_time,
create_by, create_time, update_by, update_time
from ccdi_bank_tag_task
where project_id = #{projectId}
and status = 'RUNNING'
order by id desc
limit 1
</select>
</mapper>

View File

@@ -29,7 +29,7 @@
<select id="selectByProjectAndModel" resultMap="ModelParamResult">
<include refid="selectModelParamVo"/>
where project_id = #{projectId} and model_code = #{modelCode}
order by sort_order asc
order by sort_order asc, id asc
</select>
<select id="selectDistinctModels" resultMap="ModelParamResult">
@@ -43,7 +43,7 @@
<select id="selectByProjectId" resultType="CcdiModelParam">
<include refid="selectModelParamVo"/>
WHERE project_id = #{projectId}
ORDER BY model_code, sort_order
ORDER BY model_code, sort_order, id
</select>
<update id="batchUpdateParamValues">
update ccdi_model_param

View File

@@ -0,0 +1,45 @@
package com.ruoyi.ccdi.project.controller;
import com.ruoyi.ccdi.project.domain.dto.CcdiBankTagRebuildDTO;
import com.ruoyi.ccdi.project.service.ICcdiBankTagService;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.utils.SecurityUtils;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CcdiBankTagControllerTest {
@InjectMocks
private CcdiBankTagController controller;
@Mock
private ICcdiBankTagService bankTagService;
@Test
void rebuild_shouldDelegateProjectAndModelCode() {
CcdiBankTagRebuildDTO dto = new CcdiBankTagRebuildDTO();
dto.setProjectId(40L);
dto.setModelCode("LARGE_TRANSACTION");
when(bankTagService.submitRebuild(dto, "admin")).thenReturn("标签重算任务已提交");
try (MockedStatic<SecurityUtils> mocked = mockStatic(SecurityUtils.class)) {
mocked.when(SecurityUtils::getUsername).thenReturn("admin");
AjaxResult result = controller.rebuild(dto);
assertEquals(200, result.get("code"));
verify(bankTagService).submitRebuild(dto, "admin");
}
}
}

View File

@@ -16,6 +16,7 @@ import java.nio.charset.StandardCharsets;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
@@ -64,4 +65,19 @@ class CcdiFileUploadControllerTest {
assertEquals(200, result.get("code"));
}
}
@Test
void deleteFile_shouldUseCurrentLoginUserId() {
try (MockedStatic<SecurityUtils> mocked = mockStatic(SecurityUtils.class)) {
mocked.when(SecurityUtils::getUserId).thenReturn(9527L);
when(fileUploadService.deleteFileUploadRecord(123L, 9527L))
.thenReturn("删除成功");
AjaxResult result = controller.deleteFile(123L);
assertEquals(200, result.get("code"));
assertEquals("删除成功", result.get("msg"));
verify(fileUploadService).deleteFileUploadRecord(123L, 9527L);
}
}
}

View File

@@ -0,0 +1,24 @@
package com.ruoyi.ccdi.project.domain.entity;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class CcdiBankTagEntityMappingTest {
@Test
void bankTagResult_shouldExposeStatementAndObjectFields() {
CcdiBankTagResult result = new CcdiBankTagResult();
result.setProjectId(40L);
result.setRuleCode("RULE_1");
result.setBankStatementId(10L);
result.setGroupId(40);
result.setLogId(40001);
result.setObjectType("STAFF_ID_CARD");
result.setObjectKey("330101198801010011");
assertEquals(40L, result.getProjectId());
assertEquals(40001, result.getLogId());
assertEquals("STAFF_ID_CARD", result.getObjectType());
}
}

View File

@@ -0,0 +1,47 @@
package com.ruoyi.ccdi.project.mapper;
import org.junit.jupiter.api.Test;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CcdiBankTagAnalysisMapperXmlTest {
private static final String RESOURCE = "mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml";
@Test
void statementRuleSql_shouldSelectGroupIdAndLogId() throws Exception {
String xml = readXml(RESOURCE);
assertTrue(xml.contains("AS groupId"));
assertTrue(xml.contains("AS logId"));
}
@Test
void houseOrCarExpenseRule_shouldJoinBankStatementAndReturnStatementHitFields() throws Exception {
String xml = readXml(RESOURCE);
assertTrue(xml.contains("selectHouseOrCarExpenseStatements"));
assertTrue(xml.contains("bs.bank_statement_id AS bankStatementId"));
assertTrue(xml.contains("bs.group_id AS groupId"));
assertTrue(xml.contains("bs.batch_id AS logId"));
}
@Test
void allLargeTransactionRules_shouldExistInAnalysisMapperXml() throws Exception {
String xml = readXml(RESOURCE);
assertTrue(xml.contains("selectTaxExpenseStatements"));
assertTrue(xml.contains("selectSingleLargeIncomeStatements"));
assertTrue(xml.contains("selectCumulativeIncomeObjects"));
assertTrue(xml.contains("selectAnnualTurnoverObjects"));
assertTrue(xml.contains("selectLargeCashDepositStatements"));
assertTrue(xml.contains("selectFrequentCashDepositObjects"));
assertTrue(xml.contains("selectLargeTransferStatements"));
}
private String readXml(String resource) throws Exception {
try (InputStream inputStream = getClass().getClassLoader().getResourceAsStream(resource)) {
return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
}
}
}

View File

@@ -0,0 +1,23 @@
package com.ruoyi.ccdi.project.mapper;
import org.junit.jupiter.api.Test;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CcdiBankTagResultMapperXmlTest {
private static final String RESOURCE = "mapper/ccdi/project/CcdiBankTagResultMapper.xml";
@Test
void deleteByProjectAndOptionalModel_shouldRenderScopedDelete() throws Exception {
try (InputStream inputStream = getClass().getClassLoader().getResourceAsStream(RESOURCE)) {
String xml = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
assertTrue(xml.contains("delete from ccdi_bank_statement_tag_result"));
assertTrue(xml.contains("project_id = #{projectId}"));
assertTrue(xml.contains("model_code = #{modelCode}"));
}
}
}

View File

@@ -0,0 +1,60 @@
package com.ruoyi.ccdi.project.service.impl;
import com.ruoyi.ccdi.project.domain.CcdiModelParam;
import com.ruoyi.ccdi.project.domain.CcdiProject;
import com.ruoyi.ccdi.project.domain.entity.CcdiBankTagRule;
import com.ruoyi.ccdi.project.domain.vo.BankTagRuleExecutionConfig;
import com.ruoyi.ccdi.project.mapper.CcdiModelParamMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class BankTagRuleConfigResolverTest {
@InjectMocks
private BankTagRuleConfigResolver resolver;
@Mock
private CcdiProjectMapper projectMapper;
@Mock
private CcdiModelParamMapper modelParamMapper;
@Test
void resolve_shouldReadEffectiveProjectParamsForThresholdRules() {
CcdiProject project = new CcdiProject();
project.setProjectId(40L);
project.setConfigType("default");
when(projectMapper.selectById(40L)).thenReturn(project);
when(modelParamMapper.selectByProjectAndModel(0L, "LARGE_TRANSACTION")).thenReturn(List.of(
buildParam("SINGLE_TRANSACTION_AMOUNT", "1111")
));
CcdiBankTagRule ruleMeta = new CcdiBankTagRule();
ruleMeta.setModelCode("LARGE_TRANSACTION");
ruleMeta.setRuleCode("SINGLE_LARGE_INCOME");
ruleMeta.setIndicatorCode("SINGLE_TRANSACTION_AMOUNT");
BankTagRuleExecutionConfig config = resolver.resolve(40L, ruleMeta);
assertEquals("1111", config.getThresholdValue("SINGLE_TRANSACTION_AMOUNT"));
}
private CcdiModelParam buildParam(String paramCode, String paramValue) {
CcdiModelParam param = new CcdiModelParam();
param.setProjectId(0L);
param.setModelCode("LARGE_TRANSACTION");
param.setParamCode(paramCode);
param.setParamValue(paramValue);
return param;
}
}

View File

@@ -0,0 +1,84 @@
package com.ruoyi.ccdi.project.service.impl;
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.BankTagRuleExecutionConfig;
import com.ruoyi.ccdi.project.domain.vo.BankTagStatementHitVO;
import com.ruoyi.ccdi.project.mapper.CcdiBankTagAnalysisMapper;
import com.ruoyi.ccdi.project.mapper.CcdiBankTagResultMapper;
import com.ruoyi.ccdi.project.mapper.CcdiBankTagRuleMapper;
import com.ruoyi.ccdi.project.mapper.CcdiBankTagTaskMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InOrder;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import java.util.concurrent.Executor;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CcdiBankTagServiceImplTest {
@InjectMocks
private CcdiBankTagServiceImpl service;
@Mock
private CcdiBankTagRuleMapper ruleMapper;
@Mock
private CcdiBankTagResultMapper resultMapper;
@Mock
private CcdiBankTagTaskMapper taskMapper;
@Mock
private CcdiBankTagAnalysisMapper analysisMapper;
@Mock
private BankTagRuleConfigResolver configResolver;
@Test
void rebuildProject_shouldDeleteOldResultsBeforeSubmittingRuleTasks() {
ReflectionTestUtils.setField(service, "tagRuleExecutor", (Executor) Runnable::run);
CcdiBankTagRule rule = new CcdiBankTagRule();
rule.setModelCode("LARGE_TRANSACTION");
rule.setModelName("大额交易");
rule.setRuleCode("HOUSE_OR_CAR_EXPENSE");
rule.setRuleName("房车消费支出交易");
rule.setResultType("STATEMENT");
BankTagRuleExecutionConfig config = new BankTagRuleExecutionConfig();
config.setProjectId(40L);
config.setRuleMeta(rule);
BankTagStatementHitVO hit = new BankTagStatementHitVO();
hit.setBankStatementId(10L);
hit.setGroupId(40);
hit.setLogId(40001);
hit.setReasonDetail("命中房车消费支出");
when(ruleMapper.selectEnabledRules(null)).thenReturn(List.of(rule));
when(configResolver.resolve(40L, rule)).thenReturn(config);
when(analysisMapper.selectHouseOrCarExpenseStatements(40L)).thenReturn(List.of(hit));
service.rebuildProject(40L, null, "admin", TriggerType.MANUAL);
InOrder inOrder = inOrder(resultMapper);
verify(resultMapper).deleteByProjectAndModel(40L, null);
verify(resultMapper).insertBatch(anyList());
inOrder.verify(resultMapper).deleteByProjectAndModel(40L, null);
inOrder.verify(resultMapper).insertBatch(anyList());
verify(taskMapper).insertTask(any(CcdiBankTagTask.class));
}
}

View File

@@ -5,13 +5,17 @@ import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import com.alibaba.excel.EasyExcel;
import com.ruoyi.ccdi.project.domain.CcdiProject;
import com.ruoyi.ccdi.project.domain.enums.TriggerType;
import com.ruoyi.ccdi.project.domain.vo.CcdiFileUploadStatisticsVO;
import com.ruoyi.ccdi.project.domain.entity.CcdiFileUploadRecord;
import com.ruoyi.ccdi.project.mapper.CcdiBankStatementMapper;
import com.ruoyi.ccdi.project.mapper.CcdiFileUploadRecordMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
import com.ruoyi.ccdi.project.service.ICcdiBankTagService;
import com.ruoyi.lsfx.client.LsfxAnalysisClient;
import com.ruoyi.lsfx.domain.request.GetBankStatementRequest;
import com.ruoyi.lsfx.domain.response.CheckParseStatusResponse;
import com.ruoyi.lsfx.domain.response.DeleteFilesResponse;
import com.ruoyi.lsfx.domain.response.FetchInnerFlowResponse;
import com.ruoyi.lsfx.domain.response.GetBankStatementResponse;
import com.ruoyi.lsfx.domain.response.GetFileUploadStatusResponse;
@@ -37,6 +41,7 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Executor;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.atomic.AtomicInteger;
@@ -47,7 +52,9 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -79,6 +86,9 @@ class CcdiFileUploadServiceImplTest {
@Mock
private Executor fileUploadExecutor;
@Mock
private ICcdiBankTagService bankTagService;
@TempDir
Path tempDir;
@@ -255,6 +265,61 @@ class CcdiFileUploadServiceImplTest {
);
}
@Test
void deleteFileUploadRecord_shouldDeletePlatformFileBankStatementsAndMarkDeleted() {
CcdiFileUploadRecord record = buildRecord();
record.setProjectId(PROJECT_ID);
record.setLsfxProjectId(LSFX_PROJECT_ID);
record.setLogId(LOG_ID);
record.setFileStatus("parsed_success");
when(recordMapper.selectById(RECORD_ID)).thenReturn(record);
when(lsfxClient.deleteFiles(any())).thenReturn(buildDeleteFilesResponse());
String result = service.deleteFileUploadRecord(RECORD_ID, 9527L);
assertEquals("删除成功", result);
verify(lsfxClient).deleteFiles(argThat(request ->
request.getGroupId().equals(LSFX_PROJECT_ID)
&& request.getUserId().equals(9527)
&& request.getLogIds().length == 1
&& request.getLogIds()[0].equals(LOG_ID)
));
verify(bankStatementMapper).deleteByProjectIdAndBatchId(PROJECT_ID, LOG_ID);
verify(recordMapper).updateById(org.mockito.ArgumentMatchers.<CcdiFileUploadRecord>argThat(item ->
RECORD_ID.equals(item.getId()) && "deleted".equals(item.getFileStatus())
));
}
@Test
void deleteFileUploadRecord_shouldRejectNonParsedSuccessStatus() {
CcdiFileUploadRecord record = buildRecord();
record.setFileStatus("parsed_failed");
when(recordMapper.selectById(RECORD_ID)).thenReturn(record);
RuntimeException exception = assertThrows(RuntimeException.class,
() -> service.deleteFileUploadRecord(RECORD_ID, 9527L));
assertTrue(exception.getMessage().contains("仅支持删除解析成功文件"));
}
@Test
void deleteFileUploadRecord_shouldStopWhenLsfxDeleteFails() {
CcdiFileUploadRecord record = buildRecord();
record.setFileStatus("parsed_success");
record.setLogId(LOG_ID);
record.setLsfxProjectId(LSFX_PROJECT_ID);
when(recordMapper.selectById(RECORD_ID)).thenReturn(record);
when(lsfxClient.deleteFiles(any())).thenThrow(new RuntimeException("lsfx delete failed"));
assertThrows(RuntimeException.class, () -> service.deleteFileUploadRecord(RECORD_ID, 9527L));
verify(bankStatementMapper, never()).deleteByProjectIdAndBatchId(any(), any());
verify(recordMapper, never()).updateById(org.mockito.ArgumentMatchers.<CcdiFileUploadRecord>argThat(item ->
"deleted".equals(item.getFileStatus())
));
}
// @Test
// void processPullBankInfoAsync_shouldMarkParsedFailedWhenFetchInnerFlowThrows() {
// when(lsfxClient.fetchInnerFlow(any())).thenThrow(new RuntimeException("fetch inner flow failed"));
@@ -414,6 +479,46 @@ class CcdiFileUploadServiceImplTest {
assertFalse(events.stream().anyMatch(event -> event.endsWith("record:parsed_success")));
}
@Test
void countByStatus_shouldIncludeDeletedCount() {
when(recordMapper.countByStatus(PROJECT_ID)).thenReturn(List.of(
Map.of("status", "uploading", "count", 1),
Map.of("status", "deleted", "count", 2)
));
CcdiFileUploadStatisticsVO result = service.countByStatus(PROJECT_ID);
assertEquals(1L, result.getUploading());
assertEquals(2L, result.getDeleted());
assertEquals(3L, result.getTotal());
}
@Test
void batchUploadCompletion_shouldSubmitProjectTagRebuildWhenAnyFileSucceeded() {
ReflectionTestUtils.invokeMethod(
service,
"handleTagRebuildAfterBatchCompletion",
PROJECT_ID,
TriggerType.AUTO_BATCH_UPLOAD,
Boolean.TRUE
);
verify(bankTagService).submitAutoRebuild(PROJECT_ID, TriggerType.AUTO_BATCH_UPLOAD);
}
@Test
void pullBankInfoCompletion_shouldSubmitProjectTagRebuildWhenAnyTaskSucceeded() {
ReflectionTestUtils.invokeMethod(
service,
"handleTagRebuildAfterBatchCompletion",
PROJECT_ID,
TriggerType.AUTO_PULL_BANK_INFO,
Boolean.TRUE
);
verify(bankTagService).submitAutoRebuild(PROJECT_ID, TriggerType.AUTO_PULL_BANK_INFO);
}
private void captureRecordStatus(List<String> events, AtomicInteger sequence) {
doAnswer(invocation -> {
CcdiFileUploadRecord record = invocation.getArgument(0);
@@ -493,6 +598,12 @@ class CcdiFileUploadServiceImplTest {
return response;
}
private DeleteFilesResponse buildDeleteFilesResponse() {
DeleteFilesResponse response = new DeleteFilesResponse();
response.setSuccessResponse(Boolean.TRUE);
return response;
}
private GetFileUploadStatusResponse buildParsedSuccessStatusResponse(String uploadFileName) {
GetFileUploadStatusResponse response = buildParsedSuccessStatusResponse();
response.getData().getLogs().get(0).setUploadFileName(uploadFileName);

View File

@@ -0,0 +1,117 @@
package com.ruoyi.ccdi.project.service.impl;
import com.ruoyi.ccdi.project.domain.CcdiModelParam;
import com.ruoyi.ccdi.project.domain.CcdiProject;
import com.ruoyi.ccdi.project.domain.dto.ModelParamSaveDTO;
import com.ruoyi.ccdi.project.domain.vo.ModelParamAllVO;
import com.ruoyi.ccdi.project.mapper.CcdiModelParamMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
import com.ruoyi.common.utils.SecurityUtils;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CcdiModelParamServiceImplTest {
@InjectMocks
private CcdiModelParamServiceImpl service;
@Mock
private CcdiModelParamMapper modelParamMapper;
@Mock
private CcdiProjectMapper projectMapper;
@Test
void selectAllParams_shouldReadSystemDefaultsForDefaultProject() {
CcdiProject project = new CcdiProject();
project.setProjectId(100L);
project.setConfigType("default");
when(projectMapper.selectById(100L)).thenReturn(project);
when(modelParamMapper.selectByProjectId(0L)).thenReturn(List.of(
buildParam(2L, 0L, "SUSPICIOUS_PART_TIME", "模型B", "P2", "2"),
buildParam(1L, 0L, "LARGE_TRANSACTION", "模型A", "P1", "1")
));
ModelParamAllVO result = service.selectAllParams(100L);
verify(modelParamMapper).selectByProjectId(0L);
assertEquals(2, result.getModels().size());
assertEquals("LARGE_TRANSACTION", result.getModels().get(0).getModelCode());
assertEquals("SUSPICIOUS_PART_TIME", result.getModels().get(1).getModelCode());
}
@Test
@SuppressWarnings("unchecked")
void saveParams_shouldCopyAllSystemDefaultsForDefaultProjectOnFirstSave() {
CcdiProject project = new CcdiProject();
project.setProjectId(123L);
project.setConfigType("default");
when(projectMapper.selectById(123L)).thenReturn(project);
when(modelParamMapper.selectByProjectId(0L)).thenReturn(List.of(
buildParam(1L, 0L, "LARGE_TRANSACTION", "大额交易模型", "SINGLE_TRANSACTION_AMOUNT", "1111"),
buildParam(2L, 0L, "SUSPICIOUS_GAMBLING", "疑似赌博交易模型", "multi_party_amt_min", "500")
));
when(modelParamMapper.insertBatch(anyList())).thenReturn(2);
when(modelParamMapper.updateParamValue(123L, "LARGE_TRANSACTION", "SINGLE_TRANSACTION_AMOUNT", "2222", "admin"))
.thenReturn(1);
ModelParamSaveDTO saveDTO = new ModelParamSaveDTO();
saveDTO.setProjectId(123L);
saveDTO.setModelCode("LARGE_TRANSACTION");
ModelParamSaveDTO.ParamValueItem item = new ModelParamSaveDTO.ParamValueItem();
item.setParamCode("SINGLE_TRANSACTION_AMOUNT");
item.setParamValue("2222");
saveDTO.setParams(List.of(item));
try (MockedStatic<SecurityUtils> mocked = mockStatic(SecurityUtils.class)) {
mocked.when(SecurityUtils::getUsername).thenReturn("admin");
service.saveParams(saveDTO);
}
ArgumentCaptor<List<CcdiModelParam>> captor = ArgumentCaptor.forClass(List.class);
verify(modelParamMapper).insertBatch(captor.capture());
List<CcdiModelParam> copiedParams = captor.getValue();
assertEquals(2, copiedParams.size());
assertTrue(copiedParams.stream().allMatch(param -> Long.valueOf(123L).equals(param.getProjectId())));
assertEquals("custom", project.getConfigType());
verify(projectMapper).updateById(project);
verify(modelParamMapper).updateParamValue(123L, "LARGE_TRANSACTION", "SINGLE_TRANSACTION_AMOUNT", "2222", "admin");
}
private CcdiModelParam buildParam(
Long id,
Long projectId,
String modelCode,
String modelName,
String paramCode,
String paramValue
) {
CcdiModelParam param = new CcdiModelParam();
param.setId(id);
param.setProjectId(projectId);
param.setModelCode(modelCode);
param.setModelName(modelName);
param.setParamCode(paramCode);
param.setParamName(paramCode);
param.setParamValue(paramValue);
param.setSortOrder(1);
return param;
}
}

View File

@@ -0,0 +1,57 @@
package com.ruoyi.ccdi.project.service.impl;
import com.ruoyi.ccdi.project.domain.entity.CcdiBankTagTask;
import com.ruoyi.ccdi.project.domain.enums.TriggerType;
import com.ruoyi.ccdi.project.mapper.CcdiBankTagTaskMapper;
import com.ruoyi.common.exception.ServiceException;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ProjectBankTagRebuildCoordinatorTest {
@InjectMocks
private ProjectBankTagRebuildCoordinator coordinator;
@Mock
private CcdiBankTagTaskMapper taskMapper;
@Mock
private CcdiBankTagServiceImpl bankTagService;
@Test
void submitManualRebuild_shouldRejectWhenProjectAlreadyRunning() {
CcdiBankTagTask runningTask = new CcdiBankTagTask();
runningTask.setId(1L);
runningTask.setProjectId(40L);
runningTask.setStatus("RUNNING");
when(taskMapper.selectRunningTaskByProjectId(40L)).thenReturn(runningTask);
assertThrows(ServiceException.class, () -> coordinator.submitManual(40L, null, "admin"));
verify(bankTagService, never()).rebuildProject(40L, null, "admin", TriggerType.MANUAL);
}
@Test
void submitAutoRebuild_shouldMarkNeedRerunWhenProjectAlreadyRunning() {
CcdiBankTagTask runningTask = new CcdiBankTagTask();
runningTask.setId(1L);
runningTask.setProjectId(40L);
runningTask.setStatus("RUNNING");
runningTask.setNeedRerun(0);
when(taskMapper.selectRunningTaskByProjectId(40L)).thenReturn(runningTask);
coordinator.submitAuto(40L, TriggerType.AUTO_BATCH_UPLOAD);
verify(taskMapper).updateTask(any(CcdiBankTagTask.class));
verify(bankTagService, never()).rebuildProject(40L, null, "system", TriggerType.AUTO_BATCH_UPLOAD);
}
}

View File

@@ -33,7 +33,18 @@ function Ensure-Command {
function Reset-Directory {
param([string]$Path)
if (Test-Path $Path) {
[System.IO.Directory]::Delete($Path, $true)
Get-ChildItem -LiteralPath $Path -Recurse -Force -ErrorAction SilentlyContinue | ForEach-Object {
$_.Attributes = $_.Attributes `
-band (-bnot [System.IO.FileAttributes]::ReadOnly) `
-band (-bnot [System.IO.FileAttributes]::Hidden) `
-band (-bnot [System.IO.FileAttributes]::System)
}
$rootItem = Get-Item -LiteralPath $Path -Force
$rootItem.Attributes = $rootItem.Attributes `
-band (-bnot [System.IO.FileAttributes]::ReadOnly) `
-band (-bnot [System.IO.FileAttributes]::Hidden) `
-band (-bnot [System.IO.FileAttributes]::System)
Remove-Item -LiteralPath $Path -Recurse -Force
}
New-Item -ItemType Directory -Path $Path | Out-Null
}

View File

@@ -6,7 +6,7 @@ services:
container_name: ccdi-backend
restart: unless-stopped
environment:
SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-local}
SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-nas}
RUOYI_PROFILE: ${RUOYI_PROFILE:-/app/data/ruoyi}
JAVA_OPTS: ${JAVA_OPTS:--Xms512m -Xmx1024m}
ports:

View File

@@ -0,0 +1,88 @@
# 项目40大额交易测试流水实施报告
## 执行概况
- 执行时间2026-03-16
- 目标项目:`project_id=40`
- 项目名称:大额交易模型测试
- 执行方式:直接向开发库 `ccdi_bank_statement` 插入测试流水
- 数据脚本:[2026-03-16-project40-large-transaction-seed.sql](/D:/ccdi/ccdi/assets/database/2026-03-16-project40-large-transaction-seed.sql)
本次执行前先清理了 `project_id=40` 的旧流水,再重新插入测试数据。最终共落库 `21` 条流水,涉及两名员工和两名家属。
## 复用身份
- 员工:模型测试员工,身份证 `330101198801010011`
- 家属:模型测试家属,身份证 `330101199001010022`
- 员工:模型二测试员工,身份证 `330101198802020033`
- 家属:模型二测试家属,身份证 `330101199202020044`
## 阈值依据
`project_id=40` 当前没有项目级模型参数,命中判断使用 `project_id=0` 的系统默认参数:
- 单笔大额收入:`100000`
- 累计大额收入:`50000001`
- 年累计交易额:`50000001`
- 单笔大额存现:`2000001`
- 单日多次存现次数:`5`
- 单笔大额转账金额:`100001`
## 校验结果
### 总量
- 项目流水总数:`21`
### 指标命中
- 房车消费支出:命中 `2` 条,流水号 `34262,34263`
- `34262`:模型测试员工,`购买房产首付款`,对手方 `杭州贝壳房地产经纪有限公司`
- `34263`:模型测试家属,`购车首付款`,对手方 `兰溪星耀汽车销售服务有限公司`
- 税务支出交易:命中 `2` 条,流水号 `34264,34265`
- `34264`:模型二测试员工,`个人所得税税款`
- `34265`:模型二测试家属,`房产税务缴税`
- 单笔大额收入:命中 `10`
- 典型流水:`34266`,模型测试员工,收入 `188000.00`,对手方 `杭州启明咨询有限公司`
- 说明:累计收入和存现样本也满足“单笔收入超 100000”的口径因此命中数大于 1
- 累计收入超限:命中 `1`
- 命中对象:`330101198802020033`
- 对手方:`浙江远望贸易有限公司`
- 累计收入:`60300000.00`
- 典型流水:`34267,34268,34269`
- 年流水交易额超限:命中 `1`
- 命中对象:`330101198802020033`
- 年交易总额:`74712000.00`
- 主要流水:`34267-34271,34278`
- 单笔大额存现:命中 `6` 条,流水号 `34272,34273,34274,34275,34276,34277`
- 单日多次存现:命中 `1`
- 命中对象:`330101198801010011`
- 日期:`2026-03-10`
- 次数:`6`
- 单笔大额转账:命中 `3` 条,流水号 `34270,34271,34278`
- 典型流水:`34278`,模型二测试员工,`手机银行转账`,支出 `360000.00`
## 噪声数据
为避免页面只出现极端命中样本,补充了少量非命中或用于排除逻辑验证的流水:
- `34279`:工资代发收入,对手方 `浙江兰溪农村商业银行股份有限公司`
- `34280`:超市消费
- `34281`:水电费支出
- `34282`:本人账户划转
## 前端验证建议
前端本次无需代码改造,直接使用现有项目流水明细页面验证即可。建议按以下方式检查:
- 进入项目 `40` 的流水明细页,确认可见 `21` 条新增流水
- 搜索摘要 `首付款`,应能看到 `34262,34263`
- 搜索摘要 `税款` 或对手方 `税务局`,应能看到 `34264,34265`
- 按金额倒序查看收入,应能在前列看到 `34267,34268,34269`
- 按金额倒序查看支出,应能看到 `34270,34271,34278`
- 查看 `34272-34277` 的详情,确认现金存入类摘要与金额展示正常
## 执行说明
- 首次通过 PowerShell 管道执行时,中文文本被写成了 `?`,随后改用 MySQL `source` 命令并显式指定 `utf8mb4` 后重新导入,最终数据已正确写入。
- 脚本可重复执行;每次都会先删除 `project_id=40` 现有流水,再重建本次测试数据。

View File

@@ -0,0 +1,149 @@
# Project 40 Large Transaction Data Backend Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 为开发库 `project_id=40` 直接插入一批符合大额交易模型口径的银行流水,并完成 SQL 级命中验证。
**Architecture:** 通过独立 SQL 脚本管理测试数据生命周期,只操作 `ccdi_bank_statement`。脚本先清理项目 40 旧流水,再按既定身份、账户、日期和金额批量插入命中流水与少量噪声流水,最后执行核验 SQL 确认每个指标至少命中一次。
**Tech Stack:** MySQL 5.7, PowerShell, 项目现有 `ccdi_bank_statement` 表结构, `assets/大额交易.csv`
---
### Task 1: 固化设计与目标数据清单
**Files:**
- Modify: `D:\ccdi\ccdi\docs\plans\2026-03-16-large-transaction-project40-design.md`
- Create: `D:\ccdi\ccdi\assets\database\2026-03-16-project40-large-transaction-seed.sql`
**Step 1: 复核命中口径**
对照 `assets/大额交易.csv` 和库内默认参数,整理每个指标的命中条件、阈值和使用身份。
**Step 2: 写出目标数据清单**
在 SQL 脚本注释区列出每类指标对应的样本数量、证件号、金额范围、关键词和日期范围。
**Step 3: 自检唯一键策略**
确认每条插入记录的 `(project_id, LE_ACCOUNT_NO, ACCOUNTING_DATE_ID, AMOUNT_DR, AMOUNT_CR)` 组合唯一。
**Step 4: Commit**
```bash
git add docs/plans/2026-03-16-large-transaction-project40-design.md assets/database/2026-03-16-project40-large-transaction-seed.sql
git commit -m "文档: 补充项目40大额交易测试数据设计"
```
### Task 2: 编写清理与插入 SQL
**Files:**
- Modify: `D:\ccdi\ccdi\assets\database\2026-03-16-project40-large-transaction-seed.sql`
**Step 1: 写清理语句**
添加只针对 `project_id=40` 的删除语句:
```sql
DELETE FROM ccdi_bank_statement
WHERE project_id = 40;
```
**Step 2: 编写最小插入块**
先写 1 到 2 条房车消费和税务支出的插入语句,确认字段完整:
```sql
INSERT INTO ccdi_bank_statement (
project_id, LE_ID, ACCOUNT_ID, group_id, LE_ACCOUNT_NAME, LE_ACCOUNT_NO,
ACCOUNTING_DATE_ID, ACCOUNTING_DATE, TRX_DATE, CURRENCY,
AMOUNT_DR, AMOUNT_CR, AMOUNT_BALANCE, CASH_TYPE, CUSTOMER_LE_ID,
CUSTOMER_ACCOUNT_NAME, CUSTOMER_ACCOUNT_NO, customer_bank, customer_reference,
USER_MEMO, BANK_COMMENTS, BANK_TRX_NUMBER, BANK, TRX_FLAG, TRX_TYPE,
EXCEPTION_TYPE, internal_flag, batch_id, batch_sequence, CREATE_DATE,
created_by, meta_json, no_balance, begin_balance, end_balance,
override_bs_id, payment_method, cret_no
) VALUES (...);
```
**Step 3: 扩展全部指标数据**
补全单笔大额收入、累计收入、年流水超限、大额存现、多次存现、大额转账和噪声流水。
**Step 4: 运行脚本**
Run:
```bash
mysql --host=116.62.17.81 --user=root --password=*** --database=ccdi < assets/database/2026-03-16-project40-large-transaction-seed.sql
```
Expected: 执行成功,无唯一键冲突。
**Step 5: Commit**
```bash
git add assets/database/2026-03-16-project40-large-transaction-seed.sql
git commit -m "数据: 生成项目40大额交易测试流水"
```
### Task 3: 编写并执行核验 SQL
**Files:**
- Modify: `D:\ccdi\ccdi\assets\database\2026-03-16-project40-large-transaction-seed.sql`
- Create: `D:\ccdi\ccdi\docs\implementation-reports\2026-03-16-project40-large-transaction-report.md`
**Step 1: 添加核验查询**
在脚本末尾添加按 `大额交易.csv` 口径改写后的项目 40 验证查询,覆盖全部指标。
**Step 2: 执行核验**
Run:
```bash
mysql --host=116.62.17.81 --user=root --password=*** --database=ccdi -e "/* verify queries */"
```
Expected: 每个指标返回至少 1 条命中记录或 1 个命中分组。
**Step 3: 记录结果**
把每个指标的命中数量、示例流水编号和涉及人员写入报告文档。
**Step 4: Commit**
```bash
git add assets/database/2026-03-16-project40-large-transaction-seed.sql docs/implementation-reports/2026-03-16-project40-large-transaction-report.md
git commit -m "验证: 完成项目40大额交易测试流水校验"
```
### Task 4: 回归检查与收尾
**Files:**
- Modify: `D:\ccdi\ccdi\docs\implementation-reports\2026-03-16-project40-large-transaction-report.md`
**Step 1: 检查项目总量**
Run:
```bash
mysql --host=116.62.17.81 --user=root --password=*** --database=ccdi -e "SELECT project_id, COUNT(*) FROM ccdi_bank_statement WHERE project_id=40 GROUP BY project_id;"
```
Expected: `project_id=40` 存在稳定数量的测试流水。
**Step 2: 抽样检查页面关键字段**
确认 `TRX_DATE``USER_MEMO``CUSTOMER_ACCOUNT_NAME``AMOUNT_DR``AMOUNT_CR` 等字段适合前端展示。
**Step 3: 补充最终说明**
在报告中注明依赖的默认阈值、复用的测试身份和复跑方式。
**Step 4: Commit**
```bash
git add docs/implementation-reports/2026-03-16-project40-large-transaction-report.md
git commit -m "文档: 完善项目40大额交易测试流水报告"
```

View File

@@ -0,0 +1,69 @@
# Project 40 Large Transaction Test Data Design
**背景**
`project_id=40` 对应项目为“大额交易模型测试”,当前 `ccdi_bank_statement` 中没有任何流水数据。目标是根据 [assets/大额交易.csv](/D:/ccdi/ccdi/assets/大额交易.csv) 的业务口径,在开发库中直接插入一批能够稳定命中大额交易模型指标的测试流水。
**现状**
- 项目 `40` 已存在,但 `ccdi_model_param` 中没有项目级参数,命中逻辑将使用 `project_id=0` 的系统默认阈值。
- 可复用的测试身份已存在:
- 员工 `模型测试员工 / 330101198801010011`
- 家属 `模型测试家属 / 330101199001010022`
- 员工 `模型二测试员工 / 330101198802020033`
- 家属 `模型二测试家属 / 330101199202020044`
- 流水命中依赖的核心字段为 `project_id``cret_no``trx_date``amount_dr``amount_cr``user_memo``customer_account_name``cash_type``le_account_name``le_account_no``accounting_date_id`
**设计目标**
-`大额交易.csv` 中的每个指标至少生成一组稳定命中的流水。
- 直接写入开发库 `ccdi_bank_statement`,只影响 `project_id=40`
- 除命中流水外,补充少量普通流水,避免页面展示只有极端数据。
- 插入后可以使用 `大额交易.csv` 中的 SQL 口径逐项核验。
**设计方案**
采用“指标命中 + 少量真实噪声”的平衡方案:
1. 为以下指标分别构造命中流水:
- 房车消费支出
- 税务支出
- 单笔大额收入
- 累计收入超限
- 年流水交易额超限
- 单笔大额存现
- 单日多次存现
- 单笔大额转账
2. 每个指标使用明显高于阈值的金额,避免边界值误差。
3. 复用两名员工和两名家属的证件号作为 `cret_no`,确保能命中员工本人及亲属范围。
4. 为每个账户生成一组连续日期和递增的 `accounting_date_id`,规避唯一键 `(project_id, LE_ACCOUNT_NO, ACCOUNTING_DATE_ID, AMOUNT_DR, AMOUNT_CR)` 冲突。
5. 补充少量普通转入、普通消费、工资代发等非命中流水,保证页面可读性,并验证排除逻辑。
**数据策略**
- 单笔大额收入:为员工本人生成大于 `100000` 的单笔收入,对手方不使用本人、家属和工资代发主体。
- 累计收入超限:为同一员工和同一外部对手方生成多笔累计收入,总额超过 `50000001`
- 年流水交易额超限:在近一年内为同一员工生成大量转入与转出组合,使总交易额超过 `50000001`
- 单笔大额存现:生成 `amount_cr > 2000001` 且摘要/交易类型命中现金存入关键词的流水。
- 单日多次存现:同一身份证同一天生成至少 `6` 笔满足大额存现条件的流水。
- 单笔大额转账:生成 `amount_dr > 100001``user_memo` / `cash_type` 命中转账关键词、同时避开“款”字排除条件的流水。
- 房车消费与税务支出:通过 `user_memo``customer_account_name` 命中关键词,且使用员工本人和家属证件号混合覆盖。
**落库方式**
- 新增一份独立 SQL 脚本,包含:
- 插入前清理 `project_id=40` 既有测试流水
- 批量插入设计好的流水数据
- 插入后的核验 SQL
- 落库只操作 `ccdi_bank_statement`,不修改员工、家属、项目、参数等基础表。
**验证方式**
- 校验 `project_id=40` 的流水总数和涉及证件号是否符合预期。
-`大额交易.csv` 的 SQL 口径逐项运行命中检查。
- 抽样查看流水明细中的摘要、对手方、金额、日期,确认页面展示可用。
**风险与控制**
- 若模型实现与 CSV 口径存在偏差可能出现“SQL 能命中、页面不命中”的情况;因此落库后需要按数据库口径与页面结果双重验证。
- 由于 `project_id=40` 使用默认阈值,后续若产品调整项目级参数,当前测试数据可能不再命中;为避免误解,插入脚本会在注释中写明依赖的默认阈值。

View File

@@ -0,0 +1,58 @@
# Project 40 Large Transaction Data Frontend Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 确认前端无需代码改造,仅通过现有流水明细页面验证 `project_id=40` 新增测试流水是否可见、可筛选、可展示。
**Architecture:** 本次交付以数据库测试数据生成为主,前端沿用现有 `ccdiProject` 流水明细页面能力。实施重点是准备验证口径,确保新数据在现有筛选器、列表和详情页中可被正确检索和展示。
**Tech Stack:** Vue 2, 若依前端, 现有流水明细查询接口
---
### Task 1: 确认无前端代码变更需求
**Files:**
- Read: `D:\ccdi\ccdi\ruoyi-ui\src\api\ccdiProjectBankStatement.js`
- Read: `D:\ccdi\ccdi\ruoyi-ui\src\views\ccdiProject\`
- Modify: `D:\ccdi\ccdi\docs\implementation-reports\2026-03-16-project40-large-transaction-report.md`
**Step 1: 检查现有筛选能力**
确认页面已支持按时间、对手方、摘要、金额和收支方向查看流水。
**Step 2: 列出验证入口**
在报告文档中写明建议验证的页面入口、过滤条件和预期结果。
**Step 3: Commit**
```bash
git add docs/implementation-reports/2026-03-16-project40-large-transaction-report.md
git commit -m "文档: 补充项目40流水前端验证说明"
```
### Task 2: 前端联调验证清单
**Files:**
- Modify: `D:\ccdi\ccdi\docs\implementation-reports\2026-03-16-project40-large-transaction-report.md`
**Step 1: 编写页面验证步骤**
列出以下检查项:
- 项目 40 的流水列表可以查到新增数据
- 房车消费、税务支出等关键词能被摘要/对手方检索到
- 单笔大额收入和大额转账在金额排序下位于前列
- 详情页展示身份证号、原始文件名为空时不报错
**Step 2: 记录无代码改动结论**
明确说明前端本次不需要新增接口、不需要修改组件、不需要补菜单。
**Step 3: Commit**
```bash
git add docs/implementation-reports/2026-03-16-project40-large-transaction-report.md
git commit -m "文档: 完成项目40流水前端验证清单"
```

View File

@@ -0,0 +1,357 @@
# Model Param CSV Alignment Backend Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Align system default model parameters with `assets/模型默认参数.csv` on the backend and database side without changing the existing `listAll/saveAll` API contract.
**Architecture:** Keep `ccdi_model_param` as the single source of truth for model metadata and default values. Update initialization SQL, add an upgrade SQL for existing environments, and make a small service/mapper cleanup so query ordering and default-project copy behavior remain stable while historical `custom` projects stay untouched.
**Tech Stack:** Java 21, Spring Boot 3, MyBatis Plus, MySQL, Maven
---
### Task 1: 固化 CSV 对应的系统默认参数清单
**Files:**
- Reference: `assets/模型默认参数.csv`
- Modify: `sql/ccdi_model_param.sql`
**Step 1: 对照 CSV 列出最终参数集合**
确认最终系统默认参数为 5 个模型、16 个参数:
- `LARGE_TRANSACTION`
- `SUSPICIOUS_PART_TIME`
- `SUSPICIOUS_FOREIGN_EXCHANGE`
- `ABNORMAL_BEHAVIOR`
- `SUSPICIOUS_GAMBLING`
并确认每条记录的 `model_code``model_name``param_code``param_name``param_desc``param_value``param_unit``sort_order`
**Step 2: 写一个失败前检查**
运行:
```bash
Get-Content -Raw 'sql/ccdi_model_param.sql'
```
预期:可以看到旧参数定义仍与 CSV 不完全一致,包含已废弃参数或缺失模型。
**Step 3: 更新初始化 SQL**
`sql/ccdi_model_param.sql``project_id = 0` 的初始化数据改为与 CSV 一致,要求:
- 删除旧的废弃参数
- 新增 `ABNORMAL_BEHAVIOR``SUSPICIOUS_GAMBLING`
- 保证 `sort_order` 从 1 开始递增
- 不引入任何千分位格式数据
**Step 4: 做静态自检**
运行:
```bash
Get-Content -Raw 'sql/ccdi_model_param.sql'
```
预期SQL 中只剩 CSV 对应的 16 条系统默认参数记录。
**Step 5: 提交**
```bash
git add sql/ccdi_model_param.sql
git commit -m "feat: 对齐模型默认参数初始化脚本"
```
### Task 2: 为已有环境补充默认参数覆盖脚本
**Files:**
- Create or Modify: `sql/2026-03-16-update-ccdi-model-param-defaults.sql`
**Step 1: 写覆盖脚本骨架**
脚本要求:
- 以事务包裹
- 只处理 `project_id = 0`
- 先删除旧的系统默认参数
- 再插入与 CSV 一致的 16 条新默认参数
**Step 2: 写失败前检查**
运行:
```bash
Test-Path 'sql/2026-03-16-update-ccdi-model-param-defaults.sql'
Get-Content -Raw 'sql/2026-03-16-update-ccdi-model-param-defaults.sql'
```
预期:若文件已存在,内容可能未完全符合最终参数集合;若不存在,则本步骤补齐。
**Step 3: 写最小正确实现**
确保脚本满足:
- `START TRANSACTION;`
- `DELETE FROM ccdi_model_param WHERE project_id = 0;`
- 插入 16 条目标数据
- `COMMIT;`
**Step 4: 自检**
运行:
```bash
Get-Content -Raw 'sql/2026-03-16-update-ccdi-model-param-defaults.sql'
```
预期:脚本与初始化 SQL 的系统默认参数集合完全一致。
**Step 5: 提交**
```bash
git add sql/2026-03-16-update-ccdi-model-param-defaults.sql
git commit -m "feat: 新增模型默认参数升级脚本"
```
### Task 3: 稳定后端查询顺序
**Files:**
- Modify: `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiModelParamMapper.xml`
**Step 1: 先查看当前 SQL**
运行:
```bash
Get-Content -Raw 'ccdi-project/src/main/resources/mapper/ccdi/project/CcdiModelParamMapper.xml'
```
预期:`selectByProjectId` 已存在,但排序可能仅依赖 `model_code, sort_order`
**Step 2: 写一个小改动**
`selectByProjectId` 的排序改为稳定排序:
```xml
ORDER BY model_code, sort_order, id
```
如有必要,也检查 `selectByProjectAndModel` 是否需要补 `id` 兜底排序。
**Step 3: 静态验证**
运行:
```bash
Get-Content -Raw 'ccdi-project/src/main/resources/mapper/ccdi/project/CcdiModelParamMapper.xml'
```
预期:查询排序稳定,不依赖数据库默认返回顺序。
**Step 4: 提交**
```bash
git add ccdi-project/src/main/resources/mapper/ccdi/project/CcdiModelParamMapper.xml
git commit -m "fix: 稳定模型参数查询顺序"
```
### Task 4: 清理服务层的 CSV 对齐边界
**Files:**
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiModelParamServiceImpl.java`
**Step 1: 查看当前实现**
运行:
```bash
Get-Content -Raw 'ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiModelParamServiceImpl.java'
```
预期:当前已经支持 `listAll/saveAll`,但需要确认注释、排序依赖和默认项目复制逻辑是否与本次设计一致。
**Step 2: 写最小修正**
仅做与本次设计直接相关的修正:
- 保持 `projectId=0/default/custom` 的现有读取规则
- 保持默认项目首次保存时复制系统默认参数全集
- 不增加任何历史 `custom` 项目补齐逻辑
- 不引入任何千分位处理逻辑
- 如有重复或无效的局部变量,顺手清理为更直接的实现
**Step 3: 写失败前验证**
运行:
```bash
mvn -pl ccdi-project -am -DskipTests compile
```
预期:若存在语法或导入问题,本步先暴露出来。
**Step 4: 调整到编译通过**
修正编译问题后再次运行:
```bash
mvn -pl ccdi-project -am -DskipTests compile
```
预期BUILD SUCCESS。
**Step 5: 提交**
```bash
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiModelParamServiceImpl.java
git commit -m "refactor: 收敛模型参数服务对齐逻辑"
```
### Task 5: 验证新旧环境脚本产物一致
**Files:**
- Reference: `sql/ccdi_model_param.sql`
- Reference: `sql/2026-03-16-update-ccdi-model-param-defaults.sql`
- Optional Record: `docs/test-records/model-param-backend-alignment-test.md`
**Step 1: 准备校验项**
至少检查:
- 模型总数是否为 5
- 参数总数是否为 16
- 每个 `model_code + param_code` 唯一
- 所有 `param_value` 为原始字符串,不含千分位逗号
**Step 2: 执行静态比对**
运行:
```bash
Get-Content -Raw 'sql/ccdi_model_param.sql'
Get-Content -Raw 'sql/2026-03-16-update-ccdi-model-param-defaults.sql'
```
预期:两个脚本中的系统默认参数集合一致。
**Step 3: 如本地有数据库,执行 SQL 验证**
示例:
```sql
SELECT model_code, COUNT(*) AS cnt
FROM ccdi_model_param
WHERE project_id = 0
GROUP BY model_code
ORDER BY model_code;
```
```sql
SELECT model_code, param_code, param_value
FROM ccdi_model_param
WHERE project_id = 0
ORDER BY model_code, sort_order, id;
```
预期:返回结果与 CSV 一致。
**Step 4: 记录结果**
将验证过程写入:
```text
docs/test-records/model-param-backend-alignment-test.md
```
**Step 5: 提交**
```bash
git add docs/test-records/model-param-backend-alignment-test.md
git commit -m "test: 记录模型默认参数后端对齐验证"
```
### Task 6: 验证接口行为不变
**Files:**
- Reference: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiModelParamController.java`
- Reference: `ruoyi-ui/src/api/ccdi/modelParam.js`
**Step 1: 启动后端**
运行:
```bash
mvn -pl ruoyi-admin -am spring-boot:run
```
**Step 2: 验证查询接口**
使用 Swagger 或 HTTP 工具请求:
```http
GET /ccdi/modelParam/listAll?projectId=0
```
预期:
- 返回 `models` 数组
- 包含 5 个模型
- 各模型参数与 CSV 一致
**Step 3: 验证默认项目查询**
对一个 `configType=default` 项目请求:
```http
GET /ccdi/modelParam/listAll?projectId=<defaultID>
```
预期:返回系统默认参数全集。
**Step 4: 验证默认项目首次保存**
调用:
```http
POST /ccdi/modelParam/saveAll
```
请求体示例:
```json
{
"projectId": 123,
"models": [
{
"modelCode": "LARGE_TRANSACTION",
"params": [
{
"paramCode": "SINGLE_TRANSACTION_AMOUNT",
"paramValue": "2222"
}
]
}
]
}
```
预期:
- 保存成功
- 该项目 `config_type` 变为 `custom`
- 项目参数表中只复制当前系统默认参数全集,不补历史 `custom` 项目
**Step 5: 停止后端进程并提交**
测试结束后关闭 `mvn spring-boot:run` 启动的进程,再提交测试记录:
```bash
git add docs/test-records/model-param-backend-alignment-test.md
git commit -m "test: 完成模型参数后端接口回归验证"
```
---
Plan complete and saved to `docs/plans/2026-03-16-model-param-csv-alignment-backend-implementation.md`.

View File

@@ -0,0 +1,199 @@
# 模型默认参数 CSV 对齐设计
## 背景
当前模型参数配置页面已经支持按模型卡片垂直展示并统一保存,但系统默认参数的实际定义与 `assets/模型默认参数.csv` 不一致,主要体现在模型数量、参数数量、参数编码、名称、描述、默认值和单位上。
本次需要将系统默认参数、后端查询保存链路和前端展示统一对齐到 CSV并明确兼容边界。
## 目标
- 让系统默认参数数据与 `assets/模型默认参数.csv` 保持一致
- 前端页面完全根据查询接口动态展示所有模型和参数信息
- 保持现有 `listAll/saveAll` 接口契约不变
- 保持默认配置项目和自定义配置项目的既有行为清晰可控
## 非目标
- 不补齐历史 `config_type = custom` 项目缺失的模型或参数
- 不调整 `ccdi_model_param` 表结构
- 不增加前端本地写死的模型定义
- 不增加任何千分位相关展示、输入或保存逻辑
## 现状分析
### 现有页面
当前前端页面已经具备以下能力:
- 全局模型参数页支持按模型卡片展示全部参数
- 项目参数页支持按模型卡片展示全部参数
- 两个页面均通过 `GET /ccdi/modelParam/listAll` 动态拉取模型参数
- 两个页面均通过 `POST /ccdi/modelParam/saveAll` 统一保存修改
这意味着本次不需要推翻页面结构,重点应放在数据定义对齐和动态渲染稳定性上。
### 现有后端
当前后端服务已经具备以下能力:
- 根据 `projectId` 和项目 `configType` 决定查询系统默认参数或项目自定义参数
- 默认配置项目首次保存时,会把系统默认参数复制到项目下,并切换为 `custom`
- 批量查询和批量保存接口已可复用
### 差异点
与 CSV 对比后,当前系统存在以下差异:
- 缺少 `ABNORMAL_BEHAVIOR``SUSPICIOUS_GAMBLING` 两个模型
- 部分旧参数在 CSV 中已被替换或删除
- 多个参数的 `paramCode``paramName``paramDesc``paramValue``paramUnit` 已发生变化
- 初始化 SQL 与已有数据库环境更新脚本尚未完全统一
## 方案对比
### 方案一:以数据库默认参数为唯一真实来源
- 优点:前后端天然一致,默认项目复制逻辑无需重写,风险最低
- 优点:新增或删除模型参数后,前端可自动跟随接口展示
- 缺点:需要认真维护初始化脚本和增量更新脚本
### 方案二:前端按 CSV 写死模型定义,后端只保存值
- 优点:页面改造直观
- 缺点:前后端各维护一份模型定义,后续极易漂移
- 缺点:一旦后端参数集合变化,前端会出现展示与保存不一致
### 方案三:后端代码内置元数据,数据库只存参数值
- 优点:元数据集中管理
- 缺点:需要重构当前基于表驱动的实现方式,改动范围过大
- 缺点:对已有项目参数复制链路影响较大
## 最终方案
采用方案一,以 `ccdi_model_param``project_id = 0` 的系统默认参数作为唯一真实来源。
### 数据策略
- 更新 `sql/ccdi_model_param.sql`,使新环境初始化时直接生成与 CSV 一致的模型参数数据
- 保留并完善 `sql/2026-03-16-update-ccdi-model-param-defaults.sql`,用于已有环境覆盖系统默认参数
- 系统默认参数集合以 CSV 为准,共包含 5 个模型、16 个参数
- 历史 `config_type = custom` 项目不补齐新增模型或参数
### 查询策略
- `projectId = 0` 时,查询系统默认参数
- `projectId > 0 && configType = default` 时,仍查询系统默认参数
- `projectId > 0 && configType = custom` 时,查询项目自己的参数
- 查询接口继续返回 `models` 数组,前端完全依赖接口返回数据动态渲染
### 保存策略
- 全局参数保存仍更新 `project_id = 0` 的系统默认参数
- 默认配置项目首次保存时,复制当前系统默认参数全集到项目,再切换项目 `configType``custom`
- 已经是 `custom` 的历史项目继续只更新自身已有参数,不做补齐
- 参数值继续按原始字符串处理,不增加千分位格式化、去逗号或自动转换逻辑
## 后端设计
### 保持接口契约不变
继续使用现有接口:
- `GET /ccdi/modelParam/listAll`
- `POST /ccdi/modelParam/saveAll`
这样可以避免额外改动前端请求层和项目参数页调用方式。
### 服务层调整点
- 保持 `CcdiModelParamServiceImpl` 现有按 `configType` 切换数据源的逻辑
- 保持默认项目首次保存时复制系统默认参数全集的逻辑
- 保持空值校验,防止参数值被保存为空字符串
- 不新增历史 `custom` 项目的补齐逻辑
### Mapper 与 SQL 调整点
- `selectByProjectId` 查询顺序需要稳定,建议按 `model_code``sort_order``id` 排序
- 初始化 SQL 和增量 SQL 的模型定义必须一致,避免新库与老库表现不同
## 前端设计
### 展示原则
前端不写死任何模型或参数定义,完全根据查询接口返回的数据展示:
- 模型标题使用 `modelName`
- 模型编码使用 `modelCode`
- 参数名称使用 `paramName`
- 参数描述使用 `paramDesc`
- 参数值使用 `paramValue`
- 参数单位使用 `paramUnit`
### 页面行为
- 保留现有“模型卡片垂直堆叠 + 统一保存”布局
- 不限制模型数量和参数数量
- 不假设固定模型顺序和参数顺序,以接口返回顺序为准
- 保存成功后重新查询,保证页面展示与后端数据一致
### 修改记录实现
当前页面中对修改项的记录依赖 `Set + $forceUpdate`。本次建议改为更稳定的响应式结构,例如:
-`modelCode:paramCode` 作为唯一键
- 使用普通对象或数组维护已修改项
这样可以减少 Vue 2 对 `Set` 响应式不完整带来的不稳定行为。
## 影响范围
### 后端
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiModelParamServiceImpl.java`
- `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiModelParamMapper.xml`
- `sql/ccdi_model_param.sql`
- `sql/2026-03-16-update-ccdi-model-param-defaults.sql`
### 前端
- `ruoyi-ui/src/views/ccdi/modelParam/index.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/ParamConfig.vue`
## 测试方案
### 数据对齐测试
- 校验系统默认参数与 CSV 中的模型数量一致
- 校验每个模型下的参数数量、编码、名称、描述、默认值和单位一致
- 校验新环境初始化 SQL 和老环境增量 SQL 产出的系统默认参数一致
### 功能测试
- 全局模型参数页可动态展示所有模型参数
- 项目参数页可动态展示所有模型参数
- 全局参数修改后可保存成功
- 默认配置项目读取系统默认参数
- 默认配置项目首次保存后切换为 `custom`
- 历史 `custom` 项目不补新增参数,且页面只展示其自身已有参数
### 回归测试
- 现有 `listAll/saveAll` 接口可继续使用
- 页面不再引入任何千分位相关逻辑
- 保存后页面重新加载正常,修改提示正常
## 验收标准
- 系统默认参数与 `assets/模型默认参数.csv` 完全一致
- 全局参数页和项目参数页均根据查询接口动态展示所有模型信息
- 默认配置项目的读取与首次保存行为正确
- 历史 `custom` 项目不被补齐、不受新增默认参数影响
- 前后端不存在千分位相关功能设计和实现
---
**设计日期:** 2026-03-16
**设计人员:** Codex
**审核状态:** 已确认

View File

@@ -0,0 +1,335 @@
# Model Param CSV Alignment Frontend Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Make the global and project model-parameter pages render all model information dynamically from the query API, remove any thousand-separator related design, and keep unified save behavior stable.
**Architecture:** Reuse the existing `listAll/saveAll` front-end flow and current card-based layout. Only adjust the page internals so rendering depends entirely on API payloads and modified-state tracking becomes reliably reactive in Vue 2.
**Tech Stack:** Vue 2, Element UI, Axios, npm
---
### Task 1: 盘点当前页面与 API 的真实状态
**Files:**
- Reference: `ruoyi-ui/src/api/ccdi/modelParam.js`
- Reference: `ruoyi-ui/src/views/ccdi/modelParam/index.vue`
- Reference: `ruoyi-ui/src/views/ccdiProject/components/detail/ParamConfig.vue`
**Step 1: 查看 API 层**
运行:
```bash
Get-Content -Raw 'ruoyi-ui/src/api/ccdi/modelParam.js'
```
预期:`listAllParams``saveAllParams` 已存在,无需重复新增接口方法。
**Step 2: 查看全局配置页**
运行:
```bash
Get-Content -Raw 'ruoyi-ui/src/views/ccdi/modelParam/index.vue'
```
预期:页面已按模型卡片展示,但要确认修改记录实现、动态渲染边界和空状态处理。
**Step 3: 查看项目配置页**
运行:
```bash
Get-Content -Raw 'ruoyi-ui/src/views/ccdiProject/components/detail/ParamConfig.vue'
```
预期:页面已按模型卡片展示,但与全局页一样需要做实现收敛。
**Step 4: 记录结论**
确认本次前端重点不是“重做布局”,而是:
- 完全依赖接口动态展示
- 去掉千分位相关设计
- 优化修改记录实现
### Task 2: 重构全局参数页的动态展示实现
**Files:**
- Modify: `ruoyi-ui/src/views/ccdi/modelParam/index.vue`
**Step 1: 先写一个失败前检查**
启动前端前先做静态检查:
```bash
Get-Content -Raw 'ruoyi-ui/src/views/ccdi/modelParam/index.vue'
```
重点确认是否存在以下问题:
- 使用 `Set + $forceUpdate`
- 对模型或参数数量有隐含假设
- 对格式化值有额外处理
**Step 2: 写最小实现改动**
将页面调整为:
- 直接渲染 `res.data.models || []`
- 通过 `model.modelCode``model.modelName``row.paramName``row.paramDesc``row.paramValue``row.paramUnit` 动态展示
- 不做任何千分位格式化或清洗
- 将修改记录改为响应式对象,例如:
```javascript
modifiedParams: {}
```
并使用类似下面的键:
```javascript
const modifiedKey = `${modelCode}:${row.paramCode}`
```
**Step 3: 写保存组装逻辑**
保证 `handleSaveAll` 只提交已修改项,且请求体继续符合现有接口:
```json
{
"projectId": 0,
"models": [
{
"modelCode": "LARGE_TRANSACTION",
"params": [
{
"paramCode": "SINGLE_TRANSACTION_AMOUNT",
"paramValue": "1111"
}
]
}
]
}
```
**Step 4: 本地编译验证**
运行:
```bash
cd ruoyi-ui
npm run build:prod
```
预期:构建成功,无语法错误。
**Step 5: 提交**
```bash
git add ruoyi-ui/src/views/ccdi/modelParam/index.vue
git commit -m "feat: 优化全局模型参数页动态展示"
```
### Task 3: 重构项目参数页的动态展示实现
**Files:**
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/ParamConfig.vue`
**Step 1: 查看当前实现**
运行:
```bash
Get-Content -Raw 'ruoyi-ui/src/views/ccdiProject/components/detail/ParamConfig.vue'
```
预期:与全局页类似,也在使用 `Set + $forceUpdate` 或同类逻辑。
**Step 2: 写最小实现改动**
将页面调整为:
- 完全根据 `listAllParams({ projectId: this.projectId })` 返回结果动态渲染
- 不写死模型名称、数量、顺序和参数结构
- 复用与全局页一致的响应式修改记录方案
- 保存成功后重新加载接口数据
**Step 3: 校验默认项目和自定义项目行为**
确保页面本身不做 `default/custom` 分支拼装,只消费接口返回结果。
**Step 4: 本地编译验证**
运行:
```bash
cd ruoyi-ui
npm run build:prod
```
预期:构建成功。
**Step 5: 提交**
```bash
git add ruoyi-ui/src/views/ccdiProject/components/detail/ParamConfig.vue
git commit -m "feat: 优化项目模型参数页动态展示"
```
### Task 4: 统一前端修改记录与保存逻辑
**Files:**
- Modify: `ruoyi-ui/src/views/ccdi/modelParam/index.vue`
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/ParamConfig.vue`
**Step 1: 抽出共同规则**
两个页面都应满足:
- 修改后才计入 `modifiedCount`
- 未修改时点击保存提示“没有需要保存的修改”
- 保存成功后清空修改记录并重新拉取数据
**Step 2: 移除不稳定实现**
删除或替换:
- `new Set()`
- `$forceUpdate()`
改为 Vue 2 可稳定追踪的普通对象结构。
**Step 3: 静态验证**
运行:
```bash
Get-Content -Raw 'ruoyi-ui/src/views/ccdi/modelParam/index.vue'
Get-Content -Raw 'ruoyi-ui/src/views/ccdiProject/components/detail/ParamConfig.vue'
```
预期:页面内部不再依赖 `Set` 的响应式边界。
**Step 4: 构建验证**
运行:
```bash
cd ruoyi-ui
npm run build:prod
```
预期:构建成功。
**Step 5: 提交**
```bash
git add ruoyi-ui/src/views/ccdi/modelParam/index.vue ruoyi-ui/src/views/ccdiProject/components/detail/ParamConfig.vue
git commit -m "refactor: 收敛模型参数页修改状态管理"
```
### Task 5: 验证“无千分位设计”和“接口驱动展示”
**Files:**
- Optional Record: `docs/test-records/model-param-frontend-alignment-test.md`
**Step 1: 启动前端开发服务**
运行:
```bash
cd ruoyi-ui
npm run dev
```
**Step 2: 验证全局参数页**
检查:
- 页面根据接口返回显示全部模型
- 模型标题、参数名称、描述、单位均来自接口
- 输入框不自动插入千分位逗号
**Step 3: 验证项目参数页**
检查:
- 默认配置项目展示系统默认参数全集
- 历史 `custom` 项目只展示自身已有参数
- 页面不写死模型数量和参数数量
**Step 4: 验证统一保存**
检查:
- 修改一个参数后 `modifiedCount` 正确增加
- 保存后成功提示正常
- 重新加载后值与接口一致
**Step 5: 停止前端进程并记录结果**
测试结束后关闭 `npm run dev` 启动的进程,并把结果写入:
```text
docs/test-records/model-param-frontend-alignment-test.md
```
然后提交:
```bash
git add docs/test-records/model-param-frontend-alignment-test.md
git commit -m "test: 记录模型参数前端动态展示验证"
```
### Task 6: 做一次前后端联调验收
**Files:**
- Reference: `ruoyi-ui/src/views/ccdi/modelParam/index.vue`
- Reference: `ruoyi-ui/src/views/ccdiProject/components/detail/ParamConfig.vue`
- Reference: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiModelParamServiceImpl.java`
**Step 1: 同时启动前后端**
运行:
```bash
mvn -pl ruoyi-admin -am spring-boot:run
```
```bash
cd ruoyi-ui
npm run dev
```
**Step 2: 联调全局参数页**
验证:
- 接口返回的 5 个模型全部显示
- 参数值与系统默认参数一致
- 修改并保存后,刷新仍保持最新值
**Step 3: 联调项目参数页**
验证:
- `default` 项目读取系统默认参数
- 首次保存后项目切为 `custom`
- 历史 `custom` 项目不补新增模型或参数
**Step 4: 结束测试进程**
按仓库约定,测试结束后关闭前后端进程。
**Step 5: 提交联调记录**
```bash
git add docs/test-records/model-param-frontend-alignment-test.md docs/test-records/model-param-backend-alignment-test.md
git commit -m "test: 完成模型参数前后端联调验收"
```
---
Plan complete and saved to `docs/plans/2026-03-16-model-param-csv-alignment-frontend-implementation.md`.

View File

@@ -0,0 +1,47 @@
# Param Save Bar Fixed Bottom Backend Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 评估并确认模型参数页底部保存栏悬浮需求是否需要后端配合,并输出后端执行结论。
**Architecture:** 本次需求仅涉及前端页面布局与样式调整参数查询和保存接口、返回结构、DTO 以及持久化逻辑均不发生变化。后端实施计划以确认边界和回归验证要点为主,不引入代码修改。
**Tech Stack:** Java 21, Spring Boot 3, 若依后端接口, 现有模型参数保存 API
---
### Task 1: 确认需求边界
**Files:**
- Review: `docs/plans/2026-03-16-param-save-bar-fixed-bottom-design.md`
- Review: `ccdi-project`
- Review: `ccdi-info-collection`
**Step 1: 阅读设计文档并确认影响面**
检查设计文档中关于“仅修改前端布局”的描述,确认没有新增字段、接口或保存时机变更。
**Step 2: 对照现有接口职责**
确认 `listAllParams``saveAllParams` 相关接口仍可满足前端使用,不需要新增响应字段或调整 DTO。
**Step 3: 记录实施结论**
结论应明确写为:后端无需改动,仅需配合前端回归验证现有保存流程。
### Task 2: 回归验证清单
**Files:**
- Review: `docs/plans/2026-03-16-param-save-bar-fixed-bottom-design.md`
**Step 1: 验证参数查询接口**
确保页面布局调整后,前端仍以原方式调用查询接口,接口入参与返回结构保持不变。
**Step 2: 验证批量保存接口**
确保前端仅改变按钮展示位置,不改变 `saveDTO` 结构与保存触发方式。
**Step 3: 记录无需联调变更**
在实施说明中注明:后端只需参与已有保存链路的回归确认,不需要代码发布。

View File

@@ -0,0 +1,72 @@
# 模型参数页底部保存栏悬浮设计
**背景**
当前“模型参数修改”相关页面中的“保存所有修改”按钮位于普通文档流末尾。参数卡片较多时,用户需要滚动到页面底部才能看到保存入口,保存操作不够直观,也容易在编辑过程中误以为没有统一保存入口。
**目标**
将“保存所有修改”区域调整为在页面滚动过程中始终保持可见的底部操作栏,覆盖以下页面:
- 项目详情中的参数配置页 `ruoyi-ui/src/views/ccdiProject/components/detail/ParamConfig.vue`
- 全局模型参数管理页 `ruoyi-ui/src/views/ccdi/modelParam/index.vue`
**约束**
- 不修改参数加载、修改标记、批量保存等现有业务逻辑。
- 保持两个页面交互一致,避免同类页面体验割裂。
- 适配当前若依布局中 `AppMain` 作为滚动容器的结构。
- 兼顾桌面端与移动端显示,避免按钮区压住主要内容。
**方案选择**
## 方案一:使用 `position: sticky; bottom: 0`
将保存栏保留在页面内容流中,但通过 `sticky` 吸附在滚动容器底部。
优点:
- 与当前若依布局兼容性最好。
- 不需要手动计算侧边栏宽度、主内容区宽度和响应式偏移。
- 实际用户体验上可达到“滚动时始终可见”的效果。
缺点:
- 严格意义上是吸附在内容滚动容器底部,而不是直接固定到 `window`
## 方案二:使用 `position: fixed`
将保存栏直接固定到浏览器视口底部。
优点:
- 语义上最接近“固定在浏览器底部”。
缺点:
- 需要额外处理左侧菜单宽度、固定头部、移动端安全区域和页面宽度同步。
- 在若依当前布局下更容易出现遮挡或错位。
**结论**
采用方案一。对于当前项目的页面结构,这种实现可以稳定地实现底部悬浮效果,同时将实现复杂度和回归风险控制在最低。
**界面设计**
- 保留“保存所有修改”按钮和“已修改 X 个参数”文案。
- 将按钮区改造成统一的底部操作栏。
- 操作栏使用白底、顶部边框和轻阴影,与卡片区分层。
- 桌面端使用左右分布或同一行布局。
- 移动端允许换行,确保按钮点击区域充足。
**内容区域处理**
- 页面主体增加底部留白,避免最后一张模型卡片或表格内容在滚动时被吸附栏遮挡。
- 空状态页面不显示底部保存栏。
**测试与验证**
- 验证项目详情参数配置页在多组参数卡片下滚动时保存栏持续可见。
- 验证全局模型参数页在多组参数卡片下滚动时保存栏持续可见。
- 验证移动端宽度下保存按钮与“已修改 X 个参数”文案不重叠。
- 验证未修改参数、已修改参数、保存中三种状态下按钮区表现正常。

View File

@@ -0,0 +1,84 @@
# Param Save Bar Fixed Bottom Frontend Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 将模型参数页的“保存所有修改”区域调整为滚动时始终可见的底部吸附操作栏,并统一项目详情页与全局模型参数页的展示效果。
**Architecture:** 在两个现有 Vue 页面中复用同一套样式策略:保留现有保存逻辑,仅通过 `position: sticky; bottom: 0` 将按钮区吸附到滚动容器底部,同时补充页面底部留白和移动端布局适配。这样可以与若依当前的 `AppMain` 滚动容器兼容,避免 `fixed` 带来的侧边栏遮挡问题。
**Tech Stack:** Vue 2, Element UI, SCSS, 若依前端布局
---
### Task 1: 为项目详情参数页补充底部吸附样式
**Files:**
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/ParamConfig.vue`
**Step 1: 写出预期行为检查点**
确认以下目标行为:
- 页面滚动时“保存所有修改”始终可见
- 最后一张模型卡片不会被保存栏遮挡
- 已修改计数文案继续正常展示
**Step 2: 调整保存栏结构样式**
`button-section` 改为底部吸附操作栏,补充:
- `position: sticky`
- `bottom: 0`
- `z-index`
- 顶部边框与阴影
- 桌面端横向排列
**Step 3: 补充容器底部留白**
在页面主容器增加足够的 `padding-bottom`,避免内容被悬浮栏遮挡。
**Step 4: 增加移动端适配**
在媒体查询中让保存栏换行显示,并让按钮在窄屏下占满宽度。
### Task 2: 为全局模型参数页应用同样的吸附方案
**Files:**
- Modify: `ruoyi-ui/src/views/ccdi/modelParam/index.vue`
**Step 1: 复用项目详情页的布局样式策略**
保持保存栏的结构与交互一致,避免两个模型参数页体验不一致。
**Step 2: 校正全局页容器留白**
结合页面标题区和卡片区,补足底部滚动空间,确保最后一组参数完整可见。
**Step 3: 保持空状态不显示保存栏**
确认 `modelGroups.length === 0` 时仍仅展示空状态,不渲染底部操作栏。
### Task 3: 进行前端回归验证
**Files:**
- Review: `ruoyi-ui/src/views/ccdiProject/components/detail/ParamConfig.vue`
- Review: `ruoyi-ui/src/views/ccdi/modelParam/index.vue`
**Step 1: 执行构建验证**
Run: `npm run build:prod`
Expected: 前端生产构建成功,样式改动未引入编译错误。
**Step 2: 手工检查两个页面**
验证点:
- 滚动时保存栏持续可见
- 修改参数后计数正常变化
- 点击保存后逻辑不受影响
- 窄屏下按钮区不重叠、不溢出
**Step 3: 记录验证结果**
在交付说明中明确区分已执行的构建验证与建议的页面人工回归项。

View File

@@ -0,0 +1,760 @@
# Project Bank Statement Tagging Backend Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 为项目流水新增自动打标与手动重算后端能力,支持批量上传和拉取本行信息两条链路自动触发,支持项目级互斥重算与规则级并行执行。
**Architecture:**`ccdi-project` 中新增标签规则表、结果表、任务表及对应 Mapper引入项目级重算协调器和规则级线程池`CcdiFileUploadServiceImpl` 的批量上传与拉取本行信息收尾阶段统一申请项目级标签重算;通过独立的标签服务读取规则元数据、参数配置,调度 Mapper XML 中的规则 SQL 并写入结果表。
**Tech Stack:** Java 21, Spring Boot 3, MyBatis Plus, MyBatis XML, JUnit 5, Mockito, Maven
---
### Task 1: 定义手动重算接口契约
**Files:**
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiBankTagRebuildDTO.java`
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiBankTagController.java`
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiBankTagService.java`
- Test: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiBankTagControllerTest.java`
**Step 1: Write the failing test**
新增控制器测试,验证接口会把 `projectId + modelCode` 透传到 Service
```java
@Test
void rebuild_shouldDelegateProjectAndModelCode() {
CcdiBankTagRebuildDTO dto = new CcdiBankTagRebuildDTO();
dto.setProjectId(40L);
dto.setModelCode("LARGE_TRANSACTION");
when(bankTagService.submitRebuild(dto, "admin")).thenReturn("标签重算任务已提交");
try (MockedStatic<SecurityUtils> mocked = mockStatic(SecurityUtils.class)) {
mocked.when(SecurityUtils::getUsername).thenReturn("admin");
AjaxResult result = controller.rebuild(dto);
assertEquals(200, result.get("code"));
verify(bankTagService).submitRebuild(dto, "admin");
}
}
```
**Step 2: Run test to verify it fails**
Run:
```bash
mvn test -pl ccdi-project -am -Dtest=CcdiBankTagControllerTest#rebuild_shouldDelegateProjectAndModelCode
```
Expected:
- `FAIL`
- 原因是 DTO、Controller 或 Service 契约尚不存在
**Step 3: Write minimal implementation**
补最小接口:
```java
public interface ICcdiBankTagService {
String submitRebuild(CcdiBankTagRebuildDTO dto, String operator);
}
```
```java
@PostMapping("/rebuild")
public AjaxResult rebuild(@Validated @RequestBody CcdiBankTagRebuildDTO dto) {
String operator = SecurityUtils.getUsername();
return AjaxResult.success(bankTagService.submitRebuild(dto, operator));
}
```
**Step 4: Run test to verify it passes**
Run:
```bash
mvn test -pl ccdi-project -am -Dtest=CcdiBankTagControllerTest#rebuild_shouldDelegateProjectAndModelCode
```
Expected:
- `PASS`
**Step 5: Commit**
```bash
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiBankTagRebuildDTO.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiBankTagController.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiBankTagService.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiBankTagControllerTest.java
git commit -m "test: 补充流水标签重算接口契约"
```
### Task 2: 新增标签核心表结构与实体映射
**Files:**
- Create: `sql/2026-03-16-bank-tagging.sql`
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/entity/CcdiBankTagRule.java`
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/entity/CcdiBankTagResult.java`
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/entity/CcdiBankTagTask.java`
- Test: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/domain/entity/CcdiBankTagEntityMappingTest.java`
**Step 1: Write the failing test**
新增一个轻量测试,校验结果实体的关键字段存在:
```java
@Test
void bankTagResult_shouldExposeStatementAndObjectFields() {
CcdiBankTagResult result = new CcdiBankTagResult();
result.setProjectId(40L);
result.setRuleCode("RULE_1");
result.setBankStatementId(10L);
result.setGroupId(40);
result.setLogId(40001);
result.setObjectType("STAFF_ID_CARD");
result.setObjectKey("330101198801010011");
assertEquals(40L, result.getProjectId());
assertEquals(40001, result.getLogId());
assertEquals("STAFF_ID_CARD", result.getObjectType());
}
```
**Step 2: Run test to verify it fails**
Run:
```bash
mvn test -pl ccdi-project -am -Dtest=CcdiBankTagEntityMappingTest#bankTagResult_shouldExposeStatementAndObjectFields
```
Expected:
- `FAIL`
- 原因是新实体尚不存在
**Step 3: Write minimal implementation**
- 新建三张表的 SQL 脚本
- 新建三个实体类,字段只覆盖第一版设计所需字段
- 规则表初始化“大额交易” 8 条规则元数据
**Step 4: Run test to verify it passes**
Run:
```bash
mvn test -pl ccdi-project -am -Dtest=CcdiBankTagEntityMappingTest#bankTagResult_shouldExposeStatementAndObjectFields
```
Expected:
- `PASS`
**Step 5: Commit**
```bash
git add sql/2026-03-16-bank-tagging.sql ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/entity/CcdiBankTagRule.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/entity/CcdiBankTagResult.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/entity/CcdiBankTagTask.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/domain/entity/CcdiBankTagEntityMappingTest.java
git commit -m "feat: 新增流水标签核心表结构与实体映射"
```
### Task 3: 建立规则元数据与结果表 Mapper
**Files:**
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagRuleMapper.java`
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagResultMapper.java`
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagTaskMapper.java`
- Create: `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagRuleMapper.xml`
- Create: `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagResultMapper.xml`
- Create: `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagTaskMapper.xml`
- Test: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagResultMapperXmlTest.java`
**Step 1: Write the failing test**
新增 XML 渲染测试,校验结果表支持按项目或项目+模型删除:
```java
@Test
void deleteByProjectAndOptionalModel_shouldRenderScopedDelete() throws Exception {
String xml = readXml("mapper/ccdi/project/CcdiBankTagResultMapper.xml");
assertTrue(xml.contains("delete from ccdi_bank_statement_tag_result"));
assertTrue(xml.contains("project_id = #{projectId}"));
assertTrue(xml.contains("model_code = #{modelCode}"));
}
```
**Step 2: Run test to verify it fails**
Run:
```bash
mvn test -pl ccdi-project -am -Dtest=CcdiBankTagResultMapperXmlTest#deleteByProjectAndOptionalModel_shouldRenderScopedDelete
```
Expected:
- `FAIL`
- 原因是 Mapper XML 尚不存在
**Step 3: Write minimal implementation**
- 规则表 Mapper 提供启用规则查询
- 结果表 Mapper 提供批量插入和按范围删除
- 任务表 Mapper 提供创建任务、更新任务、查询运行中任务
**Step 4: Run test to verify it passes**
Run:
```bash
mvn test -pl ccdi-project -am -Dtest=CcdiBankTagResultMapperXmlTest#deleteByProjectAndOptionalModel_shouldRenderScopedDelete
```
Expected:
- `PASS`
**Step 5: Commit**
```bash
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagRuleMapper.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagResultMapper.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagTaskMapper.java ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagRuleMapper.xml ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagResultMapper.xml ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagTaskMapper.xml ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagResultMapperXmlTest.java
git commit -m "feat: 新增流水标签规则结果任务Mapper"
```
### Task 4: 为规则 SQL 定义统一返回 VO
**Files:**
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/BankTagStatementHitVO.java`
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/BankTagObjectHitVO.java`
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapper.java`
- Create: `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml`
- Test: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapperXmlTest.java`
**Step 1: Write the failing test**
新增 XML 测试,先校验流水级命中 SQL 会返回 `group_id``log_id`
```java
@Test
void statementRuleSql_shouldSelectGroupIdAndLogId() throws Exception {
String xml = readXml("mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml");
assertTrue(xml.contains("AS groupId"));
assertTrue(xml.contains("AS logId"));
}
```
**Step 2: Run test to verify it fails**
Run:
```bash
mvn test -pl ccdi-project -am -Dtest=CcdiBankTagAnalysisMapperXmlTest#statementRuleSql_shouldSelectGroupIdAndLogId
```
Expected:
- `FAIL`
**Step 3: Write minimal implementation**
先定义统一 VO 和一个最小的 XML 框架,确保:
- 流水级规则 SQL 返回 `bankStatementId``groupId``logId``reasonDetail`
- 对象级规则 SQL 返回 `objectType``objectKey``reasonDetail`
**Step 4: Run test to verify it passes**
Run:
```bash
mvn test -pl ccdi-project -am -Dtest=CcdiBankTagAnalysisMapperXmlTest#statementRuleSql_shouldSelectGroupIdAndLogId
```
Expected:
- `PASS`
**Step 5: Commit**
```bash
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/BankTagStatementHitVO.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/BankTagObjectHitVO.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapper.java ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapperXmlTest.java
git commit -m "feat: 定义流水标签规则命中返回结构"
```
### Task 5: 先实现一条流水级规则的失败测试与最小通过
**Files:**
- Modify: `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml`
- Test: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapperXmlTest.java`
**Step 1: Write the failing test**
以“房车消费支出交易”为第一条规则,测试 XML 中存在 `selectHouseOrCarExpenseStatements`
```java
@Test
void houseOrCarExpenseRule_shouldJoinBankStatementAndReturnStatementHitFields() throws Exception {
String xml = readXml("mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml");
assertTrue(xml.contains("selectHouseOrCarExpenseStatements"));
assertTrue(xml.contains("bs.bank_statement_id AS bankStatementId"));
assertTrue(xml.contains("bs.group_id AS groupId"));
assertTrue(xml.contains("bs.batch_id AS logId"));
}
```
**Step 2: Run test to verify it fails**
Run:
```bash
mvn test -pl ccdi-project -am -Dtest=CcdiBankTagAnalysisMapperXmlTest#houseOrCarExpenseRule_shouldJoinBankStatementAndReturnStatementHitFields
```
Expected:
- `FAIL`
**Step 3: Write minimal implementation**
在 XML 中补第一条规则 SQL并返回固定原因摘要。
**Step 4: Run test to verify it passes**
Run:
```bash
mvn test -pl ccdi-project -am -Dtest=CcdiBankTagAnalysisMapperXmlTest#houseOrCarExpenseRule_shouldJoinBankStatementAndReturnStatementHitFields
```
Expected:
- `PASS`
**Step 5: Commit**
```bash
git add ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapperXmlTest.java
git commit -m "test: 打通首条流水标签规则SQL"
```
### Task 6: 完成剩余 7 条规则 SQL
**Files:**
- Modify: `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml`
- Test: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapperXmlTest.java`
**Step 1: Write the failing tests**
为剩余规则补 XML 存在性断言,分别覆盖:
- `selectTaxExpenseStatements`
- `selectSingleLargeIncomeStatements`
- `selectCumulativeIncomeObjects`
- `selectAnnualTurnoverObjects`
- `selectLargeCashDepositStatements`
- `selectFrequentCashDepositObjects`
- `selectLargeTransferStatements`
**Step 2: Run test to verify it fails**
Run:
```bash
mvn test -pl ccdi-project -am -Dtest=CcdiBankTagAnalysisMapperXmlTest
```
Expected:
- `FAIL`
**Step 3: Write minimal implementation**
补齐 7 条规则 SQL要求
- 阈值类规则使用入参,不在 XML 中写死参数值
- 流水级规则返回 `bankStatementId/groupId/logId/reasonDetail`
- 对象级规则返回 `objectType/objectKey/reasonDetail`
**Step 4: Run test to verify it passes**
Run:
```bash
mvn test -pl ccdi-project -am -Dtest=CcdiBankTagAnalysisMapperXmlTest
```
Expected:
- `PASS`
**Step 5: Commit**
```bash
git add ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapperXmlTest.java
git commit -m "feat: 补齐大额交易全部标签规则SQL"
```
### Task 7: 实现项目参数读取与规则注册表
**Files:**
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/BankTagRuleExecutionConfig.java`
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/BankTagRuleConfigResolver.java`
- Test: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/BankTagRuleConfigResolverTest.java`
**Step 1: Write the failing test**
新增测试,验证 resolver 会根据项目读取有效参数,并为规则产出执行配置:
```java
@Test
void resolve_shouldReadEffectiveProjectParamsForThresholdRules() {
BankTagRuleExecutionConfig config = resolver.resolve(40L, ruleMeta);
assertEquals("1111", config.getThresholdValue("SINGLE_TRANSACTION_AMOUNT"));
}
```
**Step 2: Run test to verify it fails**
Run:
```bash
mvn test -pl ccdi-project -am -Dtest=BankTagRuleConfigResolverTest#resolve_shouldReadEffectiveProjectParamsForThresholdRules
```
Expected:
- `FAIL`
**Step 3: Write minimal implementation**
实现 resolver
- 读取项目 `configType`
- 选择有效 `projectId`
- 读取模型参数
-`ruleCode` 组装执行配置
**Step 4: Run test to verify it passes**
Run:
```bash
mvn test -pl ccdi-project -am -Dtest=BankTagRuleConfigResolverTest#resolve_shouldReadEffectiveProjectParamsForThresholdRules
```
Expected:
- `PASS`
**Step 5: Commit**
```bash
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/BankTagRuleExecutionConfig.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/BankTagRuleConfigResolver.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/BankTagRuleConfigResolverTest.java
git commit -m "feat: 新增流水标签规则执行参数解析器"
```
### Task 8: 实现规则级并行执行服务
**Files:**
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/config/BankTagThreadPoolConfig.java`
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImpl.java`
- Test: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImplTest.java`
**Step 1: Write the failing test**
新增服务测试,验证项目级任务会先删旧结果,再并行执行规则并汇总命中数:
```java
@Test
void rebuildProject_shouldDeleteOldResultsBeforeSubmittingRuleTasks() {
service.rebuildProject(40L, null, "admin", TriggerType.MANUAL);
verify(resultMapper).deleteByProjectAndModel(40L, null);
verify(resultMapper).insertBatch(anyList());
}
```
**Step 2: Run test to verify it fails**
Run:
```bash
mvn test -pl ccdi-project -am -Dtest=CcdiBankTagServiceImplTest#rebuildProject_shouldDeleteOldResultsBeforeSubmittingRuleTasks
```
Expected:
- `FAIL`
**Step 3: Write minimal implementation**
实现 `CcdiBankTagServiceImpl`
- 查询启用规则
- 删旧结果
- 使用 `tagRuleExecutor` 并行提交每条规则
- 汇总命中结果
- 更新任务状态
**Step 4: Run test to verify it passes**
Run:
```bash
mvn test -pl ccdi-project -am -Dtest=CcdiBankTagServiceImplTest#rebuildProject_shouldDeleteOldResultsBeforeSubmittingRuleTasks
```
Expected:
- `PASS`
**Step 5: Commit**
```bash
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/config/BankTagThreadPoolConfig.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImpl.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImplTest.java
git commit -m "feat: 实现规则级并行流水标签重算服务"
```
### Task 9: 实现同项目互斥与自动补跑协调器
**Files:**
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/ProjectBankTagRebuildCoordinator.java`
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImpl.java`
- Test: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/ProjectBankTagRebuildCoordinatorTest.java`
**Step 1: Write the failing test**
新增测试,验证同一项目运行中时:
- 手动触发会被拒绝
- 自动触发会标记 `need_rerun`
```java
@Test
void submitManualRebuild_shouldRejectWhenProjectAlreadyRunning() {
assertThrows(ServiceException.class, () -> coordinator.submitManual(40L, null, "admin"));
}
```
**Step 2: Run test to verify it fails**
Run:
```bash
mvn test -pl ccdi-project -am -Dtest=ProjectBankTagRebuildCoordinatorTest
```
Expected:
- `FAIL`
**Step 3: Write minimal implementation**
实现协调器:
- 使用 `projectId` 级别互斥
- 自动触发遇到运行中任务时打 `need_rerun`
- 当前任务结束后根据 `need_rerun` 触发补跑
**Step 4: Run test to verify it passes**
Run:
```bash
mvn test -pl ccdi-project -am -Dtest=ProjectBankTagRebuildCoordinatorTest
```
Expected:
- `PASS`
**Step 5: Commit**
```bash
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/ProjectBankTagRebuildCoordinator.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImpl.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/ProjectBankTagRebuildCoordinatorTest.java
git commit -m "feat: 新增项目级流水标签重算互斥与补跑协调器"
```
### Task 10: 接入批量上传收尾自动触发
**Files:**
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java`
- Test: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java`
**Step 1: Write the failing test**
新增测试,验证批量上传所有文件完成后会申请项目级重算:
```java
@Test
void batchUploadCompletion_shouldSubmitProjectTagRebuildWhenAnyFileSucceeded() {
verify(bankTagService).submitAutoRebuild(40L, TriggerType.AUTO_BATCH_UPLOAD);
}
```
**Step 2: Run test to verify it fails**
Run:
```bash
mvn test -pl ccdi-project -am -Dtest=CcdiFileUploadServiceImplTest#batchUploadCompletion_shouldSubmitProjectTagRebuildWhenAnyFileSucceeded
```
Expected:
- `FAIL`
**Step 3: Write minimal implementation**
重构 `submitTasksAsync`
- 收集每个文件任务的 `CompletableFuture`
- `allOf(...).whenComplete(...)` 收尾
- 至少一个文件成功落库时申请自动重算
**Step 4: Run test to verify it passes**
Run:
```bash
mvn test -pl ccdi-project -am -Dtest=CcdiFileUploadServiceImplTest#batchUploadCompletion_shouldSubmitProjectTagRebuildWhenAnyFileSucceeded
```
Expected:
- `PASS`
**Step 5: Commit**
```bash
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java
git commit -m "feat: 接入批量上传完成后的自动流水打标"
```
### Task 11: 接入拉取本行信息收尾自动触发
**Files:**
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java`
- Test: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java`
**Step 1: Write the failing test**
新增测试,验证拉取本行信息全部任务完成后也会申请项目级重算:
```java
@Test
void pullBankInfoCompletion_shouldSubmitProjectTagRebuildWhenAnyTaskSucceeded() {
verify(bankTagService).submitAutoRebuild(40L, TriggerType.AUTO_PULL_BANK_INFO);
}
```
**Step 2: Run test to verify it fails**
Run:
```bash
mvn test -pl ccdi-project -am -Dtest=CcdiFileUploadServiceImplTest#pullBankInfoCompletion_shouldSubmitProjectTagRebuildWhenAnyTaskSucceeded
```
Expected:
- `FAIL`
**Step 3: Write minimal implementation**
仿照批量上传链路改造 `submitPullBankInfoTasks`,在全部任务结束后申请一次自动重算。
**Step 4: Run test to verify it passes**
Run:
```bash
mvn test -pl ccdi-project -am -Dtest=CcdiFileUploadServiceImplTest#pullBankInfoCompletion_shouldSubmitProjectTagRebuildWhenAnyTaskSucceeded
```
Expected:
- `PASS`
**Step 5: Commit**
```bash
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java
git commit -m "feat: 接入拉取本行信息完成后的自动流水打标"
```
### Task 12: 完成全量验证
**Files:**
- Modify: `docs/plans/2026-03-16-project-bank-statement-tagging-backend-implementation.md`
**Step 1: Run focused backend tests**
Run:
```bash
mvn test -pl ccdi-project -am -Dtest=CcdiBankTagControllerTest,CcdiBankTagEntityMappingTest,CcdiBankTagResultMapperXmlTest,CcdiBankTagAnalysisMapperXmlTest,BankTagRuleConfigResolverTest,CcdiBankTagServiceImplTest,ProjectBankTagRebuildCoordinatorTest,CcdiFileUploadServiceImplTest
```
Expected:
- 所有新增测试通过
**Step 2: Run module compile**
Run:
```bash
mvn clean compile -pl ccdi-project -am
```
Expected:
- `BUILD SUCCESS`
**Step 3: Update verification notes**
在本实施计划末尾补充实际执行结果摘要和任何遗留风险。
**Step 4: Commit**
```bash
git add ccdi-project sql docs/plans/2026-03-16-project-bank-statement-tagging-backend-implementation.md
git commit -m "feat: 完成项目流水标签后端实现"
```
---
## 实际执行结果
### 已完成范围
- 已新增手动重算接口、标签规则/结果/任务三张核心表及实体映射
- 已新增规则、结果、任务、分析四类 Mapper 与 XML
- 已完成“大额交易” 8 条规则 SQL 的首版落地
- 已完成规则参数解析器、规则级线程池、规则级并行重算服务
- 已完成项目级互斥协调器与自动触发补跑标记逻辑
- 已接入批量上传与拉取本行信息两条链路的批次收尾自动触发
### 实际验证命令
`ccdi-project` 模块目录执行:
```bash
mvn test "-Dtest=CcdiBankTagControllerTest,CcdiBankTagEntityMappingTest,CcdiBankTagResultMapperXmlTest,CcdiBankTagAnalysisMapperXmlTest,BankTagRuleConfigResolverTest,CcdiBankTagServiceImplTest,ProjectBankTagRebuildCoordinatorTest,CcdiFileUploadServiceImplTest"
mvn clean compile
```
### 实际验证结果
- 聚焦测试集:`BUILD SUCCESS`
- `ccdi-project` 模块编译:`BUILD SUCCESS`
- 聚焦测试共执行 30 个测试0 失败0 错误
### 遗留风险
- 当前规则 SQL 已按设计稿和现有表结构落地,但尚未补集成测试验证真实数据库数据命中情况
- 自动补跑已支持 `need_rerun` 标记与串行补跑,后续建议增加更完整的并发场景回归测试
- 当前实施仅完成后端能力,结果查询接口与前端展示仍未接入

View File

@@ -0,0 +1,410 @@
# Project Upload File Delete Backend Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 为项目上传文件列表新增后端删除能力,支持删除已解析成功的文件、清理本地流水,并把上传记录状态更新为 `deleted`
**Architecture:**`CcdiFileUploadController` 新增按记录 ID 删除接口,由 Controller 获取当前登录用户 ID 并传给 `ICcdiFileUploadService`。Service 负责查询记录、校验状态、调用 `LsfxAnalysisClient.deleteFiles`、删除 `ccdi_bank_statement` 中对应 `logId` 的流水,并更新 `ccdi_file_upload_record.file_status``deleted`;统计 VO 同步扩展 `deleted` 状态。
**Tech Stack:** Java 21, Spring Boot 3, MyBatis Plus, JUnit 5, Mockito, Maven
---
### Task 1: 补齐删除接口控制器契约
**Files:**
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadController.java`
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiFileUploadService.java`
- Test: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadControllerTest.java`
**Step 1: Write the failing test**
`CcdiFileUploadControllerTest` 中新增测试,验证删除接口会读取当前登录用户 ID 并调用 Service
```java
@Test
void deleteFile_shouldUseCurrentLoginUserId() {
try (MockedStatic<SecurityUtils> mocked = mockStatic(SecurityUtils.class)) {
mocked.when(SecurityUtils::getUserId).thenReturn(9527L);
when(fileUploadService.deleteFileUploadRecord(123L, 9527L))
.thenReturn("删除成功");
AjaxResult result = controller.deleteFile(123L);
assertEquals(200, result.get("code"));
assertEquals("删除成功", result.get("msg"));
verify(fileUploadService).deleteFileUploadRecord(123L, 9527L);
}
}
```
**Step 2: Run test to verify it fails**
Run:
```bash
mvn test -pl ccdi-project -am -Dtest=CcdiFileUploadControllerTest#deleteFile_shouldUseCurrentLoginUserId
```
Expected:
- `FAIL`
- 原因是 `CcdiFileUploadController` 中还没有 `deleteFile` 方法或 `ICcdiFileUploadService` 中还没有 `deleteFileUploadRecord` 方法
**Step 3: Write minimal implementation**
在接口和控制器中补最小实现:
```java
String deleteFileUploadRecord(Long id, Long operatorUserId);
```
```java
@DeleteMapping("/{id}")
@Operation(summary = "删除上传文件", description = "按上传记录ID删除文件并清理流水")
public AjaxResult deleteFile(@PathVariable Long id) {
Long userId = SecurityUtils.getUserId();
String message = fileUploadService.deleteFileUploadRecord(id, userId);
return AjaxResult.success(message);
}
```
**Step 4: Run test to verify it passes**
Run:
```bash
mvn test -pl ccdi-project -am -Dtest=CcdiFileUploadControllerTest#deleteFile_shouldUseCurrentLoginUserId
```
Expected:
- `PASS`
**Step 5: Commit**
```bash
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadController.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiFileUploadService.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadControllerTest.java
git commit -m "test: 补充上传文件删除接口控制器契约"
```
### Task 2: 实现删除成功主链路
**Files:**
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java`
- Test: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java`
**Step 1: Write the failing test**
`CcdiFileUploadServiceImplTest` 中新增成功链路测试:
```java
@Test
void deleteFileUploadRecord_shouldDeletePlatformFileBankStatementsAndMarkDeleted() {
CcdiFileUploadRecord record = buildRecord();
record.setProjectId(PROJECT_ID);
record.setLsfxProjectId(LSFX_PROJECT_ID);
record.setLogId(LOG_ID);
record.setFileStatus("parsed_success");
when(recordMapper.selectById(RECORD_ID)).thenReturn(record);
when(lsfxClient.deleteFiles(any())).thenReturn(buildDeleteFilesResponse());
String result = service.deleteFileUploadRecord(RECORD_ID, 9527L);
assertEquals("删除成功", result);
verify(lsfxClient).deleteFiles(argThat(request ->
request.getGroupId().equals(LSFX_PROJECT_ID)
&& request.getUserId().equals(9527)
&& request.getLogIds().length == 1
&& request.getLogIds()[0].equals(LOG_ID)
));
verify(bankStatementMapper).deleteByProjectIdAndBatchId(PROJECT_ID, LOG_ID);
verify(recordMapper).updateById(argThat(item ->
RECORD_ID.equals(item.getId()) && "deleted".equals(item.getFileStatus())
));
}
```
**Step 2: Run test to verify it fails**
Run:
```bash
mvn test -pl ccdi-project -am -Dtest=CcdiFileUploadServiceImplTest#deleteFileUploadRecord_shouldDeletePlatformFileBankStatementsAndMarkDeleted
```
Expected:
- `FAIL`
- 原因是 `CcdiFileUploadServiceImpl` 中还没有 `deleteFileUploadRecord` 实现
**Step 3: Write minimal implementation**
`CcdiFileUploadServiceImpl` 中实现删除主链路:
```java
@Override
@Transactional
public String deleteFileUploadRecord(Long id, Long operatorUserId) {
CcdiFileUploadRecord record = recordMapper.selectById(id);
validateDeleteRecord(record);
DeleteFilesRequest request = new DeleteFilesRequest();
request.setGroupId(record.getLsfxProjectId());
request.setLogIds(new Integer[]{record.getLogId()});
request.setUserId(toUploadUserId(operatorUserId));
DeleteFilesResponse response = lsfxClient.deleteFiles(request);
if (response == null || Boolean.FALSE.equals(response.getSuccessResponse())) {
throw new RuntimeException("流水分析平台删除文件失败");
}
bankStatementMapper.deleteByProjectIdAndBatchId(record.getProjectId(), record.getLogId());
CcdiFileUploadRecord update = new CcdiFileUploadRecord();
update.setId(record.getId());
update.setFileStatus("deleted");
recordMapper.updateById(update);
return "删除成功";
}
```
同时补一个私有校验方法,仅先满足成功路径所需字段。
**Step 4: Run test to verify it passes**
Run:
```bash
mvn test -pl ccdi-project -am -Dtest=CcdiFileUploadServiceImplTest#deleteFileUploadRecord_shouldDeletePlatformFileBankStatementsAndMarkDeleted
```
Expected:
- `PASS`
**Step 5: Commit**
```bash
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java
git commit -m "feat: 打通上传文件删除成功主链路"
```
### Task 3: 补齐删除前置校验与失败保护
**Files:**
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java`
- Test: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java`
**Step 1: Write the failing test**
为关键边界新增失败测试,至少覆盖“状态不允许删除”和“平台删除失败时不应更新本地状态”:
```java
@Test
void deleteFileUploadRecord_shouldRejectNonParsedSuccessStatus() {
CcdiFileUploadRecord record = buildRecord();
record.setFileStatus("parsed_failed");
when(recordMapper.selectById(RECORD_ID)).thenReturn(record);
RuntimeException exception = assertThrows(RuntimeException.class,
() -> service.deleteFileUploadRecord(RECORD_ID, 9527L));
assertTrue(exception.getMessage().contains("仅支持删除解析成功文件"));
}
@Test
void deleteFileUploadRecord_shouldStopWhenLsfxDeleteFails() {
CcdiFileUploadRecord record = buildRecord();
record.setFileStatus("parsed_success");
record.setLogId(LOG_ID);
record.setLsfxProjectId(LSFX_PROJECT_ID);
when(recordMapper.selectById(RECORD_ID)).thenReturn(record);
when(lsfxClient.deleteFiles(any())).thenThrow(new RuntimeException("lsfx delete failed"));
assertThrows(RuntimeException.class, () -> service.deleteFileUploadRecord(RECORD_ID, 9527L));
verify(bankStatementMapper, org.mockito.Mockito.never()).deleteByProjectIdAndBatchId(any(), any());
verify(recordMapper, org.mockito.Mockito.never()).updateById(argThat(item ->
"deleted".equals(item.getFileStatus())
));
}
```
**Step 2: Run test to verify it fails**
Run:
```bash
mvn test -pl ccdi-project -am -Dtest=CcdiFileUploadServiceImplTest#deleteFileUploadRecord_shouldRejectNonParsedSuccessStatus,CcdiFileUploadServiceImplTest#deleteFileUploadRecord_shouldStopWhenLsfxDeleteFails
```
Expected:
- `FAIL`
- 原因是现有实现还没有完整的前置校验和失败保护
**Step 3: Write minimal implementation**
补全 Service 校验和异常处理:
```java
private void validateDeleteRecord(CcdiFileUploadRecord record) {
if (record == null) {
throw new RuntimeException("上传记录不存在");
}
if (!"parsed_success".equals(record.getFileStatus())) {
if ("deleted".equals(record.getFileStatus())) {
throw new RuntimeException("文件已删除,请勿重复操作");
}
throw new RuntimeException("仅支持删除解析成功文件");
}
if (record.getLsfxProjectId() == null) {
throw new RuntimeException("缺少流水分析项目ID");
}
if (record.getLogId() == null) {
throw new RuntimeException("缺少文件logId");
}
}
```
不要吞掉平台删除异常,让事务直接回滚。
**Step 4: Run test to verify it passes**
Run:
```bash
mvn test -pl ccdi-project -am -Dtest=CcdiFileUploadServiceImplTest#deleteFileUploadRecord_shouldRejectNonParsedSuccessStatus,CcdiFileUploadServiceImplTest#deleteFileUploadRecord_shouldStopWhenLsfxDeleteFails
```
Expected:
- `PASS`
**Step 5: Commit**
```bash
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java
git commit -m "test: 补齐上传文件删除校验与失败保护"
```
### Task 4: 扩展统计口径支持 `deleted`
**Files:**
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiFileUploadStatisticsVO.java`
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java`
- Test: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java`
**Step 1: Write the failing test**
新增统计测试,验证 `deleted` 状态会被正确映射到 VO
```java
@Test
void countByStatus_shouldIncludeDeletedCount() {
when(recordMapper.countByStatus(PROJECT_ID)).thenReturn(List.of(
Map.of("status", "uploading", "count", 1),
Map.of("status", "deleted", "count", 2)
));
CcdiFileUploadStatisticsVO result = service.countByStatus(PROJECT_ID);
assertEquals(1L, result.getUploading());
assertEquals(2L, result.getDeleted());
assertEquals(3L, result.getTotal());
}
```
**Step 2: Run test to verify it fails**
Run:
```bash
mvn test -pl ccdi-project -am -Dtest=CcdiFileUploadServiceImplTest#countByStatus_shouldIncludeDeletedCount
```
Expected:
- `FAIL`
- 原因是 `CcdiFileUploadStatisticsVO` 还没有 `deleted` 字段,或 `countByStatus` 还没有映射该状态
**Step 3: Write minimal implementation**
在 VO 和 Service 中补 `deleted` 字段及映射:
```java
private Long deleted;
```
```java
vo.setDeleted(0L);
case "deleted" -> vo.setDeleted(count);
```
**Step 4: Run test to verify it passes**
Run:
```bash
mvn test -pl ccdi-project -am -Dtest=CcdiFileUploadServiceImplTest#countByStatus_shouldIncludeDeletedCount
```
Expected:
- `PASS`
**Step 5: Commit**
```bash
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiFileUploadStatisticsVO.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java
git commit -m "feat: 扩展上传文件统计支持已删除状态"
```
### Task 5: 跑后端回归验证
**Files:**
- Verify only: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadController.java`
- Verify only: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java`
- Verify only: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadControllerTest.java`
- Verify only: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java`
**Step 1: Run focused tests**
Run:
```bash
mvn test -pl ccdi-project -am -Dtest=CcdiFileUploadControllerTest,CcdiFileUploadServiceImplTest
```
Expected:
- 全部 `PASS`
**Step 2: Run module compile**
Run:
```bash
mvn clean compile -pl ccdi-project -am
```
Expected:
- `BUILD SUCCESS`
**Step 3: Record manual API smoke checklist**
手工检查以下接口行为:
- `DELETE /ccdi/file-upload/{id}` 删除 `parsed_success` 记录返回成功
- 删除 `parsed_failed` 记录返回错误
- 重复删除 `deleted` 记录返回错误
**Step 4: Commit**
```bash
git add .
git commit -m "test: 完成上传文件删除后端回归验证"
```

View File

@@ -0,0 +1,337 @@
# 项目上传文件列表删除功能设计
## 背景
当前项目详情页“上传数据”中的“上传文件列表”仅展示文件信息和状态,不支持针对单条文件记录执行业务操作。
现有系统已经具备以下基础能力:
- `ccdi_file_upload_record` 已保存每条上传记录的 `id``lsfxProjectId``logId``fileStatus``errorMessage`
- `ccdi_bank_statement` 已按 `projectId + batchId(logId)` 关联每次上传入库的流水数据
- 流水分析平台客户端 `LsfxAnalysisClient` 已封装删除文件接口 `deleteFiles`
当前缺失的是:
- 解析失败文件缺少“查看错误原因”入口
- 解析成功文件缺少“删除”入口
- 删除后缺少保留历史记录并展示“已删除”状态的机制
## 目标
- 在项目详情-上传数据-上传文件列表中新增“操作”列
- 当文件状态为 `parsed_failed` 时,支持查看错误原因
- 当文件状态为 `parsed_success` 时,支持删除文件
- 删除时增加二次确认,明确会同步删除平台文件与本地流水
- 删除成功后调用流水分析平台删除接口
- 删除成功后删除本地银行流水表中对应文件的全部流水
- 删除成功后保留上传记录,并将状态更新为 `deleted`
- 删除后的列表状态显示为“已删除”
## 非目标
- 不删除 `ccdi_file_upload_record` 历史记录
- 不新增独立删除标记字段,如 `deleted_flag``deleted_time`
- 不调整现有批量上传、轮询解析、本行信息拉取等主流程
- 不改造流水明细查询页面的筛选交互
## 现状分析
### 前端现状
上传文件列表页面位于:
- `ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue`
当前列表字段包括:
- 文件名
- 文件大小
- 状态
- 主体名称
- 主体账号
- 上传时间
- 上传人
当前前端已经具备:
- 查询列表:`getFileUploadList`
- 查询统计:`getFileUploadStatistics`
- 查看列表中的 `fileStatus`
- 弹窗提示能力 `this.$alert`
- 二次确认能力 `this.$confirm`
当前前端未具备:
- 操作列渲染
- 按记录 ID 删除文件的 API
- `deleted` 状态映射
### 后端现状
后端上传记录相关接口位于:
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadController.java`
当前已提供:
- `GET /ccdi/file-upload/list`
- `GET /ccdi/file-upload/statistics/{projectId}`
- `GET /ccdi/file-upload/detail/{id}`
服务层位于:
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java`
当前已具备:
- 按状态统计上传记录
- 根据 `logId` 拉取并入库银行流水
- 失败时记录 `errorMessage`
- 通过 `CcdiBankStatementMapper.deleteByProjectIdAndBatchId(projectId, logId)` 清理本地流水
流水分析平台客户端位于:
- `ccdi-lsfx/src/main/java/com/ruoyi/lsfx/client/LsfxAnalysisClient.java`
当前已具备:
- `deleteFiles(DeleteFilesRequest)` 删除平台文件
### 状态现状
当前 `file_status` 已存在以下状态:
- `uploading`
- `parsing`
- `parsed_success`
- `parsed_failed`
当前缺少删除后的业务状态,因此无法在保留记录的前提下表达“已删除”。
## 方案对比
### 方案一:基于现有状态字段增加 `deleted`
-`file_status` 中新增 `deleted`
- 删除成功后仅更新记录状态,不删除记录
- 前后端统一基于状态判断操作按钮与文案
优点:
- 最符合“保留并显示为已删除”的需求
- 复用现有查询、展示、统计结构
- 改动范围集中,数据库结构无需新增字段
缺点:
- 需要同步扩展状态映射和统计逻辑
### 方案二:新增独立删除标记字段
- 保留原有 `file_status`
- 额外增加 `deletedFlag``deletedTime`
优点:
- 删除状态与解析状态语义分离
缺点:
- 查询、展示、统计逻辑更复杂
- 需要新增数据库字段和 Mapper 映射
- 当前需求下属于过度设计
### 方案三:物理删除上传记录
优点:
- 实现最直接
缺点:
- 与“继续保留并显示为已删除”冲突
- 无法追溯文件历史和删除操作结果
## 最终方案
采用方案一:在 `ccdi_file_upload_record.file_status` 中新增 `deleted` 状态,基于现有上传记录做软删除保留。
## 详细设计
### 状态模型
上传记录状态统一定义为:
- `uploading`:上传中
- `parsing`:解析中
- `parsed_success`:解析成功
- `parsed_failed`:解析失败
- `deleted`:已删除
状态驱动前端操作规则:
- `parsed_failed`:显示“查看错误原因”
- `parsed_success`:显示“删除”
- `deleted`:不显示按钮
- `uploading``parsing`:不显示按钮
### 删除链路
删除流程按以下顺序执行:
1. 前端点击“删除”
2. 前端展示二次确认弹窗
3. 用户确认后调用“按上传记录 ID 删除文件”接口
4. 后端校验记录存在、归属当前项目、状态为 `parsed_success`、且包含 `lsfxProjectId``logId`
5. 后端调用流水分析平台 `deleteFiles`
6. 平台删除成功后,后端删除本地 `ccdi_bank_statement``projectId + batchId(logId)` 的全部流水
7. 本地流水删除成功后,后端将 `ccdi_file_upload_record.file_status` 更新为 `deleted`
8. 前端收到成功响应后刷新列表与统计
### 一致性原则
删除必须遵守“先平台、后本地、再改状态”:
- 平台删除失败:不删本地流水,不更新记录状态
- 本地流水删除失败:不更新记录状态为 `deleted`
- 状态更新失败:整体按失败返回,避免界面和数据不一致
### 后端接口设计
新增接口建议:
- `DELETE /ccdi/file-upload/{id}`
接口语义:
- 按上传记录 ID 删除对应文件
- 仅允许删除 `parsed_success` 状态记录
返回规则:
- 成功:`AjaxResult.success("删除成功")`
- 失败:`AjaxResult.error("具体失败原因")`
### 后端服务设计
服务层新增删除方法,职责包括:
- 查询上传记录
- 校验项目归属与状态
- 组装 `DeleteFilesRequest`
- 调用 `LsfxAnalysisClient.deleteFiles`
- 删除本地银行流水
- 更新上传记录状态为 `deleted`
建议保持数据库层面不新增表结构,仅扩展状态值和相关逻辑。
### 前端交互设计
上传文件列表新增“操作”列。
操作规则:
- `parsed_failed`:点击“查看错误原因”,弹出错误信息弹窗
- `parsed_success`:点击“删除”,弹出确认框
- `deleted`:显示状态“已删除”,无操作按钮
删除确认文案建议:
`删除后将同步删除流水分析平台中的文件,并清除本系统中该文件对应的所有银行流水数据,是否继续?`
删除成功后:
- 提示“删除成功”
- 刷新列表
- 刷新统计
### 统计设计
当前统计 VO 仅统计:
- `uploading`
- `parsing`
- `parsedSuccess`
- `parsedFailed`
- `total`
本次建议扩展:
- `deleted`
这样前端统计口径可以完整覆盖全部状态,也便于后续展示已删除数量。
如果短期内页面不展示该数字,也建议后端先统一支持,避免状态被统计遗漏。
## 影响范围
### 后端
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadController.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiFileUploadService.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiFileUploadStatisticsVO.java`
- `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiFileUploadRecordMapper.xml`
### 前端
- `ruoyi-ui/src/api/ccdiProjectUpload.js`
- `ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue`
## 风险与边界
需要重点拦截以下删除场景:
- 记录不存在
- 记录不属于当前项目
- 记录状态不是 `parsed_success`
- 记录已经是 `deleted`
- 记录缺少 `lsfxProjectId`
- 记录缺少 `logId`
需要注意以下业务影响:
- 软删除后,流水明细查询将不再能查到该文件对应流水
- 上传记录会继续保留,便于审计和追溯
- 如果平台删除成功但本地状态更新失败,需要明确作为失败返回并记录日志
## 测试方案
### 后端单元测试
- 删除成功时:平台删除成功、本地流水删除成功、记录状态更新为 `deleted`
- 平台删除失败时:本地流水不删除、记录状态不变
- 本地流水删除失败时:记录状态不变
-`parsed_success` 状态删除时返回失败
- `deleted` 状态重复删除时返回失败
- 缺少 `logId``lsfxProjectId` 时返回失败
### 控制器测试
- 删除接口成功返回正确消息
- Service 抛出异常时返回错误消息
### 前端测试
- `parsed_failed` 状态显示“查看错误原因”
- `parsed_success` 状态显示“删除”
- `deleted` 状态显示“已删除”且不显示操作按钮
- 删除前弹出二次确认
- 删除成功后刷新列表和统计
- 错误原因弹窗能正确显示 `errorMessage`
## 验收标准
- 上传文件列表新增“操作”列
- 解析失败文件可以查看错误原因
- 解析成功文件可以二次确认后删除
- 删除成功后已调用流水分析平台删除接口
- 删除成功后本地银行流水数据被清理
- 删除成功后上传记录仍保留,状态显示为“已删除”
- 已删除记录不再出现删除按钮
---
**设计日期:** 2026-03-16
**设计人员:** Codex
**审核状态:** 已确认

View File

@@ -0,0 +1,362 @@
# Project Upload File Delete Frontend Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 在项目详情-上传数据-上传文件列表中新增操作列,实现“查看错误原因”和“删除”交互,并让删除后的记录显示为“已删除”。
**Architecture:** 先把状态到“操作/标签”的判断抽取到一个轻量 helper通过 Node 可执行脚本做最小自动化校验;然后在 `ccdiProjectUpload.js` 新增删除接口,在 `UploadData.vue` 中接入操作列、删除确认框、删除后刷新列表与统计。保留现有轮询机制,不新增前端全局状态管理。
**Tech Stack:** Vue 2, Element UI, Axios request wrapper, Node script verification, npm build
---
### Task 1: 抽取状态与操作规则并先写失败测试
**Files:**
- Create: `ruoyi-ui/src/views/ccdiProject/components/detail/uploadFileActionRules.mjs`
- Create: `tests/frontend/upload-file-action-rules.test.mjs`
**Step 1: Write the failing test**
新建一个轻量 Node 规则测试脚本,先定义期望行为:
```javascript
import assert from "node:assert/strict";
import {
getUploadFileAction,
getUploadFileStatusText,
getUploadFileStatusType
} from "../../ruoyi-ui/src/views/ccdiProject/components/detail/uploadFileActionRules.mjs";
assert.deepEqual(getUploadFileAction("parsed_failed"), { key: "viewError", text: "查看错误原因" });
assert.deepEqual(getUploadFileAction("parsed_success"), { key: "delete", text: "删除" });
assert.equal(getUploadFileAction("deleted"), null);
assert.equal(getUploadFileStatusText("deleted"), "已删除");
assert.equal(getUploadFileStatusType("deleted"), "info");
```
**Step 2: Run test to verify it fails**
Run:
```bash
node tests/frontend/upload-file-action-rules.test.mjs
```
Expected:
- `FAIL`
- 原因是 helper 文件还不存在
**Step 3: Write minimal implementation**
创建 helper 文件:
```javascript
export function getUploadFileAction(status) {
const actionMap = {
parsed_failed: { key: "viewError", text: "查看错误原因" },
parsed_success: { key: "delete", text: "删除" }
};
return actionMap[status] || null;
}
export function getUploadFileStatusText(status) {
const map = {
uploading: "上传中",
parsing: "解析中",
parsed_success: "解析成功",
parsed_failed: "解析失败",
deleted: "已删除"
};
return map[status] || status;
}
export function getUploadFileStatusType(status) {
const map = {
uploading: "primary",
parsing: "warning",
parsed_success: "success",
parsed_failed: "danger",
deleted: "info"
};
return map[status] || "info";
}
```
**Step 4: Run test to verify it passes**
Run:
```bash
node tests/frontend/upload-file-action-rules.test.mjs
```
Expected:
- `PASS`
**Step 5: Commit**
```bash
git add ruoyi-ui/src/views/ccdiProject/components/detail/uploadFileActionRules.mjs tests/frontend/upload-file-action-rules.test.mjs
git commit -m "test: 补充上传文件操作规则测试"
```
### Task 2: 新增删除 API 契约
**Files:**
- Modify: `ruoyi-ui/src/api/ccdiProjectUpload.js`
**Step 1: Write the failing usage**
先在计划中约定组件调用的新 API 形式,后续组件实现前必须存在:
```javascript
export function deleteFileUploadRecord(id) {
return request({
url: `/ccdi/file-upload/${id}`,
method: "delete"
});
}
```
**Step 2: Run a quick import smoke check**
Run:
```bash
node -e "const fs=require('fs'); const text=fs.readFileSync('ruoyi-ui/src/api/ccdiProjectUpload.js','utf8'); if(!text.includes('deleteFileUploadRecord')) process.exit(1);"
```
Expected:
- `FAIL`
**Step 3: Write minimal implementation**
把旧的按 `projectId + uploadType` 删除接口保留不动,新加按记录 ID 删除的方法:
```javascript
export function deleteFileUploadRecord(id) {
return request({
url: `/ccdi/file-upload/${id}`,
method: "delete"
});
}
```
**Step 4: Run smoke check to verify it passes**
Run:
```bash
node -e "const fs=require('fs'); const text=fs.readFileSync('ruoyi-ui/src/api/ccdiProjectUpload.js','utf8'); if(!text.includes('deleteFileUploadRecord')) process.exit(1);"
```
Expected:
- 退出码 `0`
**Step 5: Commit**
```bash
git add ruoyi-ui/src/api/ccdiProjectUpload.js
git commit -m "feat: 新增上传文件按记录删除接口"
```
### Task 3: 在列表中渲染操作列和错误原因弹窗
**Files:**
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue`
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/uploadFileActionRules.mjs`
- Test: `tests/frontend/upload-file-action-rules.test.mjs`
**Step 1: Write the failing test**
先扩充规则测试,确保无操作状态不会返回按钮:
```javascript
assert.equal(getUploadFileAction("uploading"), null);
assert.equal(getUploadFileAction("parsing"), null);
```
**Step 2: Run test to verify it fails**
Run:
```bash
node tests/frontend/upload-file-action-rules.test.mjs
```
Expected:
- 如果 helper 暂未覆盖全部状态,则 `FAIL`
**Step 3: Write minimal implementation**
`UploadData.vue` 中:
- 引入 helper
- 给表格新增“操作”列
- 根据 `getUploadFileAction(scope.row.fileStatus)` 渲染按钮
- 增加 `handleViewError(row)`,直接读取 `row.errorMessage || "未知错误"` 并调用:
```javascript
this.$alert(row.errorMessage || "未知错误", "错误信息", {
confirmButtonText: "确定",
type: "error"
});
```
表格模板示例:
```vue
<el-table-column label="操作" width="160" fixed="right">
<template slot-scope="scope">
<el-button
v-if="getRowAction(scope.row)"
type="text"
@click="handleRowAction(scope.row)"
>
{{ getRowAction(scope.row).text }}
</el-button>
</template>
</el-table-column>
```
**Step 4: Run test to verify it passes**
Run:
```bash
node tests/frontend/upload-file-action-rules.test.mjs
```
Expected:
- `PASS`
**Step 5: Commit**
```bash
git add ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue ruoyi-ui/src/views/ccdiProject/components/detail/uploadFileActionRules.mjs tests/frontend/upload-file-action-rules.test.mjs
git commit -m "feat: 新增上传文件列表操作列与错误原因查看"
```
### Task 4: 接入删除确认与刷新逻辑
**Files:**
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue`
- Modify: `ruoyi-ui/src/api/ccdiProjectUpload.js`
**Step 1: Write the failing manual checklist**
先记录需要被满足的行为:
- `parsed_success` 行点击“删除”必须弹出二次确认
- 确认后调用 `deleteFileUploadRecord(row.id)`
- 成功后提示“删除成功”
- 成功后刷新 `loadStatistics()``loadFileList()`
- 若仍存在 `uploading/parsing` 记录,则继续轮询;否则不重复启动轮询
**Step 2: Implement the minimal component code**
`UploadData.vue` 新增删除逻辑:
```javascript
async handleDeleteFile(row) {
await this.$confirm(
"删除后将同步删除流水分析平台中的文件,并清除本系统中该文件对应的所有银行流水数据,是否继续?",
"提示",
{
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
}
);
await deleteFileUploadRecord(row.id);
this.$message.success("删除成功");
await Promise.all([this.loadStatistics(), this.loadFileList()]);
if (this.statistics.uploading > 0 || this.statistics.parsing > 0) {
this.startPolling();
}
}
```
并在统一入口 `handleRowAction(row)` 中根据 action key 分流到:
- `viewError`
- `delete`
**Step 3: Run build to verify no syntax errors**
Run:
```bash
cd ruoyi-ui
npm run build:prod
```
Expected:
- `BUILD SUCCESS` 或等价的前端打包成功输出
**Step 4: Commit**
```bash
git add ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue ruoyi-ui/src/api/ccdiProjectUpload.js
git commit -m "feat: 接入上传文件删除确认与刷新逻辑"
```
### Task 5: 完成前端手工回归验证
**Files:**
- Verify only: `ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue`
- Verify only: `ruoyi-ui/src/api/ccdiProjectUpload.js`
- Verify only: `tests/frontend/upload-file-action-rules.test.mjs`
**Step 1: Run rules test**
Run:
```bash
node tests/frontend/upload-file-action-rules.test.mjs
```
Expected:
- `PASS`
**Step 2: Run production build**
Run:
```bash
cd ruoyi-ui
npm run build:prod
```
Expected:
- 打包成功
**Step 3: Perform manual UI verification**
手工验证清单:
- `parsed_failed` 行显示“查看错误原因”
- 点击后能弹出错误信息
- `parsed_success` 行显示“删除”
- 点击删除会出现确认框
- 删除成功后状态显示为“已删除”
- `deleted` 行不再显示任何操作按钮
**Step 4: Commit**
```bash
git add .
git commit -m "test: 完成上传文件删除前端回归验证"
```

View File

@@ -0,0 +1,108 @@
# 模型默认参数后端对齐验证记录
## 验证时间
- 2026-03-16
## 静态脚本比对
- 对比文件:
- `sql/ccdi_model_param.sql`
- `sql/2026-03-16-update-ccdi-model-param-defaults.sql`
- 校验结果:
- 系统默认模型数5
- 系统默认参数数16
- `model_code + param_code` 唯一组合数16
- `param_value` 均为原始字符串,不含千分位逗号
## 后端代码验证
- 单测命令:
- `mvn -pl ccdi-project -Dtest=CcdiModelParamServiceImplTest test`
- 结果:
- `BUILD SUCCESS`
- 覆盖 `default` 项目读取系统默认参数
- 覆盖默认项目首次保存时复制整套系统默认参数并切换为 `custom`
- 编译命令:
- `mvn -pl ccdi-project -am -DskipTests compile`
- 结果:
- `BUILD SUCCESS`
## 联调环境检查
- 使用配置:
- `ruoyi-admin/src/main/resources/application-local.yml`
- 数据库检查命令:
- `SELECT COUNT(*) AS total_rows, COUNT(DISTINCT model_code) AS model_count FROM ccdi_model_param WHERE project_id = 0;`
- 检查结果:
- 初始状态下,`project_id = 0` 默认参数记录数为 `0`
- 已执行 `sql/2026-03-16-update-ccdi-model-param-defaults.sql`
- 升级后,`project_id = 0` 默认参数记录数为 `16`
- 升级后默认模型数为 `5`
## 接口回归结论
- 已完成:
- 代码层行为通过单测验证
- SQL 初始化脚本与升级脚本产物一致
- `GET /ccdi/modelParam/listAll?projectId=0`
- `GET /ccdi/modelParam/listAll?projectId=<default项目ID>`
- `POST /ccdi/modelParam/saveAll`
## 接口回归结果
- 启动方式:
- 由于 `spring-boot:run` 在本地会读到不可达数据源,最终使用 `ruoyi-admin/target/ruoyi-admin.jar`
- 显式传入数据库、Redis、`ruoyi.profile` 参数启动
- 测试结束后已关闭后端进程
- 登录接口:
- `POST /login/test`
- 结果:成功获取 token
- 查询系统默认参数:
- `GET /ccdi/modelParam/listAll?projectId=0`
- 结果:返回 `5` 个模型、`16` 条参数
- 模型编码:`ABNORMAL_BEHAVIOR``LARGE_TRANSACTION``SUSPICIOUS_FOREIGN_EXCHANGE``SUSPICIOUS_GAMBLING``SUSPICIOUS_PART_TIME`
- 查询默认项目参数:
- 使用临时默认项目 `project_id = 39`
- `GET /ccdi/modelParam/listAll?projectId=39`
- 结果:返回 `5` 个模型、`16` 条参数,与系统默认参数一致
- 验证默认项目首次保存:
- `POST /ccdi/modelParam/saveAll`
- 请求:仅更新 `LARGE_TRANSACTION/SINGLE_TRANSACTION_AMOUNT = 2222`
- 结果:
- 接口返回 `保存成功`
- `ccdi_project.config_type``default` 变为 `custom`
- `ccdi_model_param` 为该项目复制了 `16` 条参数
- 唯一参数组合数为 `16`
- `LARGE_TRANSACTION/SINGLE_TRANSACTION_AMOUNT` 已更新为 `2222`
- 清理:
- 已删除临时测试项目 `project_id = 39` 及其参数数据
## 环境清理
- 已删除临时创建的测试项目数据,不保留额外脏数据
- 已关闭测试时启动的后端进程
## 2026-03-16 前端联调补充复核
- 本次前端联调复用了本地已运行的开发服务:
- 前端:`http://localhost`
- 后端:`http://localhost:62318`
- 实际联调页面:
- `/modelParam`
- `/ccdiProject/detail/36?tab=config`
- `/ccdiProject/detail/32?tab=config`
- 联调观察与接口一致:
- 全局页展示 `5` 个模型、`16` 个参数
- 默认项目 `projectId=36` 读取系统默认参数全集
- 历史 custom 项目 `projectId=32` 返回空模型集合,页面保持空状态,不补齐默认模型
- 联调过程中为验证保存链路曾触发真实保存,验证结束后已恢复现场:
- `projectId=0 / LARGE_TRANSACTION / SINGLE_TRANSACTION_AMOUNT` 已恢复为 `1111`
- `projectId=36` 已恢复为 `configType=default`
- `projectId=36` 的项目级参数副本已删除

View File

@@ -0,0 +1,71 @@
# 模型参数前端动态展示验证记录
## 验证时间
- 2026-03-16
## 验证环境
- 前端地址:`http://localhost`
- 后端地址:`http://localhost:62318`
- 登录账号:`admin/admin123`
- 本次验证复用了本地已在运行的开发服务:
- 前端 `vue-cli-service serve`
- 后端 `RuoYiApplication`
- 本次验证未额外启动新的前后端进程,因此测试结束时没有新增进程需要关闭
## 联调前基线核对
- 全局参数接口:`GET /ccdi/modelParam/listAll?projectId=0`
- 返回 `5` 个模型、`16` 个参数
- 默认项目样本:`projectId=36`
- `GET /ccdi/project/36` 返回 `configType=default`
- `GET /ccdi/modelParam/listAll?projectId=36` 返回 `5` 个模型、`16` 个参数
- 历史 custom 项目样本:`projectId=32`
- `GET /ccdi/project/32` 返回 `configType=custom`
- `GET /ccdi/modelParam/listAll?projectId=32` 返回 `0` 个模型、`0` 个参数
## 全局参数页验证
- 页面路径:`/modelParam`
- 验证结果:
- 页面按接口返回动态渲染出 `5` 张模型卡片
- 模型标题、参数名称、描述、单位均直接展示接口返回值
- 输入框录入 `1111000` 时页面未自动插入千分位逗号
- 修改一个参数后提示 `已修改 1 个参数`
- 点击保存后页面重新拉取接口,修改值保留
- 无修改时再次点击保存,提示 `没有需要保存的修改`
## 项目参数页验证
- 默认项目页面路径:`/ccdiProject/detail/36?tab=config`
- 默认项目验证结果:
- 初始页面标签显示 `默认配置`
- 页面展示完整 `5` 个模型,与系统默认参数全集一致
- 修改一个参数后提示 `已修改 1 个参数`
- 保存并整页刷新后,页面标签切换为 `自定义配置`
- 刷新后修改值仍与接口返回一致
- 历史 custom 项目页面路径:`/ccdiProject/detail/32?tab=config`
- 历史 custom 项目验证结果:
- 页面标签显示 `自定义配置`
- 页面展示空状态 `暂无参数配置数据`
- 未补齐系统默认模型和参数,符合“仅展示自身已有参数”的接口驱动行为
## 数据清理
- 为避免联调污染现有测试数据,验证完成后已执行恢复:
- 全局参数 `projectId=0 / LARGE_TRANSACTION / SINGLE_TRANSACTION_AMOUNT` 恢复为 `1111`
- 删除 `projectId=36` 测试保存产生的项目参数副本
-`projectId=36``configType` 恢复为 `default`
- 清理后复核结果:
- `GET /ccdi/project/36` 返回 `configType=default`
- `GET /ccdi/modelParam/listAll?projectId=36` 再次返回系统默认参数全集
## 结论
- 前端页面已满足以下目标:
- 展示完全由接口返回驱动
- 不再包含千分位格式化设计
- 修改计数、无修改保存提示、保存后回刷行为一致
- 默认项目与历史 custom 项目在页面表现上均与当前后端接口语义一致

View File

@@ -5,7 +5,7 @@ services:
build: .
container_name: lsfx-mock-server
ports:
- "8000:8000"
- "62320:8000"
environment:
- APP_NAME=流水分析Mock服务
- APP_VERSION=1.0.0

View File

@@ -1,4 +1,6 @@
from fastapi import APIRouter, BackgroundTasks, UploadFile, File, Form, Query
import json
from fastapi import APIRouter, BackgroundTasks, UploadFile, File, Form, HTTPException, Query
from services.token_service import TokenService
from services.file_service import FileService
from services.statement_service import StatementService
@@ -14,6 +16,32 @@ file_service = FileService()
statement_service = StatementService()
def _parse_log_ids(log_ids: str) -> List[int]:
"""兼容逗号分隔和 JSON 数组两种 logIds 传参格式。"""
raw_value = log_ids.strip()
if not raw_value:
raise HTTPException(status_code=422, detail="logIds 不能为空")
try:
if raw_value.startswith("["):
parsed = json.loads(raw_value)
if not isinstance(parsed, list):
raise ValueError
values = parsed
else:
values = [item.strip() for item in raw_value.split(",") if item.strip()]
if not values:
raise ValueError
return [int(item) for item in values]
except (TypeError, ValueError, json.JSONDecodeError) as exc:
raise HTTPException(
status_code=422,
detail="logIds 必须是逗号分隔的数字字符串或 JSON 数组"
) from exc
# ==================== 接口1获取Token ====================
@router.post("/account/common/getToken")
async def get_token(
@@ -144,15 +172,14 @@ async def get_upload_status(
@router.post("/watson/api/project/batchDeleteUploadFile")
async def delete_files(
groupId: int = Form(..., description="项目id"),
logIds: str = Form(..., description="文件id数组逗号分隔,如: 10001,10002"),
logIds: str = Form(..., description="文件id数组支持 10001,10002 或 [10001,10002]"),
userId: int = Form(..., description="用户柜员号"),
):
"""批量删除上传的文件
根据logIds列表删除对应的文件记录
"""
# 将逗号分隔的字符串转换为整数列表
log_id_list = [int(id.strip()) for id in logIds.split(",")]
log_id_list = _parse_log_ids(logIds)
return file_service.delete_files(groupId, log_id_list, userId)

View File

@@ -174,3 +174,20 @@ def test_field_completeness(client):
for field in required_fields:
assert field in log, f"缺少字段: {field}"
def test_delete_files_accepts_array_style_log_ids(client):
"""测试删除文件接口兼容数组风格的 logIds 入参"""
response = client.post(
"/watson/api/project/batchDeleteUploadFile",
data={
"groupId": 1000,
"logIds": "[50689]",
"userId": 902001,
}
)
assert response.status_code == 200
data = response.json()
assert data["code"] == "200 OK"
assert data["message"] == "delete.files.success"

View File

@@ -111,8 +111,8 @@ spring:
lsfx:
api:
# Mock Server本地测试
# base-url: http://localhost:8000
base-url: http://116.62.17.81:62320
base-url: http://localhost:8000
# base-url: http://116.62.17.81:62320
# 测试环境
# base-url: http://158.234.196.5:82/c4c3
# 生产环境

View File

@@ -154,3 +154,14 @@ export function getFileUploadDetail(id) {
method: 'get'
})
}
/**
* 按记录ID删除上传记录
* @param {Number} id 记录ID
*/
export function deleteFileUploadRecord(id) {
return request({
url: `/ccdi/file-upload/${id}`,
method: 'delete'
})
}

View File

@@ -59,38 +59,53 @@ export default {
name: "ModelParam",
data() {
return {
// 模型参数数据(按模型分组)
modelGroups: [],
// 修改记录使用对象而非Map确保Vue能检测变化
modifiedParams: {},
// 加载状态
originalParamValues: {},
loading: false,
// 保存状态
saving: false
};
},
computed: {
/** 计算已修改参数数量 */
modifiedCount() {
let count = 0;
Object.values(this.modifiedParams).forEach(params => {
count += params.size;
});
return count;
return Object.keys(this.modifiedParams).length;
}
},
created() {
this.loadAllParams();
},
methods: {
/** 加载所有模型参数 */
buildModifiedKey(modelCode, paramCode) {
return `${modelCode}:${paramCode}`;
},
normalizeParamValue(value) {
return value === null || value === undefined ? '' : String(value);
},
normalizeModelGroups(models) {
if (!Array.isArray(models)) {
return [];
}
return models.map(model => ({
...model,
params: Array.isArray(model.params) ? model.params : []
}));
},
resetModifiedState() {
this.modifiedParams = {};
this.originalParamValues = {};
this.modelGroups.forEach(model => {
model.params.forEach(row => {
const key = this.buildModifiedKey(model.modelCode, row.paramCode);
this.$set(this.originalParamValues, key, this.normalizeParamValue(row.paramValue));
});
});
},
async loadAllParams() {
this.loading = true;
try {
const res = await listAllParams({ projectId: 0 });
this.modelGroups = res.data.models || [];
// 清空修改记录
this.modifiedParams = {};
this.modelGroups = this.normalizeModelGroups(res.data && res.data.models);
this.resetModifiedState();
} catch (error) {
this.$message.error('加载参数失败:' + error.message);
console.error('加载参数失败', error);
@@ -99,58 +114,52 @@ export default {
}
},
/** 标记参数为已修改 */
markAsModified(modelCode, row) {
// 使用 $set 确保 Vue 能检测到对象属性的新增
if (!this.modifiedParams[modelCode]) {
this.$set(this.modifiedParams, modelCode, new Set());
}
this.modifiedParams[modelCode].add(row.paramCode);
const modifiedKey = this.buildModifiedKey(modelCode, row.paramCode);
const currentValue = this.normalizeParamValue(row.paramValue);
const originalValue = this.originalParamValues[modifiedKey];
// 强制更新视图
this.$forceUpdate();
if (currentValue === originalValue) {
if (this.modifiedParams[modifiedKey]) {
this.$delete(this.modifiedParams, modifiedKey);
}
return;
}
this.$set(this.modifiedParams, modifiedKey, {
modelCode,
paramCode: row.paramCode,
paramValue: row.paramValue
});
},
/** 保存所有修改 */
async handleSaveAll() {
// 验证是否有修改
if (this.modifiedCount === 0) {
this.$message.info('没有需要保存的修改');
return;
}
// 构造保存数据(只包含修改过的参数)
const modelMap = {};
Object.values(this.modifiedParams).forEach(item => {
if (!modelMap[item.modelCode]) {
modelMap[item.modelCode] = {
modelCode: item.modelCode,
params: []
};
}
modelMap[item.modelCode].params.push({
paramCode: item.paramCode,
paramValue: item.paramValue
});
});
const saveDTO = {
projectId: 0,
models: []
models: Object.values(modelMap)
};
Object.entries(this.modifiedParams).forEach(([modelCode, paramCodes]) => {
const modelGroup = this.modelGroups.find(m => m.modelCode === modelCode);
if (!modelGroup) return;
const modifiedParamList = modelGroup.params
.filter(p => paramCodes.has(p.paramCode))
.map(p => ({
paramCode: p.paramCode,
paramValue: p.paramValue
}));
if (modifiedParamList.length > 0) {
saveDTO.models.push({
modelCode: modelCode,
params: modifiedParamList
});
}
});
// 保存
this.saving = true;
try {
await saveAllParams(saveDTO);
this.$modal.msgSuccess('保存成功');
// 清空修改记录并重新加载
this.modifiedParams = {};
await this.loadAllParams();
} catch (error) {
if (error.response && error.response.data && error.response.data.msg) {
@@ -191,6 +200,7 @@ export default {
.model-cards-container {
margin-bottom: 20px;
min-height: 300px;
padding-bottom: 92px;
}
.model-card {
@@ -220,15 +230,50 @@ export default {
}
.button-section {
padding: 15px;
background: #fff;
border-radius: 4px;
position: sticky;
bottom: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px 16px;
margin-top: 24px;
padding: 16px 20px;
padding-bottom: calc(16px + env(safe-area-inset-bottom, 0px));
background: rgba(255, 255, 255, 0.96);
border-top: 1px solid #ebeef5;
border-radius: 12px 12px 0 0;
box-shadow: 0 -6px 18px rgba(0, 0, 0, 0.08);
text-align: left;
.el-button {
flex-shrink: 0;
}
.modified-tip {
margin-left: 15px;
color: #909399;
font-size: 14px;
line-height: 1.5;
}
}
@media (max-width: 768px) {
.param-config-container {
padding: 16px;
}
.model-cards-container {
padding-bottom: 104px;
}
.button-section {
padding: 14px 16px;
padding-bottom: calc(14px + env(safe-area-inset-bottom, 0px));
.el-button {
width: 100%;
}
}
}
</style>

View File

@@ -64,30 +64,26 @@ export default {
},
data() {
return {
// 模型参数数据(按模型分组)
modelGroups: [],
// 修改记录使用对象而非Map确保Vue能检测变化
modifiedParams: {},
// 加载状态
originalParamValues: {},
loading: false,
// 保存状态
saving: false
}
},
computed: {
/** 计算已修改参数数量 */
modifiedCount() {
let count = 0;
Object.values(this.modifiedParams).forEach(params => {
count += params.size;
});
return count;
return Object.keys(this.modifiedParams).length;
}
},
watch: {
projectId(newVal) {
if (newVal) {
this.loadAllParams();
} else {
this.modelGroups = [];
this.modifiedParams = {};
this.originalParamValues = {};
}
}
},
@@ -97,14 +93,37 @@ export default {
}
},
methods: {
/** 加载所有模型参数 */
buildModifiedKey(modelCode, paramCode) {
return `${modelCode}:${paramCode}`
},
normalizeParamValue(value) {
return value === null || value === undefined ? '' : String(value)
},
normalizeModelGroups(models) {
if (!Array.isArray(models)) {
return []
}
return models.map(model => ({
...model,
params: Array.isArray(model.params) ? model.params : []
}))
},
resetModifiedState() {
this.modifiedParams = {}
this.originalParamValues = {}
this.modelGroups.forEach(model => {
model.params.forEach(row => {
const key = this.buildModifiedKey(model.modelCode, row.paramCode)
this.$set(this.originalParamValues, key, this.normalizeParamValue(row.paramValue))
})
})
},
async loadAllParams() {
this.loading = true;
try {
const res = await listAllParams({ projectId: this.projectId });
this.modelGroups = res.data.models || [];
// 清空修改记录
this.modifiedParams = {};
this.modelGroups = this.normalizeModelGroups(res.data && res.data.models);
this.resetModifiedState();
} catch (error) {
this.$message.error('加载参数失败:' + error.message);
console.error('加载参数失败', error);
@@ -113,58 +132,52 @@ export default {
}
},
/** 标记参数为已修改 */
markAsModified(modelCode, row) {
// 使用 $set 确保 Vue 能检测到对象属性的新增
if (!this.modifiedParams[modelCode]) {
this.$set(this.modifiedParams, modelCode, new Set());
}
this.modifiedParams[modelCode].add(row.paramCode);
const modifiedKey = this.buildModifiedKey(modelCode, row.paramCode);
const currentValue = this.normalizeParamValue(row.paramValue);
const originalValue = this.originalParamValues[modifiedKey];
// 强制更新视图
this.$forceUpdate();
if (currentValue === originalValue) {
if (this.modifiedParams[modifiedKey]) {
this.$delete(this.modifiedParams, modifiedKey);
}
return;
}
this.$set(this.modifiedParams, modifiedKey, {
modelCode,
paramCode: row.paramCode,
paramValue: row.paramValue
});
},
/** 保存所有修改 */
async handleSaveAll() {
// 验证是否有修改
if (this.modifiedCount === 0) {
this.$message.info('没有需要保存的修改');
return;
}
// 构造保存数据(只包含修改过的参数)
const modelMap = {};
Object.values(this.modifiedParams).forEach(item => {
if (!modelMap[item.modelCode]) {
modelMap[item.modelCode] = {
modelCode: item.modelCode,
params: []
};
}
modelMap[item.modelCode].params.push({
paramCode: item.paramCode,
paramValue: item.paramValue
});
});
const saveDTO = {
projectId: this.projectId,
models: []
models: Object.values(modelMap)
};
Object.entries(this.modifiedParams).forEach(([modelCode, paramCodes]) => {
const modelGroup = this.modelGroups.find(m => m.modelCode === modelCode);
if (!modelGroup) return;
const modifiedParamList = modelGroup.params
.filter(p => paramCodes.has(p.paramCode))
.map(p => ({
paramCode: p.paramCode,
paramValue: p.paramValue
}));
if (modifiedParamList.length > 0) {
saveDTO.models.push({
modelCode: modelCode,
params: modifiedParamList
});
}
});
// 保存
this.saving = true;
try {
await saveAllParams(saveDTO);
this.$message.success('保存成功');
// 清空修改记录并重新加载
this.modifiedParams = {};
this.$modal.msgSuccess('保存成功');
await this.loadAllParams();
} catch (error) {
if (error.response && error.response.data && error.response.data.msg) {
@@ -191,6 +204,7 @@ export default {
.model-cards-container {
margin-bottom: 20px;
min-height: 300px;
padding-bottom: 92px;
}
.model-card {
@@ -220,15 +234,50 @@ export default {
}
.button-section {
padding: 15px;
background: #fff;
border-radius: 4px;
position: sticky;
bottom: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px 16px;
margin-top: 24px;
padding: 16px 20px;
padding-bottom: calc(16px + env(safe-area-inset-bottom, 0px));
background: rgba(255, 255, 255, 0.96);
border-top: 1px solid #ebeef5;
border-radius: 12px 12px 0 0;
box-shadow: 0 -6px 18px rgba(0, 0, 0, 0.08);
text-align: left;
.el-button {
flex-shrink: 0;
}
.modified-tip {
margin-left: 15px;
color: #909399;
font-size: 14px;
line-height: 1.5;
}
}
@media (max-width: 768px) {
.param-config-container {
padding: 16px;
}
.model-cards-container {
padding-bottom: 104px;
}
.button-section {
padding: 14px 16px;
padding-bottom: calc(14px + env(safe-area-inset-bottom, 0px));
.el-button {
width: 100%;
}
}
}
</style>

View File

@@ -88,6 +88,17 @@
</template>
</el-table-column>
<el-table-column prop="uploadUser" label="上传人" width="100"></el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template slot-scope="scope">
<el-button
v-if="getRowAction(scope.row)"
type="text"
@click="handleRowAction(scope.row)"
>
{{ getRowAction(scope.row).text }}
</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
@@ -361,8 +372,14 @@ import {
batchUploadFiles,
getFileUploadList,
getFileUploadStatistics,
deleteFileUploadRecord,
} from "@/api/ccdiProjectUpload";
import { parseTime } from "@/utils/ruoyi";
import {
getUploadFileAction,
getUploadFileStatusText,
getUploadFileStatusType,
} from "./uploadFileActionRules";
export default {
name: "UploadData",
@@ -1167,26 +1184,81 @@ export default {
this.loadFileList();
},
getRowAction(row) {
return getUploadFileAction(row.fileStatus);
},
handleRowAction(row) {
const action = this.getRowAction(row);
if (!action) {
return;
}
if (action.key === "viewError") {
this.handleViewError(row);
return;
}
if (action.key === "delete") {
this.handleDeleteFile(row);
}
},
handleViewError(row) {
this.$alert(row.errorMessage || "未知错误", "错误信息", {
confirmButtonText: "确定",
type: "error",
});
},
async handleDeleteFile(row) {
try {
await this.$confirm(
"删除后将同步删除流水分析平台中的文件,并清除本系统中该文件对应的所有银行流水数据,是否继续?",
"提示",
{
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}
);
await deleteFileUploadRecord(row.id);
this.$message.success("删除成功");
await Promise.all([this.loadStatistics(), this.loadFileList()]);
if (this.hasActivePollingRecords()) {
this.startPolling();
} else {
this.stopPolling();
}
} catch (error) {
if (error === "cancel" || error === "close") {
return;
}
this.$message.error("删除失败:" + ((error && error.message) || "未知错误"));
}
},
hasActivePollingRecords() {
return (
this.statistics.uploading > 0 ||
this.statistics.parsing > 0 ||
this.fileUploadList.some((item) =>
["uploading", "parsing"].includes(item.fileStatus)
)
);
},
/** 状态文本映射 */
getStatusText(status) {
const map = {
uploading: "上传中",
parsing: "解析中",
parsed_success: "解析成功",
parsed_failed: "解析失败",
};
return map[status] || status;
return getUploadFileStatusText(status);
},
/** 状态标签类型映射 */
getStatusType(status) {
const map = {
uploading: "primary",
parsing: "warning",
parsed_success: "success",
parsed_failed: "danger",
};
return map[status] || "info";
return getUploadFileStatusType(status);
},
/** 格式化文件大小 */

View File

@@ -0,0 +1,32 @@
export function getUploadFileAction(status) {
const actionMap = {
parsed_failed: { key: "viewError", text: "查看错误原因" },
parsed_success: { key: "delete", text: "删除" }
};
return actionMap[status] || null;
}
export function getUploadFileStatusText(status) {
const map = {
uploading: "上传中",
parsing: "解析中",
parsed_success: "解析成功",
parsed_failed: "解析失败",
deleted: "已删除"
};
return map[status] || status;
}
export function getUploadFileStatusType(status) {
const map = {
uploading: "primary",
parsing: "warning",
parsed_success: "success",
parsed_failed: "danger",
deleted: "info"
};
return map[status] || "info";
}

View File

@@ -0,0 +1,81 @@
CREATE TABLE IF NOT EXISTS `ccdi_bank_tag_rule` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`model_code` VARCHAR(64) NOT NULL COMMENT '模型编码',
`model_name` VARCHAR(128) NOT NULL COMMENT '模型名称',
`rule_code` VARCHAR(64) NOT NULL COMMENT '规则编码',
`rule_name` VARCHAR(128) NOT NULL COMMENT '规则名称',
`indicator_code` VARCHAR(64) DEFAULT NULL COMMENT '指标编码',
`result_type` VARCHAR(32) NOT NULL COMMENT '结果类型',
`risk_level` VARCHAR(32) DEFAULT NULL COMMENT '风险等级',
`business_caliber` VARCHAR(1000) DEFAULT NULL COMMENT '业务口径',
`enabled` TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用',
`sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序',
`create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建者',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_by` VARCHAR(64) DEFAULT NULL COMMENT '更新者',
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_ccdi_bank_tag_rule_code` (`rule_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='流水标签规则定义表';
CREATE TABLE IF NOT EXISTS `ccdi_bank_statement_tag_result` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`project_id` BIGINT NOT NULL COMMENT '项目ID',
`model_code` VARCHAR(64) NOT NULL COMMENT '模型编码',
`model_name` VARCHAR(128) NOT NULL COMMENT '模型名称',
`rule_code` VARCHAR(64) NOT NULL COMMENT '规则编码',
`rule_name` VARCHAR(128) NOT NULL COMMENT '规则名称',
`indicator_code` VARCHAR(64) DEFAULT NULL COMMENT '指标编码',
`result_type` VARCHAR(32) NOT NULL COMMENT '结果类型',
`risk_level` VARCHAR(32) DEFAULT NULL COMMENT '风险等级',
`bank_statement_id` BIGINT DEFAULT NULL COMMENT '流水ID',
`object_type` VARCHAR(64) DEFAULT NULL COMMENT '对象类型',
`object_key` VARCHAR(128) DEFAULT NULL COMMENT '对象主键',
`group_id` INT DEFAULT NULL COMMENT '项目分组ID',
`log_id` INT DEFAULT NULL COMMENT '批次日志ID',
`reason_detail` VARCHAR(2000) DEFAULT NULL COMMENT '异常原因快照',
`business_caliber_snapshot` VARCHAR(2000) DEFAULT NULL COMMENT '业务口径快照',
`hit_value_snapshot` VARCHAR(1000) DEFAULT NULL COMMENT '命中值快照',
`create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建者',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_by` VARCHAR(64) DEFAULT NULL COMMENT '更新者',
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_ccdi_bank_tag_statement_hit` (`project_id`, `rule_code`, `bank_statement_id`),
UNIQUE KEY `uk_ccdi_bank_tag_object_hit` (`project_id`, `rule_code`, `object_type`, `object_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='流水标签结果表';
CREATE TABLE IF NOT EXISTS `ccdi_bank_tag_task` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`project_id` BIGINT NOT NULL COMMENT '项目ID',
`trigger_type` VARCHAR(64) NOT NULL COMMENT '触发方式',
`model_code` VARCHAR(64) DEFAULT NULL COMMENT '模型编码',
`status` VARCHAR(32) NOT NULL COMMENT '任务状态',
`need_rerun` TINYINT NOT NULL DEFAULT 0 COMMENT '是否需要补跑',
`success_rule_count` INT NOT NULL DEFAULT 0 COMMENT '成功规则数',
`failed_rule_count` INT NOT NULL DEFAULT 0 COMMENT '失败规则数',
`hit_count` INT NOT NULL DEFAULT 0 COMMENT '命中数量',
`error_message` VARCHAR(2000) DEFAULT NULL COMMENT '错误信息',
`start_time` DATETIME DEFAULT NULL COMMENT '开始时间',
`end_time` DATETIME DEFAULT NULL COMMENT '结束时间',
`create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建者',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_by` VARCHAR(64) DEFAULT NULL COMMENT '更新者',
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_ccdi_bank_tag_task_project_status` (`project_id`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='流水标签任务表';
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
('LARGE_TRANSACTION', '大额交易', 'HOUSE_OR_CAR_EXPENSE', '房车消费支出交易', 'HOUSE_OR_CAR_EXPENSE', 'STATEMENT', 'HIGH', '识别房产、购车等大额消费支出流水', 1, 10, 'system', '初始化规则'),
('LARGE_TRANSACTION', '大额交易', 'TAX_EXPENSE', '税务支出交易', 'TAX_EXPENSE', 'STATEMENT', 'HIGH', '识别税务类大额支出流水', 1, 20, 'system', '初始化规则'),
('LARGE_TRANSACTION', '大额交易', 'SINGLE_LARGE_INCOME', '大额单笔收入', 'SINGLE_TRANSACTION_AMOUNT', 'STATEMENT', 'HIGH', '识别超过单笔阈值的收入流水', 1, 30, 'system', '初始化规则'),
('LARGE_TRANSACTION', '大额交易', 'CUMULATIVE_INCOME', '累计收入超限', 'CUMULATIVE_TRANSACTION_AMOUNT', 'OBJECT', 'HIGH', '识别累计收入超过阈值的对象', 1, 40, 'system', '初始化规则'),
('LARGE_TRANSACTION', '大额交易', 'ANNUAL_TURNOVER', '年流水交易额超限', 'annual_turnover', 'OBJECT', 'HIGH', '识别年交易额超过阈值的对象', 1, 50, 'system', '初始化规则'),
('LARGE_TRANSACTION', '大额交易', 'LARGE_CASH_DEPOSIT', '大额存现交易', 'LARGE_CASH_DEPOSIT', 'STATEMENT', 'HIGH', '识别大额现金存入流水', 1, 60, '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', '初始化规则');

View File

@@ -0,0 +1,36 @@
START TRANSACTION;
DELETE FROM ccdi_model_param
WHERE project_id = 0;
INSERT INTO ccdi_model_param (
project_id,
model_code,
model_name,
param_code,
param_name,
param_desc,
param_value,
param_unit,
sort_order,
create_by,
remark
) VALUES
(0, 'LARGE_TRANSACTION', '大额交易模型', 'SINGLE_TRANSACTION_AMOUNT', '单笔大额收入金额', '单笔收入超过该金额', '1111', '', 1, 'admin', '系统默认参数'),
(0, 'LARGE_TRANSACTION', '大额交易模型', 'CUMULATIVE_TRANSACTION_AMOUNT', '累计大额收入金额', '年累计收入超过该金额', '50000001', '', 2, 'admin', '系统默认参数'),
(0, 'LARGE_TRANSACTION', '大额交易模型', 'annual_turnover', '年累计交易额', '年累计交易额超过该金额', '50000001', '', 3, 'admin', '系统默认参数'),
(0, 'LARGE_TRANSACTION', '大额交易模型', 'LARGE_CASH_DEPOSIT', '单笔大额存现金额', '单笔存现金额超过', '2000001', '', 4, 'admin', '系统默认参数'),
(0, 'LARGE_TRANSACTION', '大额交易模型', 'FREQUENT_CASH_DEPOSIT', '单日多次存现次数', '24小时内累计存现超过', '5', '', 5, 'admin', '系统默认参数'),
(0, 'LARGE_TRANSACTION', '大额交易模型', 'FREQUENT_TRANSFER', '单笔大额转账金额', '单日转账次数超过', '100001', '次/日', 6, 'admin', '系统默认参数'),
(0, 'SUSPICIOUS_PART_TIME', '可疑兼职模型', 'MONTHLY_FIXED_INCOME', '月度非本行工资收入金额', '除本行工资外,每月固定收入超过', '5000', '元/月', 1, 'admin', '系统默认参数'),
(0, 'SUSPICIOUS_PART_TIME', '可疑兼职模型', 'FIXED_COUNTERPARTY_TRANSFER', '季度稳定收入金额', '每季从固定交易对手转入金额', '15000', '元/季', 2, 'admin', '系统默认参数'),
(0, 'SUSPICIOUS_FOREIGN_EXCHANGE', '可疑外汇交易模型', 'SINGLE_PURCHASE_AMOUNT', '单笔购汇金额', '单笔购汇超过该金额', '50000', '美元/笔', 1, 'admin', '系统默认参数'),
(0, 'SUSPICIOUS_FOREIGN_EXCHANGE', '可疑外汇交易模型', 'SINGLE_SETTLEMENT_AMOUNT', '单笔结汇金额', '单笔结汇超过该金额', '50000', '美元/笔', 2, 'admin', '系统默认参数'),
(0, 'SUSPICIOUS_FOREIGN_EXCHANGE', '可疑外汇交易模型', 'CROSS_BORDER_REMITTANCE', '跨境汇款金额', '跨境汇款金额超过', '200000', '美元/笔', 3, 'admin', '系统默认参数'),
(0, 'ABNORMAL_BEHAVIOR', '异常行为模型', 'stock_tfr_large', '银证转账大额金额', '', '1000000', '', 1, 'admin', '系统默认参数'),
(0, 'ABNORMAL_BEHAVIOR', '异常行为模型', 'withdraw_cnt', '微信、支付宝单日提现次数', '', '3', '次/日', 2, 'admin', '系统默认参数'),
(0, 'ABNORMAL_BEHAVIOR', '异常行为模型', 'withdraw_amt', '微信、支付宝单日提现金额', '', '50000', '元/日', 3, 'admin', '系统默认参数'),
(0, 'SUSPICIOUS_GAMBLING', '疑似赌博交易模型', 'multi_party_amt_min', '疑似赌博金额下限', '', '500', '', 1, 'admin', '系统默认参数'),
(0, 'SUSPICIOUS_GAMBLING', '疑似赌博交易模型', 'multi_party_amt_max', '疑似赌博金额上限', '', '5000', '', 2, 'admin', '系统默认参数');
COMMIT;

View File

@@ -25,31 +25,22 @@ CREATE TABLE `ccdi_model_param` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='模型参数配置表';
-- ----------------------------
-- 2. 初始化大额交易模型参数
-- ----------------------------
INSERT INTO ccdi_model_param (project_id, model_code, model_name, param_code, param_name, param_desc, param_value, param_unit, sort_order, create_by, remark) VALUES
(0, 'LARGE_TRANSACTION', '大额交易模型', 'SINGLE_TRANSACTION_AMOUNT', '单笔交易额', '单笔超过该金额视为大额交易', '50000', '', 1, 'admin', '系统默认参数'),
(0, 'LARGE_TRANSACTION', '大额交易模型', 'CUMULATIVE_TRANSACTION_AMOUNT', '累计交易额', '年累计交易额超过该金额', '5000000', '', 2, 'admin', '系统默认参数'),
(0, 'LARGE_TRANSACTION', '大额交易模型', 'LARGE_CASH_DEPOSIT', '大额存现', '单笔存现金额超过', '200000', '', 3, 'admin', '系统默认参数'),
(0, 'LARGE_TRANSACTION', '大额交易模型', 'FREQUENT_CASH_DEPOSIT', '短时多次存现', '24小时内累计存现超过', '100000', '元/4小时', 4, 'admin', '系统默认参数'),
(0, 'LARGE_TRANSACTION', '大额交易模型', 'FREQUENT_TRANSFER', '频繁转账', '单日转账次数超过', '10', '次/日', 5, 'admin', '系统默认参数'),
(0, 'LARGE_TRANSACTION', '大额交易模型', 'TRANSFER_FREQUENCY', '转账频率', '单日累计转账金额超过', '1000000', '元/日', 6, 'admin', '系统默认参数');
-- ----------------------------
-- 3. 初始化可疑兼职模型参数
-- ----------------------------
INSERT INTO ccdi_model_param (project_id, model_code, model_name, param_code, param_name, param_desc, param_value, param_unit, sort_order, create_by, remark) VALUES
(0, 'SUSPICIOUS_PART_TIME', '可疑兼职模型', 'MONTHLY_FIXED_INCOME', '月度固定收入', '除本行工资外,每月固定收入超过', '5000', '元/月', 1, 'admin', '系统默认参数'),
(0, 'SUSPICIOUS_PART_TIME', '可疑兼职模型', 'FIXED_COUNTERPARTY_TRANSFER', '固定对手转入', '每季从固定交易对手转入金额', '15000', '元/季', 2, 'admin', '系统默认参数'),
(0, 'SUSPICIOUS_PART_TIME', '可疑兼职模型', 'SUSPICIOUS_TIME_TRANSACTION', '非工作时间交易', '非工作时间(22:00-06:00)交易次数', '20', '次/月', 3, 'admin', '系统默认参数');
-- ----------------------------
-- 4. 初始化可疑外汇交易模型参数
-- 2. 初始化系统默认模型参数
-- ----------------------------
INSERT INTO ccdi_model_param (project_id, model_code, model_name, param_code, param_name, param_desc, param_value, param_unit, sort_order, create_by, remark) VALUES
(0, 'LARGE_TRANSACTION', '大额交易模型', 'SINGLE_TRANSACTION_AMOUNT', '单笔大额收入金额', '单笔收入超过该金额', '1111', '', 1, 'admin', '系统默认参数'),
(0, 'LARGE_TRANSACTION', '大额交易模型', 'CUMULATIVE_TRANSACTION_AMOUNT', '累计大额收入金额', '年累计收入超过该金额', '50000001', '', 2, 'admin', '系统默认参数'),
(0, 'LARGE_TRANSACTION', '大额交易模型', 'annual_turnover', '年累计交易额', '年累计交易额超过该金额', '50000001', '', 3, 'admin', '系统默认参数'),
(0, 'LARGE_TRANSACTION', '大额交易模型', 'LARGE_CASH_DEPOSIT', '单笔大额存现金额', '单笔存现金额超过', '2000001', '', 4, 'admin', '系统默认参数'),
(0, 'LARGE_TRANSACTION', '大额交易模型', 'FREQUENT_CASH_DEPOSIT', '单日多次存现次数', '24小时内累计存现超过', '5', '', 5, 'admin', '系统默认参数'),
(0, 'LARGE_TRANSACTION', '大额交易模型', 'FREQUENT_TRANSFER', '单笔大额转账金额', '单日转账次数超过', '100001', '次/日', 6, 'admin', '系统默认参数'),
(0, 'SUSPICIOUS_PART_TIME', '可疑兼职模型', 'MONTHLY_FIXED_INCOME', '月度非本行工资收入金额', '除本行工资外,每月固定收入超过', '5000', '元/月', 1, 'admin', '系统默认参数'),
(0, 'SUSPICIOUS_PART_TIME', '可疑兼职模型', 'FIXED_COUNTERPARTY_TRANSFER', '季度稳定收入金额', '每季从固定交易对手转入金额', '15000', '元/季', 2, 'admin', '系统默认参数'),
(0, 'SUSPICIOUS_FOREIGN_EXCHANGE', '可疑外汇交易模型', 'SINGLE_PURCHASE_AMOUNT', '单笔购汇金额', '单笔购汇超过该金额', '50000', '美元/笔', 1, 'admin', '系统默认参数'),
(0, 'SUSPICIOUS_FOREIGN_EXCHANGE', '可疑外汇交易模型', 'SINGLE_SETTLEMENT_AMOUNT', '单笔结汇金额', '单笔结汇超过该金额', '50000', '美元/笔', 2, 'admin', '系统默认参数'),
(0, 'SUSPICIOUS_FOREIGN_EXCHANGE', '可疑外汇交易模型', 'CROSS_BORDER_REMITTANCE', '跨境汇款金额', '跨境汇款金额超过', '200000', '美元/笔', 3, 'admin', '系统默认参数'),
(0, 'SUSPICIOUS_FOREIGN_EXCHANGE', '可疑外汇交易模型', 'MONTHLY_PURCHASE_TOTAL', '月度购汇总', '月度购汇总额超过', '100000', '美元/月', 4, 'admin', '系统默认参数'),
(0, 'SUSPICIOUS_FOREIGN_EXCHANGE', '可疑外汇交易模型', 'MONTHLY_SETTLEMENT_TOTAL', '月度结汇总额', '月度结汇总额超过', '100000', '美元/月', 5, 'admin', '系统默认参数'),
(0, 'SUSPICIOUS_FOREIGN_EXCHANGE', '可疑外汇交易模型', 'FREQUENT_FOREX_TRADE', '频繁外汇交易', '单日外汇交易次数超过', '5', '/日', 6, 'admin', '系统默认参数');
(0, 'ABNORMAL_BEHAVIOR', '异常行为模型', 'stock_tfr_large', '银证转账大额金', '', '1000000', '', 1, 'admin', '系统默认参数'),
(0, 'ABNORMAL_BEHAVIOR', '异常行为模型', 'withdraw_cnt', '微信、支付宝单日提现次数', '', '3', '次/日', 2, 'admin', '系统默认参数'),
(0, 'ABNORMAL_BEHAVIOR', '异常行为模型', 'withdraw_amt', '微信、支付宝单日提现金额', '', '50000', '/日', 3, 'admin', '系统默认参数'),
(0, 'SUSPICIOUS_GAMBLING', '疑似赌博交易模型', 'multi_party_amt_min', '疑似赌博金额下限', '', '500', '', 1, 'admin', '系统默认参数'),
(0, 'SUSPICIOUS_GAMBLING', '疑似赌博交易模型', 'multi_party_amt_max', '疑似赌博金额上限', '', '5000', '', 2, 'admin', '系统默认参数');

View File

@@ -6,6 +6,8 @@ REPO_ROOT = Path(__file__).resolve().parents[2]
DEPLOY_PS1 = REPO_ROOT / "deploy" / "deploy.ps1"
DEPLOY_BAT = REPO_ROOT / "deploy" / "deploy-to-nas.bat"
DOCKER_COMPOSE = REPO_ROOT / "docker-compose.yml"
ENV_EXAMPLE = REPO_ROOT / ".env.example"
LSFX_MOCK_COMPOSE = REPO_ROOT / "lsfx-mock-server" / "docker-compose.yml"
def run_powershell(*args):
@@ -79,3 +81,17 @@ def test_compose_exposes_lsfx_mock_port_via_backend_namespace():
assert '${LSFX_MOCK_PORT:-62320}:8000' in compose_text
assert 'network_mode: "service:backend"' in compose_text
def test_compose_defaults_backend_profile_to_nas():
compose_text = DOCKER_COMPOSE.read_text(encoding="utf-8")
env_text = ENV_EXAMPLE.read_text(encoding="utf-8")
assert '${SPRING_PROFILES_ACTIVE:-nas}' in compose_text
assert 'SPRING_PROFILES_ACTIVE=nas' in env_text
def test_lsfx_mock_standalone_compose_exposes_62320():
compose_text = LSFX_MOCK_COMPOSE.read_text(encoding="utf-8")
assert '"62320:8000"' in compose_text

View File

@@ -0,0 +1,22 @@
import assert from "node:assert/strict";
import {
getUploadFileAction,
getUploadFileStatusText,
getUploadFileStatusType
} from "../../ruoyi-ui/src/views/ccdiProject/components/detail/uploadFileActionRules.js";
assert.deepEqual(getUploadFileAction("parsed_failed"), {
key: "viewError",
text: "查看错误原因"
});
assert.deepEqual(getUploadFileAction("parsed_success"), {
key: "delete",
text: "删除"
});
assert.equal(getUploadFileAction("uploading"), null);
assert.equal(getUploadFileAction("parsing"), null);
assert.equal(getUploadFileAction("deleted"), null);
assert.equal(getUploadFileStatusText("deleted"), "已删除");
assert.equal(getUploadFileStatusType("deleted"), "info");
console.log("PASS");