From 1fd7ae7026fa04756dec385d42c4ce85a8292005 Mon Sep 17 00:00:00 2001 From: wkc <978997012@qq.com> Date: Fri, 20 Mar 2026 14:42:11 +0800 Subject: [PATCH 01/19] =?UTF-8?q?=E6=8C=81=E4=B9=85=E5=8C=96Mock=E9=9A=8F?= =?UTF-8?q?=E6=9C=BA=E5=91=BD=E4=B8=AD=E8=A7=84=E5=88=99=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lsfx-mock-server/services/file_service.py | 55 +++++++++++++++++++++ lsfx-mock-server/tests/test_file_service.py | 46 +++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/lsfx-mock-server/services/file_service.py b/lsfx-mock-server/services/file_service.py index 4d97875e..ec13b0f3 100644 --- a/lsfx-mock-server/services/file_service.py +++ b/lsfx-mock-server/services/file_service.py @@ -9,6 +9,28 @@ from datetime import datetime, timedelta import random import uuid +LARGE_TRANSACTION_RULE_CODES = [ + "HOUSE_OR_CAR_EXPENSE", + "TAX_EXPENSE", + "SINGLE_LARGE_INCOME", + "CUMULATIVE_INCOME", + "ANNUAL_TURNOVER", + "LARGE_CASH_DEPOSIT", + "FREQUENT_CASH_DEPOSIT", + "LARGE_TRANSFER", +] + +PHASE1_RULE_CODES = [ + "GAMBLING_SENSITIVE_KEYWORD", + "SPECIAL_AMOUNT_TRANSACTION", + "SUSPICIOUS_INCOME_KEYWORD", + "FOREX_BUY_AMT", + "FOREX_SELL_AMT", + "STOCK_TFR_LARGE", + "LARGE_STOCK_TRADING", + "WITHDRAW_CNT", +] + @dataclass class FileRecord: @@ -64,6 +86,8 @@ class FileRecord: staff_name: str = "" staff_id_card: str = "" family_id_cards: List[str] = field(default_factory=list) + large_transaction_hit_rules: List[str] = field(default_factory=list) + phase1_hit_rules: List[str] = field(default_factory=list) class FileService: @@ -110,6 +134,27 @@ class FileService: "enterpriseNameList": [primary_enterprise_name], } + def _pick_rule_subset( + self, + rng: random.Random, + rule_codes: List[str], + min_count: int, + max_count: int, + ) -> List[str]: + """按固定随机源选择稳定规则子集,并保留规则池原始顺序。""" + selected_codes = set(rng.sample(rule_codes, rng.randint(min_count, max_count))) + return [rule_code for rule_code in rule_codes if rule_code in selected_codes] + + def _build_rule_hit_plan(self, log_id: int) -> dict: + """基于 logId 生成稳定的规则命中计划。""" + rng = random.Random(f"rule-plan:{log_id}") + return { + "large_transaction_hit_rules": self._pick_rule_subset( + rng, LARGE_TRANSACTION_RULE_CODES, 2, 4 + ), + "phase1_hit_rules": self._pick_rule_subset(rng, PHASE1_RULE_CODES, 2, 4), + } + def _create_file_record( self, *, @@ -130,6 +175,8 @@ class FileService: staff_name: str = "", staff_id_card: str = "", family_id_cards: List[str] = None, + large_transaction_hit_rules: List[str] = None, + phase1_hit_rules: List[str] = None, parsing: bool = True, status: int = -5, ) -> FileRecord: @@ -160,6 +207,8 @@ class FileService: staff_name=staff_name, staff_id_card=staff_id_card, family_id_cards=list(family_id_cards or []), + large_transaction_hit_rules=list(large_transaction_hit_rules or []), + phase1_hit_rules=list(phase1_hit_rules or []), parsing=parsing, status=status, ) @@ -187,6 +236,7 @@ class FileService: # 推断银行信息 bank_name, template_name = self._infer_bank_name(file.filename) + rule_hit_plan = self._build_rule_hit_plan(log_id) # 生成合理的交易日期范围 end_date = datetime.now() @@ -217,6 +267,8 @@ class FileService: staff_name=identity_scope["staff_name"], staff_id_card=identity_scope["staff_id_card"], family_id_cards=identity_scope["family_id_cards"], + large_transaction_hit_rules=rule_hit_plan["large_transaction_hit_rules"], + phase1_hit_rules=rule_hit_plan["phase1_hit_rules"], ) # 存储记录 @@ -521,6 +573,7 @@ class FileService: # 使用递增 logId,确保与上传链路一致 self.log_counter += 1 log_id = self.log_counter + rule_hit_plan = self._build_rule_hit_plan(log_id) primary_enterprise_name, primary_account_no = self._generate_primary_binding() identity_scope = self._select_staff_identity_scope() @@ -542,6 +595,8 @@ class FileService: staff_name=identity_scope["staff_name"], staff_id_card=identity_scope["staff_id_card"], family_id_cards=identity_scope["family_id_cards"], + large_transaction_hit_rules=rule_hit_plan["large_transaction_hit_rules"], + phase1_hit_rules=rule_hit_plan["phase1_hit_rules"], parsing=False, ) diff --git a/lsfx-mock-server/tests/test_file_service.py b/lsfx-mock-server/tests/test_file_service.py index 7a1b5a9a..94bd3893 100644 --- a/lsfx-mock-server/tests/test_file_service.py +++ b/lsfx-mock-server/tests/test_file_service.py @@ -154,3 +154,49 @@ def test_fetch_inner_flow_persists_primary_binding_record(monkeypatch): assert record.enterprise_name_list == ["行内主体"] assert record.account_no_list == ["6210987654321098"] assert record.total_records == 200 + + +def test_build_rule_hit_plan_should_be_deterministic_for_same_log_id(): + service = FileService(staff_identity_repository=FakeStaffIdentityRepository()) + + plan1 = service._build_rule_hit_plan(10001) + plan2 = service._build_rule_hit_plan(10001) + + assert plan1 == plan2 + assert 2 <= len(plan1["large_transaction_hit_rules"]) <= 4 + assert 2 <= len(plan1["phase1_hit_rules"]) <= 4 + + +def test_fetch_inner_flow_should_persist_rule_hit_plan(monkeypatch): + service = FileService(staff_identity_repository=FakeStaffIdentityRepository()) + monkeypatch.setattr( + service, + "_build_rule_hit_plan", + lambda log_id: { + "large_transaction_hit_rules": ["HOUSE_OR_CAR_EXPENSE", "TAX_EXPENSE"], + "phase1_hit_rules": ["GAMBLING_SENSITIVE_KEYWORD", "FOREX_BUY_AMT"], + }, + ) + + response = service.fetch_inner_flow( + { + "groupId": 1001, + "customerNo": "test_customer_001", + "dataChannelCode": "test_code", + "requestDateId": 20240101, + "dataStartDateId": 20240101, + "dataEndDateId": 20240131, + "uploadUserId": 902001, + } + ) + log_id = response["data"][0] + record = service.file_records[log_id] + + assert record.large_transaction_hit_rules == [ + "HOUSE_OR_CAR_EXPENSE", + "TAX_EXPENSE", + ] + assert record.phase1_hit_rules == [ + "GAMBLING_SENSITIVE_KEYWORD", + "FOREX_BUY_AMT", + ] From 5d03811d4968e4b252e3c0e0b7ed787478e5af2f Mon Sep 17 00:00:00 2001 From: wkc <978997012@qq.com> Date: Fri, 20 Mar 2026 14:45:49 +0800 Subject: [PATCH 02/19] =?UTF-8?q?=E6=8B=86=E5=88=86Mock=E8=A7=84=E5=88=99?= =?UTF-8?q?=E6=A0=B7=E6=9C=AC=E6=9E=84=E9=80=A0=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/statement_rule_samples.py | 691 ++++++++++++------ .../tests/test_statement_service.py | 39 + 2 files changed, 513 insertions(+), 217 deletions(-) diff --git a/lsfx-mock-server/services/statement_rule_samples.py b/lsfx-mock-server/services/statement_rule_samples.py index f365d3e1..5a6f3798 100644 --- a/lsfx-mock-server/services/statement_rule_samples.py +++ b/lsfx-mock-server/services/statement_rule_samples.py @@ -141,6 +141,468 @@ def _build_statement( } +def _build_sample_context( + log_id: int, + primary_enterprise_name: Optional[str] = None, + primary_account_no: Optional[str] = None, + staff_id_card: Optional[str] = None, + family_id_cards: Optional[List[str]] = None, +) -> Dict[str, str]: + identity_scope = resolve_identity_scope(log_id) + staff_identity = identity_scope["staff"] + family_identity = identity_scope["family"] + selected_staff_id_card = staff_id_card or staff_identity["id_card"] + selected_family_id_cards = list(family_id_cards or [family_identity["id_card"]]) + + return { + "le_name": primary_enterprise_name or "模型测试主体", + "account_no": primary_account_no or "6222024999999999", + "staff_id_card": selected_staff_id_card, + "family_id_card": ( + selected_family_id_cards[0] if selected_family_id_cards else selected_staff_id_card + ), + } + + +def build_house_or_car_samples(group_id: int, log_id: int, **kwargs) -> List[Dict]: + context = _build_sample_context(log_id, **kwargs) + return [ + _build_statement( + group_id, + log_id, + trx_datetime=REFERENCE_NOW - timedelta(days=9, hours=1), + cret_no=context["staff_id_card"], + customer_name="杭州贝壳房地产经纪有限公司", + user_memo="购买房产首付款", + cash_type="对公转账", + dr_amount=680000.0, + le_name=context["le_name"], + account_mask_no=context["account_no"], + customer_account_mask_no="6222024555500001", + ), + _build_statement( + group_id, + log_id, + trx_datetime=REFERENCE_NOW - timedelta(days=8, hours=2), + cret_no=context["family_id_card"], + customer_name="兰溪星耀汽车销售服务有限公司", + user_memo="购车首付款", + cash_type="对公转账", + dr_amount=380000.0, + le_name=context["le_name"], + account_mask_no=context["account_no"], + customer_account_mask_no="6222024555500002", + ), + ] + + +def build_tax_samples(group_id: int, log_id: int, **kwargs) -> List[Dict]: + context = _build_sample_context(log_id, **kwargs) + return [ + _build_statement( + group_id, + log_id, + trx_datetime=REFERENCE_NOW - timedelta(days=7, hours=1), + cret_no=context["staff_id_card"], + customer_name="国家金库兰溪市中心支库", + user_memo="个人所得税税款", + cash_type="税务缴款", + dr_amount=126000.0, + le_name=context["le_name"], + account_mask_no=context["account_no"], + customer_account_mask_no="6222024555500003", + ), + _build_statement( + group_id, + log_id, + trx_datetime=REFERENCE_NOW - timedelta(days=6, hours=3), + cret_no=context["family_id_card"], + customer_name="兰溪市税务局", + user_memo="房产税务缴税", + cash_type="税务缴款", + dr_amount=88000.0, + le_name=context["le_name"], + account_mask_no=context["account_no"], + customer_account_mask_no="6222024555500004", + ), + ] + + +def build_single_large_income_samples(group_id: int, log_id: int, **kwargs) -> List[Dict]: + context = _build_sample_context(log_id, **kwargs) + return [ + _build_statement( + group_id, + log_id, + trx_datetime=REFERENCE_NOW - timedelta(days=5, hours=2), + cret_no=context["staff_id_card"], + customer_name="浙江远望贸易有限公司", + user_memo="经营往来收入", + cash_type="对公转账", + cr_amount=18800000.0, + le_name=context["le_name"], + account_mask_no=context["account_no"], + customer_account_mask_no="6222024666600001", + ) + ] + + +def build_cumulative_income_samples(group_id: int, log_id: int, **kwargs) -> List[Dict]: + context = _build_sample_context(log_id, **kwargs) + return [ + _build_statement( + group_id, + log_id, + trx_datetime=REFERENCE_NOW - timedelta(days=5, hours=2), + cret_no=context["staff_id_card"], + customer_name="浙江远望贸易有限公司", + user_memo="经营往来收入", + cash_type="对公转账", + cr_amount=18800000.0, + le_name=context["le_name"], + account_mask_no=context["account_no"], + customer_account_mask_no="6222024666600001", + ), + _build_statement( + group_id, + log_id, + trx_datetime=REFERENCE_NOW - timedelta(days=5, hours=1), + cret_no=context["staff_id_card"], + customer_name="浙江远望贸易有限公司", + user_memo="项目回款收入", + cash_type="对公转账", + cr_amount=20800000.0, + le_name=context["le_name"], + account_mask_no=context["account_no"], + customer_account_mask_no="6222024666600001", + ), + _build_statement( + group_id, + log_id, + trx_datetime=REFERENCE_NOW - timedelta(days=4, hours=4), + cret_no=context["staff_id_card"], + customer_name="浙江远望贸易有限公司", + user_memo="业务合作收入", + cash_type="对公转账", + cr_amount=20700000.0, + le_name=context["le_name"], + account_mask_no=context["account_no"], + customer_account_mask_no="6222024666600001", + ), + ] + + +def build_annual_turnover_supporting_samples(group_id: int, log_id: int, **kwargs) -> List[Dict]: + context = _build_sample_context(log_id, **kwargs) + return [ + _build_statement( + group_id, + log_id, + trx_datetime=REFERENCE_NOW - timedelta(days=15, hours=2), + cret_no=context["staff_id_card"], + customer_name="浙江金穗供应链有限公司", + user_memo="年度经营回款", + cash_type="对公转账", + cr_amount=17200000.0, + le_name=context["le_name"], + account_mask_no=context["account_no"], + customer_account_mask_no="6222024666600002", + ), + _build_statement( + group_id, + log_id, + trx_datetime=REFERENCE_NOW - timedelta(days=11, hours=3), + cret_no=context["staff_id_card"], + customer_name="浙江金穗供应链有限公司", + user_memo="年度项目回款", + cash_type="对公转账", + cr_amount=17600000.0, + le_name=context["le_name"], + account_mask_no=context["account_no"], + customer_account_mask_no="6222024666600002", + ), + _build_statement( + group_id, + log_id, + trx_datetime=REFERENCE_NOW - timedelta(days=9, hours=4), + cret_no=context["staff_id_card"], + customer_name="浙江金穗供应链有限公司", + user_memo="年度合作收入", + cash_type="对公转账", + cr_amount=17800000.0, + le_name=context["le_name"], + account_mask_no=context["account_no"], + customer_account_mask_no="6222024666600002", + ), + ] + + +def build_large_cash_deposit_samples(group_id: int, log_id: int, **kwargs) -> List[Dict]: + context = _build_sample_context(log_id, **kwargs) + return [ + _build_statement( + group_id, + log_id, + trx_datetime=datetime(2026, 3, 10, 9, 0, 0), + cret_no=context["staff_id_card"], + customer_name="", + user_memo="现金存款", + cash_type="现金存款", + cr_amount=3000000.0, + le_name=context["le_name"], + account_mask_no=context["account_no"], + ) + ] + + +def build_frequent_cash_deposit_samples(group_id: int, log_id: int, **kwargs) -> List[Dict]: + context = _build_sample_context(log_id, **kwargs) + deposit_specs = [ + (datetime(2026, 3, 10, 9, 0, 0), "现金存款", 3000000.0), + (datetime(2026, 3, 10, 9, 30, 0), "ATM现金存款", 3100000.0), + (datetime(2026, 3, 10, 10, 0, 0), "自助存款现金存入", 3200000.0), + (datetime(2026, 3, 10, 10, 30, 0), "CRS存款", 3300000.0), + (datetime(2026, 3, 10, 11, 0, 0), "本行ATM存款", 3400000.0), + (datetime(2026, 3, 10, 11, 30, 0), "柜面现金存款", 3500000.0), + ] + return [ + _build_statement( + group_id, + log_id, + trx_datetime=trx_datetime, + cret_no=context["staff_id_card"], + customer_name="", + user_memo=user_memo, + cash_type="现金存款", + cr_amount=cr_amount, + le_name=context["le_name"], + account_mask_no=context["account_no"], + ) + for trx_datetime, user_memo, cr_amount in deposit_specs + ] + + +def build_large_transfer_samples(group_id: int, log_id: int, **kwargs) -> List[Dict]: + context = _build_sample_context(log_id, **kwargs) + return [ + _build_statement( + group_id, + log_id, + trx_datetime=REFERENCE_NOW - timedelta(days=3, hours=1), + cret_no=context["staff_id_card"], + customer_name="异地转账平台", + user_memo="手机银行转账", + cash_type="转账支出", + dr_amount=12000000.0, + le_name=context["le_name"], + account_mask_no=context["account_no"], + customer_account_mask_no="6222024777700001", + ) + ] + + +def build_gambling_sensitive_keyword_samples(group_id: int, log_id: int, **kwargs) -> List[Dict]: + context = _build_sample_context(log_id, **kwargs) + return [ + _build_statement( + group_id, + log_id, + trx_datetime=REFERENCE_NOW - timedelta(days=4, hours=2), + cret_no=context["staff_id_card"], + customer_name="欢乐游戏科技有限公司", + user_memo="游戏充值", + cash_type="快捷支付", + dr_amount=6888.0, + le_name=context["le_name"], + account_mask_no=context["account_no"], + customer_account_mask_no="6222024888800001", + ) + ] + + +def build_special_amount_transaction_samples(group_id: int, log_id: int, **kwargs) -> List[Dict]: + context = _build_sample_context(log_id, **kwargs) + return [ + _build_statement( + group_id, + log_id, + trx_datetime=REFERENCE_NOW - timedelta(days=4, hours=1), + cret_no=context["staff_id_card"], + customer_name="兰溪特别金额结算中心", + user_memo="特殊金额转账", + cash_type="转账支出", + dr_amount=88888.88, + le_name=context["le_name"], + account_mask_no=context["account_no"], + customer_account_mask_no="6222024888800002", + ) + ] + + +def build_suspicious_income_keyword_samples(group_id: int, log_id: int, **kwargs) -> List[Dict]: + context = _build_sample_context(log_id, **kwargs) + return [ + _build_statement( + group_id, + log_id, + trx_datetime=REFERENCE_NOW - timedelta(days=3, hours=5), + cret_no=context["staff_id_card"], + customer_name="灰度信息咨询有限公司", + user_memo="咨询返现收入", + cash_type="对公转账", + cr_amount=166666.0, + le_name=context["le_name"], + account_mask_no=context["account_no"], + customer_account_mask_no="6222024888800003", + ) + ] + + +def build_forex_buy_samples(group_id: int, log_id: int, **kwargs) -> List[Dict]: + context = _build_sample_context(log_id, **kwargs) + return [ + _build_statement( + group_id, + log_id, + trx_datetime=REFERENCE_NOW - timedelta(days=2, hours=6), + cret_no=context["staff_id_card"], + customer_name="中国银行外汇业务中心", + user_memo="个人购汇", + cash_type="购汇支出", + dr_amount=126000.0, + le_name=context["le_name"], + account_mask_no=context["account_no"], + customer_account_mask_no="6222024999900001", + ) + ] + + +def build_forex_sell_samples(group_id: int, log_id: int, **kwargs) -> List[Dict]: + context = _build_sample_context(log_id, **kwargs) + return [ + _build_statement( + group_id, + log_id, + trx_datetime=REFERENCE_NOW - timedelta(days=2, hours=4), + cret_no=context["staff_id_card"], + customer_name="中国银行外汇业务中心", + user_memo="个人结汇", + cash_type="结汇收入", + cr_amount=132000.0, + le_name=context["le_name"], + account_mask_no=context["account_no"], + customer_account_mask_no="6222024999900002", + ) + ] + + +def build_stock_transfer_large_samples(group_id: int, log_id: int, **kwargs) -> List[Dict]: + context = _build_sample_context(log_id, **kwargs) + return [ + _build_statement( + group_id, + log_id, + trx_datetime=REFERENCE_NOW - timedelta(days=2, hours=2), + cret_no=context["staff_id_card"], + customer_name="国信证券资金账户", + user_memo="证券大额转托管转出", + cash_type="转账支出", + dr_amount=560000.0, + le_name=context["le_name"], + account_mask_no=context["account_no"], + customer_account_mask_no="6222024999900003", + ) + ] + + +def build_large_stock_trading_samples(group_id: int, log_id: int, **kwargs) -> List[Dict]: + context = _build_sample_context(log_id, **kwargs) + return [ + _build_statement( + group_id, + log_id, + trx_datetime=REFERENCE_NOW - timedelta(days=1, hours=3), + cret_no=context["staff_id_card"], + customer_name="华泰证券资金账户", + user_memo="证券大额交易买入", + cash_type="证券交易", + dr_amount=880000.0, + le_name=context["le_name"], + account_mask_no=context["account_no"], + customer_account_mask_no="6222024999900004", + ) + ] + + +def build_withdraw_cnt_samples(group_id: int, log_id: int, **kwargs) -> List[Dict]: + context = _build_sample_context(log_id, **kwargs) + withdraw_specs = [ + (datetime(2026, 3, 12, 9, 0, 0), "微信提现", 8000.0), + (datetime(2026, 3, 12, 10, 0, 0), "支付宝提现", 9000.0), + (datetime(2026, 3, 12, 11, 0, 0), "微信提现", 8500.0), + (datetime(2026, 3, 12, 12, 0, 0), "支付宝提现", 9200.0), + ] + return [ + _build_statement( + group_id, + log_id, + trx_datetime=trx_datetime, + cret_no=context["staff_id_card"], + customer_name="财付通结算账户" if "微信" in user_memo else "支付宝结算账户", + user_memo=user_memo, + cash_type="提现支出", + dr_amount=dr_amount, + le_name=context["le_name"], + account_mask_no=context["account_no"], + ) + for trx_datetime, user_memo, dr_amount in withdraw_specs + ] + + +LARGE_TRANSACTION_BUILDERS = { + "HOUSE_OR_CAR_EXPENSE": build_house_or_car_samples, + "TAX_EXPENSE": build_tax_samples, + "SINGLE_LARGE_INCOME": build_single_large_income_samples, + "CUMULATIVE_INCOME": build_cumulative_income_samples, + "ANNUAL_TURNOVER": build_annual_turnover_supporting_samples, + "LARGE_CASH_DEPOSIT": build_large_cash_deposit_samples, + "FREQUENT_CASH_DEPOSIT": build_frequent_cash_deposit_samples, + "LARGE_TRANSFER": build_large_transfer_samples, +} + +PHASE1_RULE_BUILDERS = { + "GAMBLING_SENSITIVE_KEYWORD": build_gambling_sensitive_keyword_samples, + "SPECIAL_AMOUNT_TRANSACTION": build_special_amount_transaction_samples, + "SUSPICIOUS_INCOME_KEYWORD": build_suspicious_income_keyword_samples, + "FOREX_BUY_AMT": build_forex_buy_samples, + "FOREX_SELL_AMT": build_forex_sell_samples, + "STOCK_TFR_LARGE": build_stock_transfer_large_samples, + "LARGE_STOCK_TRADING": build_large_stock_trading_samples, + "WITHDRAW_CNT": build_withdraw_cnt_samples, +} + + +def build_seed_statements_for_rule_plan( + group_id: int, + log_id: int, + rule_plan: Dict, + **kwargs, +) -> List[Dict]: + statements: List[Dict] = [] + + for rule_code in rule_plan.get("large_transaction_hit_rules", []): + builder = LARGE_TRANSACTION_BUILDERS.get(rule_code) + if builder is not None: + statements.extend(builder(group_id, log_id, **kwargs)) + + for rule_code in rule_plan.get("phase1_hit_rules", []): + builder = PHASE1_RULE_BUILDERS.get(rule_code) + if builder is not None: + statements.extend(builder(group_id, log_id, **kwargs)) + + return statements + + def build_large_transaction_seed_statements( group_id: int, log_id: int, @@ -149,220 +611,15 @@ def build_large_transaction_seed_statements( staff_id_card: Optional[str] = None, family_id_cards: Optional[List[str]] = None, ) -> List[Dict]: - le_name = primary_enterprise_name or "模型测试主体" - account_no = primary_account_no or "6222024999999999" - identity_scope = resolve_identity_scope(log_id) - staff_identity = identity_scope["staff"] - family_identity = identity_scope["family"] - selected_staff_id_card = staff_id_card or staff_identity["id_card"] - selected_family_id_cards = list(family_id_cards or [family_identity["id_card"]]) - primary_family_id_card = selected_family_id_cards[0] if selected_family_id_cards else selected_staff_id_card - - statements: List[Dict] = [] - - statements.extend([ - _build_statement( - group_id, - log_id, - trx_datetime=REFERENCE_NOW - timedelta(days=9, hours=1), - cret_no=selected_staff_id_card, - customer_name="杭州贝壳房地产经纪有限公司", - user_memo="购买房产首付款", - cash_type="对公转账", - dr_amount=680000.0, - le_name=le_name, - account_mask_no=account_no, - customer_account_mask_no="6222024555500001", - ), - _build_statement( - group_id, - log_id, - trx_datetime=REFERENCE_NOW - timedelta(days=8, hours=2), - cret_no=primary_family_id_card, - customer_name="兰溪星耀汽车销售服务有限公司", - user_memo="购车首付款", - cash_type="对公转账", - dr_amount=380000.0, - le_name=le_name, - account_mask_no=account_no, - customer_account_mask_no="6222024555500002", - ), - _build_statement( - group_id, - log_id, - trx_datetime=REFERENCE_NOW - timedelta(days=7, hours=1), - cret_no=selected_staff_id_card, - customer_name="国家金库兰溪市中心支库", - user_memo="个人所得税税款", - cash_type="税务缴款", - dr_amount=126000.0, - le_name=le_name, - account_mask_no=account_no, - customer_account_mask_no="6222024555500003", - ), - _build_statement( - group_id, - log_id, - trx_datetime=REFERENCE_NOW - timedelta(days=6, hours=3), - cret_no=primary_family_id_card, - customer_name="兰溪市税务局", - user_memo="房产税务缴税", - cash_type="税务缴款", - dr_amount=88000.0, - le_name=le_name, - account_mask_no=account_no, - customer_account_mask_no="6222024555500004", - ), - _build_statement( - group_id, - log_id, - trx_datetime=REFERENCE_NOW - timedelta(days=5, hours=2), - cret_no=selected_staff_id_card, - customer_name="浙江远望贸易有限公司", - user_memo="经营往来收入", - cash_type="对公转账", - cr_amount=18800000.0, - le_name=le_name, - account_mask_no=account_no, - customer_account_mask_no="6222024666600001", - ), - _build_statement( - group_id, - log_id, - trx_datetime=REFERENCE_NOW - timedelta(days=5, hours=1), - cret_no=selected_staff_id_card, - customer_name="浙江远望贸易有限公司", - user_memo="项目回款收入", - cash_type="对公转账", - cr_amount=20800000.0, - le_name=le_name, - account_mask_no=account_no, - customer_account_mask_no="6222024666600001", - ), - _build_statement( - group_id, - log_id, - trx_datetime=REFERENCE_NOW - timedelta(days=4, hours=4), - cret_no=selected_staff_id_card, - customer_name="浙江远望贸易有限公司", - user_memo="业务合作收入", - cash_type="对公转账", - cr_amount=20700000.0, - le_name=le_name, - account_mask_no=account_no, - customer_account_mask_no="6222024666600001", - ), - _build_statement( - group_id, - log_id, - trx_datetime=datetime(2026, 3, 10, 9, 0, 0), - cret_no=selected_staff_id_card, - customer_name="", - user_memo="现金存款", - cash_type="现金存款", - cr_amount=3000000.0, - le_name=le_name, - account_mask_no=account_no, - ), - _build_statement( - group_id, - log_id, - trx_datetime=datetime(2026, 3, 10, 9, 30, 0), - cret_no=selected_staff_id_card, - customer_name="", - user_memo="ATM现金存款", - cash_type="现金存款", - cr_amount=3100000.0, - le_name=le_name, - account_mask_no=account_no, - ), - _build_statement( - group_id, - log_id, - trx_datetime=datetime(2026, 3, 10, 10, 0, 0), - cret_no=selected_staff_id_card, - customer_name="", - user_memo="自助存款现金存入", - cash_type="现金存款", - cr_amount=3200000.0, - le_name=le_name, - account_mask_no=account_no, - ), - _build_statement( - group_id, - log_id, - trx_datetime=datetime(2026, 3, 10, 10, 30, 0), - cret_no=selected_staff_id_card, - customer_name="", - user_memo="CRS存款", - cash_type="现金存款", - cr_amount=3300000.0, - le_name=le_name, - account_mask_no=account_no, - ), - _build_statement( - group_id, - log_id, - trx_datetime=datetime(2026, 3, 10, 11, 0, 0), - cret_no=selected_staff_id_card, - customer_name="", - user_memo="本行ATM存款", - cash_type="现金存款", - cr_amount=3400000.0, - le_name=le_name, - account_mask_no=account_no, - ), - _build_statement( - group_id, - log_id, - trx_datetime=datetime(2026, 3, 10, 11, 30, 0), - cret_no=selected_staff_id_card, - customer_name="", - user_memo="柜面现金存款", - cash_type="现金存款", - cr_amount=3500000.0, - le_name=le_name, - account_mask_no=account_no, - ), - _build_statement( - group_id, - log_id, - trx_datetime=REFERENCE_NOW - timedelta(days=3, hours=1), - cret_no=selected_staff_id_card, - customer_name="异地转账平台", - user_memo="手机银行转账", - cash_type="转账支出", - dr_amount=12000000.0, - le_name=le_name, - account_mask_no=account_no, - customer_account_mask_no="6222024777700001", - ), - _build_statement( - group_id, - log_id, - trx_datetime=REFERENCE_NOW - timedelta(days=3, hours=2), - cret_no=selected_staff_id_card, - customer_name="跨行转账中心", - user_memo="对外转账", - cash_type="转账支出", - dr_amount=10000000.0, - le_name=le_name, - account_mask_no=account_no, - customer_account_mask_no="6222024777700002", - ), - _build_statement( - group_id, - log_id, - trx_datetime=REFERENCE_NOW - timedelta(days=2, hours=5), - cret_no=selected_staff_id_card, - customer_name="跨境转账服务平台", - user_memo="网银转账", - cash_type="转账支出", - dr_amount=9000000.0, - le_name=le_name, - account_mask_no=account_no, - customer_account_mask_no="6222024777700003", - ), - ]) - - return statements + return build_seed_statements_for_rule_plan( + group_id=group_id, + log_id=log_id, + rule_plan={ + "large_transaction_hit_rules": list(LARGE_TRANSACTION_BUILDERS.keys()), + "phase1_hit_rules": [], + }, + primary_enterprise_name=primary_enterprise_name, + primary_account_no=primary_account_no, + staff_id_card=staff_id_card, + family_id_cards=family_id_cards, + ) diff --git a/lsfx-mock-server/tests/test_statement_service.py b/lsfx-mock-server/tests/test_statement_service.py index 21f91523..a61d4ec3 100644 --- a/lsfx-mock-server/tests/test_statement_service.py +++ b/lsfx-mock-server/tests/test_statement_service.py @@ -9,6 +9,7 @@ from services.statement_service import StatementService from services.statement_rule_samples import ( DEFAULT_LARGE_TRANSACTION_THRESHOLDS, build_large_transaction_seed_statements, + build_seed_statements_for_rule_plan, ) @@ -31,6 +32,44 @@ def test_generate_statements_should_include_seeded_samples_before_noise(): assert any(item["userMemo"] == "购买房产首付款" for item in statements) +def test_build_seed_statements_for_rule_plan_should_only_include_requested_phase1_rules(): + plan = { + "large_transaction_hit_rules": [], + "phase1_hit_rules": ["GAMBLING_SENSITIVE_KEYWORD", "FOREX_BUY_AMT"], + } + + statements = build_seed_statements_for_rule_plan( + group_id=1000, + log_id=20001, + rule_plan=plan, + ) + + assert any("游戏" in item["userMemo"] for item in statements) + assert any("购汇" in item["userMemo"] for item in statements) + assert not any("证券" in item["userMemo"] for item in statements) + + +def test_build_seed_statements_for_rule_plan_should_generate_withdraw_cnt_samples(): + plan = { + "large_transaction_hit_rules": [], + "phase1_hit_rules": ["WITHDRAW_CNT"], + } + + statements = build_seed_statements_for_rule_plan( + group_id=1000, + log_id=20001, + rule_plan=plan, + ) + + assert len( + [ + item + for item in statements + if "微信提现" in item["userMemo"] or "支付宝提现" in item["userMemo"] + ] + ) >= 4 + + def test_large_transaction_seed_should_cover_all_eight_rules(): """大额交易样本生成器必须覆盖 8 条已实现规则的关键口径。""" statements = build_large_transaction_seed_statements(group_id=1000, log_id=20001) From e97055379c7a4fc4965d4da23d355aae0ea7f54a Mon Sep 17 00:00:00 2001 From: wkc <978997012@qq.com> Date: Fri, 20 Mar 2026 14:48:02 +0800 Subject: [PATCH 03/19] =?UTF-8?q?=E6=8E=A5=E9=80=9AMock=E9=9A=8F=E6=9C=BA?= =?UTF-8?q?=E5=91=BD=E4=B8=AD=E6=B5=81=E6=B0=B4=E7=94=9F=E6=88=90=E9=93=BE?= =?UTF-8?q?=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/statement_service.py | 11 +++- .../tests/integration/test_full_workflow.py | 27 +++++++++ .../tests/test_statement_service.py | 60 +++++++++++++++++-- 3 files changed, 92 insertions(+), 6 deletions(-) diff --git a/lsfx-mock-server/services/statement_service.py b/lsfx-mock-server/services/statement_service.py index e5aa1496..45eef399 100644 --- a/lsfx-mock-server/services/statement_service.py +++ b/lsfx-mock-server/services/statement_service.py @@ -5,7 +5,7 @@ import uuid from datetime import datetime, timedelta from services.statement_rule_samples import ( - build_large_transaction_seed_statements, + build_seed_statements_for_rule_plan, resolve_identity_cards, ) @@ -144,14 +144,21 @@ class StatementService: """生成指定数量的流水记录。""" primary_enterprise_name, primary_account_no = self._resolve_primary_binding(log_id) record = self.file_service.get_file_record(log_id) if self.file_service is not None else None + rule_plan = { + "large_transaction_hit_rules": ( + list(record.large_transaction_hit_rules) if record is not None else [] + ), + "phase1_hit_rules": list(record.phase1_hit_rules) if record is not None else [], + } if record is not None and record.staff_id_card: allowed_identity_cards = tuple([record.staff_id_card, *record.family_id_cards]) else: allowed_identity_cards = resolve_identity_cards(log_id) rng = random.Random(f"statement:{log_id}") - seeded_statements = build_large_transaction_seed_statements( + seeded_statements = build_seed_statements_for_rule_plan( group_id=group_id, log_id=log_id, + rule_plan=rule_plan, primary_enterprise_name=primary_enterprise_name, primary_account_no=primary_account_no, staff_id_card=record.staff_id_card if record is not None else None, diff --git a/lsfx-mock-server/tests/integration/test_full_workflow.py b/lsfx-mock-server/tests/integration/test_full_workflow.py index dc3774a6..7bafc204 100644 --- a/lsfx-mock-server/tests/integration/test_full_workflow.py +++ b/lsfx-mock-server/tests/integration/test_full_workflow.py @@ -170,3 +170,30 @@ def test_upload_status_and_bank_statement_share_same_primary_binding(client, mon assert statements assert all(item["leName"] == status_log["enterpriseNameList"][0] for item in statements) assert all(item["accountMaskNo"] == status_log["accountNoList"][0] for item in statements) + + +def test_inner_flow_bank_statement_should_keep_same_rule_subset(client): + fetch_response = client.post( + "/watson/api/project/getJZFileOrZjrcuFile", + data={ + "groupId": 1001, + "customerNo": "customer_subset", + "dataChannelCode": "test_code", + "requestDateId": 20240101, + "dataStartDateId": 20240101, + "dataEndDateId": 20240131, + "uploadUserId": 902001, + }, + ) + log_id = fetch_response.json()["data"][0] + + page1 = client.post( + "/watson/api/project/getBSByLogId", + data={"groupId": 1001, "logId": log_id, "pageNow": 1, "pageSize": 10}, + ).json() + page2 = client.post( + "/watson/api/project/getBSByLogId", + data={"groupId": 1001, "logId": log_id, "pageNow": 1, "pageSize": 10}, + ).json() + + assert page1["data"]["bankStatementList"] == page2["data"]["bankStatementList"] diff --git a/lsfx-mock-server/tests/test_statement_service.py b/lsfx-mock-server/tests/test_statement_service.py index a61d4ec3..01224378 100644 --- a/lsfx-mock-server/tests/test_statement_service.py +++ b/lsfx-mock-server/tests/test_statement_service.py @@ -22,11 +22,28 @@ class FakeStaffIdentityRepository: } -def test_generate_statements_should_include_seeded_samples_before_noise(): - """生成流水时必须先混入固定命中样本,而不是纯随机噪声。""" - service = StatementService() +def test_generate_statements_should_include_seeded_samples_before_noise_when_rule_plan_exists(): + """存在规则命中计划时,生成流水必须先混入被选中的命中样本。""" + file_service = FileService(staff_identity_repository=FakeStaffIdentityRepository()) + service = StatementService(file_service=file_service) - statements = service._generate_statements(group_id=1000, log_id=20001, count=30) + response = file_service.fetch_inner_flow( + { + "groupId": 1001, + "customerNo": "customer_seeded_samples", + "dataChannelCode": "test_code", + "requestDateId": 20240101, + "dataStartDateId": 20240101, + "dataEndDateId": 20240131, + "uploadUserId": 902001, + } + ) + log_id = response["data"][0] + record = file_service.file_records[log_id] + record.large_transaction_hit_rules = ["HOUSE_OR_CAR_EXPENSE"] + record.phase1_hit_rules = [] + + statements = service._generate_statements(group_id=1000, log_id=log_id, count=30) assert len(statements) >= 30 assert any(item["userMemo"] == "购买房产首付款" for item in statements) @@ -137,6 +154,33 @@ def test_generate_statements_should_fill_noise_up_to_requested_count(): assert len(statements) == 80 +def test_generate_statements_should_follow_rule_hit_plan_from_file_record(): + file_service = FileService(staff_identity_repository=FakeStaffIdentityRepository()) + statement_service = StatementService(file_service=file_service) + + response = file_service.fetch_inner_flow( + { + "groupId": 1001, + "customerNo": "customer_rule_plan", + "dataChannelCode": "test_code", + "requestDateId": 20240101, + "dataStartDateId": 20240101, + "dataEndDateId": 20240131, + "uploadUserId": 902001, + } + ) + log_id = response["data"][0] + record = file_service.file_records[log_id] + record.large_transaction_hit_rules = ["HOUSE_OR_CAR_EXPENSE"] + record.phase1_hit_rules = ["GAMBLING_SENSITIVE_KEYWORD"] + + statements = statement_service._generate_statements(group_id=1001, log_id=log_id, count=200) + + assert any("房产首付款" in item["userMemo"] for item in statements) + assert any("游戏" in item["userMemo"] for item in statements) + assert not any("购汇" in item["userMemo"] for item in statements) + + def test_generate_statements_should_stay_within_single_employee_scope_per_log_id(): """同一 logId 的流水只能落在 FileRecord 绑定的员工及亲属身份证内。""" file_service = FileService(staff_identity_repository=FakeStaffIdentityRepository()) @@ -267,6 +311,14 @@ def test_get_bank_statement_contains_large_transaction_hit_samples(monkeypatch): ) log_id = response["data"][0] record = file_service.file_records[log_id] + record.large_transaction_hit_rules = [ + "HOUSE_OR_CAR_EXPENSE", + "TAX_EXPENSE", + "CUMULATIVE_INCOME", + "FREQUENT_CASH_DEPOSIT", + "LARGE_TRANSFER", + ] + record.phase1_hit_rules = [] staff_id_card = record.staff_id_card family_id_card = record.family_id_cards[0] From 440fc38805f1ce886f7f84b8996de73fc889b55d Mon Sep 17 00:00:00 2001 From: wkc <978997012@qq.com> Date: Fri, 20 Mar 2026 14:49:29 +0800 Subject: [PATCH 04/19] =?UTF-8?q?=E8=A1=A5=E9=BD=90Mock=E9=87=87=E8=B4=AD?= =?UTF-8?q?=E8=A7=84=E5=88=99=E6=95=B0=E6=8D=AE=E5=BA=93=E5=9F=BA=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...mock-random-hit-rule-purchase-baseline.sql | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 sql/migration/2026-03-20-lsfx-mock-random-hit-rule-purchase-baseline.sql diff --git a/sql/migration/2026-03-20-lsfx-mock-random-hit-rule-purchase-baseline.sql b/sql/migration/2026-03-20-lsfx-mock-random-hit-rule-purchase-baseline.sql new file mode 100644 index 00000000..eda85ce8 --- /dev/null +++ b/sql/migration/2026-03-20-lsfx-mock-random-hit-rule-purchase-baseline.sql @@ -0,0 +1,78 @@ +DELETE FROM ccdi_purchase_transaction +WHERE purchase_id = 'LSFXMOCKPUR001'; + +INSERT INTO ccdi_purchase_transaction ( + purchase_id, + purchase_category, + project_name, + subject_name, + subject_desc, + purchase_qty, + budget_amount, + bid_amount, + actual_amount, + contract_amount, + settlement_amount, + purchase_method, + supplier_name, + contact_person, + contact_phone, + supplier_uscc, + supplier_bank_account, + apply_date, + plan_approve_date, + announce_date, + bid_open_date, + contract_sign_date, + expected_delivery_date, + actual_delivery_date, + acceptance_date, + settlement_date, + applicant_id, + applicant_name, + apply_department, + purchase_leader_id, + purchase_leader_name, + purchase_department, + created_by, + updated_by +) +SELECT + 'LSFXMOCKPUR001', + '设备采购', + 'LSFX Mock 联调', + '反洗钱终端设备采购', + '用于命中 LARGE_PURCHASE_TRANSACTION 真实规则', + 1, + 188000.00, + 186000.00, + 186000.00, + 186000.00, + 186000.00, + '竞争性谈判', + '兰溪市联调供应链有限公司', + '联调联系人', + '13800000000', + '91330781MOCKPUR001', + '6222000000001234', + CURRENT_DATE, + CURRENT_DATE, + CURRENT_DATE, + CURRENT_DATE, + CURRENT_DATE, + CURRENT_DATE, + CURRENT_DATE, + CURRENT_DATE, + CURRENT_DATE, + CAST(s.staff_id AS CHAR), + s.name, + '纪检初核部', + NULL, + NULL, + NULL, + 'admin', + 'admin' +FROM ccdi_base_staff s +WHERE COALESCE(TRIM(CAST(s.staff_id AS CHAR)), '') <> '' + AND COALESCE(TRIM(s.name), '') <> '' +LIMIT 1; From 76727b3c6795c86b0188dc0eb303396b52114c5e Mon Sep 17 00:00:00 2001 From: wkc <978997012@qq.com> Date: Fri, 20 Mar 2026 14:50:52 +0800 Subject: [PATCH 05/19] =?UTF-8?q?=E8=A1=A5=E5=85=85Mock=E9=9A=8F=E6=9C=BA?= =?UTF-8?q?=E5=91=BD=E4=B8=AD=E5=90=8E=E7=AB=AF=E5=AE=9E=E6=96=BD=E8=AE=B0?= =?UTF-8?q?=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...sfx-mock-random-hit-rule-backend-record.md | 43 ++++++++++++ ...ck-random-hit-rule-backend-verification.md | 70 +++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 docs/reports/implementation/2026-03-20-lsfx-mock-random-hit-rule-backend-record.md create mode 100644 docs/tests/records/2026-03-20-lsfx-mock-random-hit-rule-backend-verification.md diff --git a/docs/reports/implementation/2026-03-20-lsfx-mock-random-hit-rule-backend-record.md b/docs/reports/implementation/2026-03-20-lsfx-mock-random-hit-rule-backend-record.md new file mode 100644 index 00000000..fe3f9527 --- /dev/null +++ b/docs/reports/implementation/2026-03-20-lsfx-mock-random-hit-rule-backend-record.md @@ -0,0 +1,43 @@ +# LSFX Mock 随机命中规则后端实施记录 + +## 修改范围 +- `lsfx-mock-server/services/file_service.py` +- `lsfx-mock-server/services/statement_rule_samples.py` +- `lsfx-mock-server/services/statement_service.py` +- `lsfx-mock-server/tests/test_file_service.py` +- `lsfx-mock-server/tests/test_statement_service.py` +- `lsfx-mock-server/tests/integration/test_full_workflow.py` +- `sql/migration/2026-03-20-lsfx-mock-random-hit-rule-purchase-baseline.sql` + +## 规则命中计划生成方式 +- 在 `FileService` 中新增 `LARGE_TRANSACTION_RULE_CODES` 与 `PHASE1_RULE_CODES` 两组规则池。 +- 新增 `_build_rule_hit_plan(log_id)`,使用 `random.Random(f"rule-plan:{log_id}")` 生成稳定随机源。 +- 通过 `_pick_rule_subset()` 从两组规则池内分别稳定选出 `2-4` 条规则,并保留规则池原始顺序。 +- 在 `upload_file()` 与 `fetch_inner_flow()` 创建 `FileRecord` 时同步写入: + - `large_transaction_hit_rules` + - `phase1_hit_rules` + +## 样本模块按规则子集装配 +- 将原有“大额交易全量样本”拆成按规则代码独立调用的 builder。 +- 新增 `LARGE_TRANSACTION_BUILDERS` 与 `PHASE1_RULE_BUILDERS` 两组映射,覆盖: + - 大额交易 8 条规则 + - 第一期可由银行流水构造的 8 条规则 +- 提供统一入口 `build_seed_statements_for_rule_plan(...)`,仅按 `rule_plan` 中被选中的规则拼装最小命中样本,不再默认返回全量命中样本。 +- `build_large_transaction_seed_statements(...)` 保留为兼容测试入口,但内部已改为走新的规则映射。 + +## StatementService 接通方式 +- `StatementService._generate_statements()` 改为优先读取 `FileRecord` 中保存的命中计划。 +- 若存在真实 `FileRecord`,则复用其主体、账号、员工及亲属身份证范围,并把命中计划传给 `build_seed_statements_for_rule_plan(...)`。 +- 命中样本与随机噪声流水继续统一走 `_assign_statement_ids()` 分配稳定 ID。 +- 首次生成后仍缓存固定 `200` 条流水;同一 `logId` 重复分页读取保持结果稳定。 + +## LARGE_PURCHASE_TRANSACTION 单独补数据库基线原因 +- `LARGE_PURCHASE_TRANSACTION` 的真实命中来源是 `ccdi_purchase_transaction`,不依赖 `ccdi_bank_statement`。 +- 为避免伪造银行流水造成业务链路偏移,本次不把该规则塞进 Mock 流水样本。 +- 通过新增 `sql/migration/2026-03-20-lsfx-mock-random-hit-rule-purchase-baseline.sql`,只补一条最小采购记录 `LSFXMOCKPUR001`。 +- 脚本通过 `ccdi_base_staff` 选取一条真实员工主数据作为 `applicant_id/applicant_name` 来源,`actual_amount=186000.00`,满足真实 SQL 的 `>100000` 命中门槛。 + +## 实施结果 +- `FileService -> StatementService -> 缓存分页` 主链路保持不变。 +- 大额交易规则与第一期新增规则均已支持“按 `logId` 稳定随机命中一部分”。 +- `LARGE_PURCHASE_TRANSACTION` 已通过独立数据库基线补齐联调数据来源。 diff --git a/docs/tests/records/2026-03-20-lsfx-mock-random-hit-rule-backend-verification.md b/docs/tests/records/2026-03-20-lsfx-mock-random-hit-rule-backend-verification.md new file mode 100644 index 00000000..322d69cf --- /dev/null +++ b/docs/tests/records/2026-03-20-lsfx-mock-random-hit-rule-backend-verification.md @@ -0,0 +1,70 @@ +# LSFX Mock 随机命中规则后端验证记录 + +## 执行命令 + +```bash +cd lsfx-mock-server +python3 -m pytest tests/test_file_service.py -k "rule_hit_plan or persist_rule_hit_plan" -v +python3 -m pytest tests/test_statement_service.py -k "rule_plan_should_only_include or withdraw_cnt_samples" -v +python3 -m pytest tests/test_statement_service.py -k "follow_rule_hit_plan or fixed_total_count_200 or cached_result" -v +python3 -m pytest tests/integration/test_full_workflow.py -k "same_rule_subset or share_same_primary_binding" -v +python3 -m pytest tests/test_file_service.py tests/test_statement_service.py tests/test_api.py tests/integration/test_full_workflow.py -v + +cd .. +bin/mysql_utf8_exec.sh sql/migration/2026-03-20-lsfx-mock-random-hit-rule-purchase-baseline.sql +python3 - <<'PY' +import pymysql +from pathlib import Path +import re + +text = Path('ruoyi-admin/src/main/resources/application-dev.yml').read_text(encoding='utf-8') +match = re.search(r"url:\s*jdbc:mysql://(?P[^:/?#]+):(?P\d+)/(?P[^?\n]+).*?\n\s*username:\s*(?P[^\n]+)\n\s*password:\s*(?P[^\n]+)", text, re.S) +conn = pymysql.connect( + host=match.group('host'), + port=int(match.group('port')), + user=match.group('user').strip(), + password=match.group('pwd').strip(), + database=match.group('db').strip(), + charset='utf8mb4', + cursorclass=pymysql.cursors.DictCursor, +) +with conn, conn.cursor() as cursor: + cursor.execute(""" + SELECT COUNT(1) AS cnt + FROM ccdi_purchase_transaction + WHERE purchase_id = 'LSFXMOCKPUR001' + AND actual_amount > 100000 + """) + print(cursor.fetchone()['cnt']) +PY +``` + +## 执行时间与结果 +- 2026-03-20 14:50 CST 完成目标回归: + `python3 -m pytest tests/test_file_service.py tests/test_statement_service.py tests/test_api.py tests/integration/test_full_workflow.py -v` +- 回归结果:`38 passed, 20 warnings in 4.20s` +- warnings 为现有 `pydantic` 与 `httpx` 弃用提示,本次改动未新增失败或 error。 + +## SQL 基线脚本执行结果 +- 执行命令:`bin/mysql_utf8_exec.sh sql/migration/2026-03-20-lsfx-mock-random-hit-rule-purchase-baseline.sql` +- 执行结果:脚本执行成功,无报错输出。 +- 脚本内容采用“先删后插”的幂等方式,避免重复执行造成脏数据。 + +## 采购基线查询结果 +- 执行前查询: + `SELECT COUNT(1) FROM ccdi_purchase_transaction WHERE purchase_id = 'LSFXMOCKPUR001'` + 返回 `0` +- 执行后查询: + `SELECT purchase_id, actual_amount, supplier_name FROM ccdi_purchase_transaction WHERE purchase_id = 'LSFXMOCKPUR001'` + 返回: + - `purchase_id = LSFXMOCKPUR001` + - `actual_amount = 186000.00` + - `supplier_name = 兰溪市联调供应链有限公司` +- 最终门槛校验: + `SELECT COUNT(1) ... WHERE purchase_id = 'LSFXMOCKPUR001' AND actual_amount > 100000` + 返回 `1` + +## 是否发现回归 +- 未发现本次改动引入的功能回归。 +- `FileService` 的规则命中计划生成、`statement_rule_samples.py` 的按规则子集装配、`StatementService` 的缓存稳定性、端到端接口链路均已通过验证。 +- 本次验证未启动额外前后端常驻进程,因此无需执行进程清理。 From 91eb46798ed2bd35b89697f52976e9adf3ebb66e Mon Sep 17 00:00:00 2001 From: wkc <978997012@qq.com> Date: Fri, 20 Mar 2026 13:22:26 +0800 Subject: [PATCH 06/19] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E7=AC=AC=E4=B8=80?= =?UTF-8?q?=E6=9C=9F=E6=B5=81=E6=B0=B4=E6=A8=A1=E5=9E=8B=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E6=98=A0=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/BankTagRuleConfigResolver.java | 19 +++-- .../impl/BankTagRuleConfigResolverTest.java | 71 +++++++++++++++++++ 2 files changed, 83 insertions(+), 7 deletions(-) diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/BankTagRuleConfigResolver.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/BankTagRuleConfigResolver.java index 2fdfc90b..0c1edf3b 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/BankTagRuleConfigResolver.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/BankTagRuleConfigResolver.java @@ -24,13 +24,18 @@ import lombok.extern.slf4j.Slf4j; @Component public class BankTagRuleConfigResolver { - private static final Map> RULE_PARAM_MAPPING = Map.of( - "SINGLE_LARGE_INCOME", Set.of("SINGLE_TRANSACTION_AMOUNT"), - "CUMULATIVE_INCOME", Set.of("CUMULATIVE_TRANSACTION_AMOUNT"), - "ANNUAL_TURNOVER", Set.of("ANNUAL_TURNOVER"), - "LARGE_CASH_DEPOSIT", Set.of("LARGE_CASH_DEPOSIT"), - "FREQUENT_CASH_DEPOSIT", Set.of("LARGE_CASH_DEPOSIT", "FREQUENT_CASH_DEPOSIT"), - "LARGE_TRANSFER", Set.of("FREQUENT_TRANSFER") + private static final Map> RULE_PARAM_MAPPING = Map.ofEntries( + Map.entry("SINGLE_LARGE_INCOME", Set.of("SINGLE_TRANSACTION_AMOUNT")), + Map.entry("CUMULATIVE_INCOME", Set.of("CUMULATIVE_TRANSACTION_AMOUNT")), + Map.entry("ANNUAL_TURNOVER", Set.of("ANNUAL_TURNOVER")), + Map.entry("LARGE_CASH_DEPOSIT", Set.of("LARGE_CASH_DEPOSIT")), + Map.entry("FREQUENT_CASH_DEPOSIT", Set.of("LARGE_CASH_DEPOSIT", "FREQUENT_CASH_DEPOSIT")), + Map.entry("LARGE_TRANSFER", Set.of("FREQUENT_TRANSFER")), + Map.entry("FOREX_BUY_AMT", Set.of("SINGLE_PURCHASE_AMOUNT")), + Map.entry("FOREX_SELL_AMT", Set.of("SINGLE_SETTLEMENT_AMOUNT")), + Map.entry("WITHDRAW_CNT", Set.of("WITHDRAW_CNT")), + Map.entry("STOCK_TFR_LARGE", Set.of("STOCK_TFR_LARGE")), + Map.entry("LARGE_STOCK_TRADING", Set.of("STOCK_TFR_LARGE")) ); @Resource diff --git a/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/BankTagRuleConfigResolverTest.java b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/BankTagRuleConfigResolverTest.java index 168d5844..43f20125 100644 --- a/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/BankTagRuleConfigResolverTest.java +++ b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/BankTagRuleConfigResolverTest.java @@ -17,6 +17,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.slf4j.LoggerFactory; import java.util.List; +import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -130,6 +131,38 @@ class BankTagRuleConfigResolverTest { assertEquals("8888", config.getThresholdValue("ANNUAL_TURNOVER")); } + @Test + void resolve_shouldMapPhaseOneThresholdRulesToUppercaseParamCodes() { + CcdiProject project = new CcdiProject(); + project.setProjectId(40L); + project.setConfigType("default"); + when(projectMapper.selectById(40L)).thenReturn(project); + + assertSingleThresholdRule("SUSPICIOUS_FOREIGN_EXCHANGE", "FOREX_BUY_AMT", + "SINGLE_PURCHASE_AMOUNT", "50000"); + assertSingleThresholdRule("SUSPICIOUS_FOREIGN_EXCHANGE", "FOREX_SELL_AMT", + "SINGLE_SETTLEMENT_AMOUNT", "60000"); + assertSingleThresholdRule("ABNORMAL_BEHAVIOR", "WITHDRAW_CNT", + "WITHDRAW_CNT", "3"); + assertSingleThresholdRule("ABNORMAL_BEHAVIOR", "STOCK_TFR_LARGE", + "STOCK_TFR_LARGE", "1000000"); + assertSingleThresholdRule("ABNORMAL_BEHAVIOR", "LARGE_STOCK_TRADING", + "STOCK_TFR_LARGE", "1000000"); + } + + @Test + void resolve_shouldKeepEmptyThresholdsForPhaseOneRulesWithoutParams() { + CcdiProject project = new CcdiProject(); + project.setProjectId(40L); + project.setConfigType("default"); + when(projectMapper.selectById(40L)).thenReturn(project); + + assertRuleHasNoThresholds("SUSPICIOUS_GAMBLING", "GAMBLING_SENSITIVE_KEYWORD"); + assertRuleHasNoThresholds("SUSPICIOUS_RELATION", "SPECIAL_AMOUNT_TRANSACTION"); + assertRuleHasNoThresholds("SUSPICIOUS_PART_TIME", "SUSPICIOUS_INCOME_KEYWORD"); + assertRuleHasNoThresholds("SUSPICIOUS_PURCHASE", "LARGE_PURCHASE_TRANSACTION"); + } + private CcdiModelParam buildParam(String paramCode, String paramValue) { CcdiModelParam param = new CcdiModelParam(); param.setProjectId(0L); @@ -138,4 +171,42 @@ class BankTagRuleConfigResolverTest { param.setParamValue(paramValue); return param; } + + private void assertSingleThresholdRule(String modelCode, String ruleCode, String paramCode, String paramValue) { + when(modelParamMapper.selectByProjectAndModel(0L, modelCode)).thenReturn(List.of( + buildParam(modelCode, paramCode, paramValue) + )); + + CcdiBankTagRule ruleMeta = new CcdiBankTagRule(); + ruleMeta.setModelCode(modelCode); + ruleMeta.setRuleCode(ruleCode); + ruleMeta.setIndicatorCode(paramCode); + + BankTagRuleExecutionConfig config = resolver.resolve(40L, ruleMeta); + + assertEquals(Map.of(paramCode, paramValue), config.getThresholdValues()); + } + + private void assertRuleHasNoThresholds(String modelCode, String ruleCode) { + when(modelParamMapper.selectByProjectAndModel(0L, modelCode)).thenReturn(List.of( + buildParam(modelCode, "IGNORED_PARAM", "999") + )); + + CcdiBankTagRule ruleMeta = new CcdiBankTagRule(); + ruleMeta.setModelCode(modelCode); + ruleMeta.setRuleCode(ruleCode); + + BankTagRuleExecutionConfig config = resolver.resolve(40L, ruleMeta); + + assertTrue(config.getThresholdValues().isEmpty()); + } + + private CcdiModelParam buildParam(String modelCode, String paramCode, String paramValue) { + CcdiModelParam param = new CcdiModelParam(); + param.setProjectId(0L); + param.setModelCode(modelCode); + param.setParamCode(paramCode); + param.setParamValue(paramValue); + return param; + } } From 7d943f96cc73e93d528d3dcddbf24aefa420c4d8 Mon Sep 17 00:00:00 2001 From: wkc <978997012@qq.com> Date: Fri, 20 Mar 2026 13:26:41 +0800 Subject: [PATCH 07/19] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E7=AC=AC=E4=B8=80?= =?UTF-8?q?=E6=9C=9F=E6=B5=81=E6=B0=B4=E6=98=8E=E7=BB=86=E8=A7=84=E5=88=99?= =?UTF-8?q?=E7=9C=9F=E5=AE=9ESQL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mapper/CcdiBankTagAnalysisMapper.java | 16 +- .../service/impl/CcdiBankTagServiceImpl.java | 16 +- .../project/CcdiBankTagAnalysisMapper.xml | 146 +++++++++++++++--- .../CcdiBankTagAnalysisMapperXmlTest.java | 47 ++++-- 4 files changed, 188 insertions(+), 37 deletions(-) diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapper.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapper.java index 5e78ef3a..9e44654f 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapper.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapper.java @@ -190,17 +190,21 @@ public interface CcdiBankTagAnalysisMapper { * 单笔购汇金额超限 * * @param projectId 项目ID + * @param threshold 单笔购汇阈值 * @return 流水命中结果 */ - List selectForexBuyAmtStatements(@Param("projectId") Long projectId); + List selectForexBuyAmtStatements(@Param("projectId") Long projectId, + @Param("threshold") BigDecimal threshold); /** * 单笔结汇金额超限 * * @param projectId 项目ID + * @param threshold 单笔结汇阈值 * @return 流水命中结果 */ - List selectForexSellAmtStatements(@Param("projectId") Long projectId); + List selectForexSellAmtStatements(@Param("projectId") Long projectId, + @Param("threshold") BigDecimal threshold); /** * 单笔跨境汇款金额超限 @@ -238,9 +242,11 @@ public interface CcdiBankTagAnalysisMapper { * 可疑银证大额转账 * * @param projectId 项目ID + * @param threshold 银证转账阈值 * @return 流水命中结果 */ - List selectStockTfrLargeStatements(@Param("projectId") Long projectId); + List selectStockTfrLargeStatements(@Param("projectId") Long projectId, + @Param("threshold") BigDecimal threshold); /** * 微信支付宝频繁提现 @@ -278,9 +284,11 @@ public interface CcdiBankTagAnalysisMapper { * 大额炒股 * * @param projectId 项目ID + * @param threshold 三方资管交易阈值 * @return 流水命中结果 */ - List selectLargeStockTradingStatements(@Param("projectId") Long projectId); + List selectLargeStockTradingStatements(@Param("projectId") Long projectId, + @Param("threshold") BigDecimal threshold); /** * 疑似代理他人账户 diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImpl.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImpl.java index f0fae6da..9e266833 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImpl.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImpl.java @@ -233,12 +233,20 @@ public class CcdiBankTagServiceImpl implements ICcdiBankTagService { case "PROPERTY_FEE_REGISTRATION_MISMATCH" -> analysisMapper.selectPropertyFeeRegistrationMismatchStatements(projectId); case "TAX_ASSET_REGISTRATION_MISMATCH" -> analysisMapper.selectTaxAssetRegistrationMismatchStatements(projectId); case "INCOME_ASSET_MISMATCH" -> analysisMapper.selectIncomeAssetMismatchStatements(projectId); - case "FOREX_BUY_AMT" -> analysisMapper.selectForexBuyAmtStatements(projectId); - case "FOREX_SELL_AMT" -> analysisMapper.selectForexSellAmtStatements(projectId); + case "FOREX_BUY_AMT" -> analysisMapper.selectForexBuyAmtStatements( + projectId, toBigDecimal(config.getThresholdValue("SINGLE_PURCHASE_AMOUNT")) + ); + case "FOREX_SELL_AMT" -> analysisMapper.selectForexSellAmtStatements( + projectId, toBigDecimal(config.getThresholdValue("SINGLE_SETTLEMENT_AMOUNT")) + ); case "CROSS_BORDER_AMT" -> analysisMapper.selectCrossBorderAmtStatements(projectId); case "LARGE_PURCHASE_TRANSACTION" -> analysisMapper.selectLargePurchaseTransactionStatements(projectId); - case "STOCK_TFR_LARGE" -> analysisMapper.selectStockTfrLargeStatements(projectId); - case "LARGE_STOCK_TRADING" -> analysisMapper.selectLargeStockTradingStatements(projectId); + case "STOCK_TFR_LARGE" -> analysisMapper.selectStockTfrLargeStatements( + projectId, toBigDecimal(config.getThresholdValue("STOCK_TFR_LARGE")) + ); + case "LARGE_STOCK_TRADING" -> analysisMapper.selectLargeStockTradingStatements( + projectId, toBigDecimal(config.getThresholdValue("STOCK_TFR_LARGE")) + ); default -> List.of(); }; } diff --git a/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml b/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml index b930686c..dd525fce 100644 --- a/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml +++ b/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml @@ -396,9 +396,19 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" bs.bank_statement_id AS bankStatementId, bs.group_id AS groupId, bs.batch_id AS logId, - '占位SQL,待补充真实规则' AS reasonDetail + CONCAT( + '摘要/对手命中赌博敏感词,摘要“', IFNULL(bs.USER_MEMO, ''), + '”,对手方“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''), + '”,支出金额 ', CAST(IFNULL(bs.AMOUNT_DR, 0) AS CHAR), ' 元' + ) AS reasonDetail from ccdi_bank_statement bs - where 1 = 0 + inner join ccdi_base_staff staff on staff.id_card = bs.cret_no + where bs.project_id = #{projectId} + and IFNULL(bs.AMOUNT_DR, 0) > 0 + and ( + IFNULL(bs.USER_MEMO, '') REGEXP '游戏|抖币|体彩|福彩|彩票|赌|球|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|捕鱼|电子游艺|投注' + or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '游戏|抖币|体彩|福彩|彩票|赌|球|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|捕鱼|电子游艺|投注' + ) select - bs.bank_statement_id AS bankStatementId, - bs.group_id AS groupId, - bs.batch_id AS logId, - '占位SQL,待补充真实规则' AS reasonDetail - from ccdi_bank_statement bs - where 1 = 0 + CAST(NULL AS SIGNED) AS bankStatementId, + CAST(NULL AS SIGNED) AS groupId, + CAST(NULL AS SIGNED) AS logId, + CONCAT( + '采购事项“', IFNULL(t.subjectName, ''), + '”实际采购金额 ', CAST(IFNULL(t.actualAmount, 0) AS CHAR), + ' 元,供应商“', IFNULL(t.supplierName, ''), '”' + ) AS reasonDetail + from ( + select distinct + pt.purchase_id AS purchaseId, + pt.subject_name AS subjectName, + pt.supplier_name AS supplierName, + pt.actual_amount AS actualAmount + from ccdi_purchase_transaction pt + inner join ccdi_base_staff staff + on CAST(staff.staff_id AS CHAR) = pt.applicant_id + where IFNULL(pt.actual_amount, 0) > 100000 + union + select distinct + pt.purchase_id AS purchaseId, + pt.subject_name AS subjectName, + pt.supplier_name AS supplierName, + pt.actual_amount AS actualAmount + from ccdi_purchase_transaction pt + inner join ccdi_base_staff staff + on CAST(staff.staff_id AS CHAR) = pt.purchase_leader_id + where pt.purchase_leader_id is not null + and IFNULL(pt.actual_amount, 0) > 100000 + ) t select 'STAFF_ID_CARD' AS objectType, - '' AS objectKey, - '占位SQL,待补充真实规则' AS reasonDetail - from ccdi_bank_statement bs - where 1 = 0 + t.objectKey AS objectKey, + CONCAT( + '单日微信/支付宝提现 ', CAST(t.withdrawCount AS CHAR), + ' 次,超过阈值 ', CAST(#{frequencyThreshold} AS CHAR), + ' 次,交易日:', t.transDate + ) AS reasonDetail + from ( + select + staff.id_card AS objectKey, + LEFT(TRIM(bs.TRX_DATE), 10) AS transDate, + COUNT(1) AS withdrawCount + from ccdi_bank_statement bs + inner join ccdi_base_staff staff on staff.id_card = bs.cret_no + where bs.project_id = #{projectId} + and IFNULL(bs.AMOUNT_CR, 0) >= 0 + and ( + IFNULL(bs.USER_MEMO, '') REGEXP '财付通|微信零钱|微信|wechat|WeChat|Tenpay|支付宝|Alipay|提现' + or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '财付通|微信零钱|微信|wechat|WeChat|Tenpay|支付宝|Alipay|提现' + ) + group by staff.id_card, LEFT(TRIM(bs.TRX_DATE), 10) + having COUNT(1) > #{frequencyThreshold} + ) t diff --git a/ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiBankStatementMapperXmlTest.java b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiBankStatementMapperXmlTest.java index f1ba30ec..d1dd33ae 100644 --- a/ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiBankStatementMapperXmlTest.java +++ b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiBankStatementMapperXmlTest.java @@ -121,7 +121,10 @@ class CcdiBankStatementMapperXmlTest { String xml = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); assertTrue( - xml.contains("LEFT JOIN ccdi_file_upload_record fur ON fur.log_id = bs.batch_id AND fur.project_id = bs.project_id"), + xml.contains("LEFT JOIN (") + && xml.contains("SELECT latest_record.project_id, latest_record.log_id, latest_record.file_name, latest_record.upload_time") + && xml.contains("MAX(id) AS max_id") + && xml.contains("fur.log_id = bs.batch_id AND fur.project_id = bs.project_id"), xml ); assertTrue(xml.contains("fur.file_name AS originalFileName"), xml); diff --git a/ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiBankTagRuleSqlMetadataTest.java b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiBankTagRuleSqlMetadataTest.java new file mode 100644 index 00000000..0d8c3906 --- /dev/null +++ b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiBankTagRuleSqlMetadataTest.java @@ -0,0 +1,48 @@ +package com.ruoyi.ccdi.project.sql; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CcdiBankTagRuleSqlMetadataTest { + + @Test + void phase1MetadataSql_shouldAlignInitAndMigrationScripts() throws IOException { + String initSql = readProjectFile("sql", "2026-03-16-bank-tagging.sql"); + String migrationSql = readProjectFile("sql", "migration", "2026-03-20-sync-bank-tag-phase1-rule-metadata.sql"); + + assertPhase1Metadata(initSql); + assertPhase1Metadata(migrationSql); + } + + private void assertPhase1Metadata(String sqlContent) { + assertAll( + () -> assertTrue(sqlContent.contains("'FOREX_BUY_AMT'") + && sqlContent.contains("'SINGLE_PURCHASE_AMOUNT'"), + "FOREX_BUY_AMT 应使用 SINGLE_PURCHASE_AMOUNT"), + () -> assertTrue(sqlContent.contains("'FOREX_SELL_AMT'") + && sqlContent.contains("'SINGLE_SETTLEMENT_AMOUNT'"), + "FOREX_SELL_AMT 应使用 SINGLE_SETTLEMENT_AMOUNT"), + () -> assertTrue(sqlContent.contains("'LARGE_STOCK_TRADING'") + && sqlContent.contains("'STOCK_TFR_LARGE'"), + "LARGE_STOCK_TRADING 应使用 STOCK_TFR_LARGE"), + () -> assertTrue(sqlContent.contains("真实规则:识别单笔购汇金额超过阈值的流水"), + "应同步 FOREX_BUY_AMT 的真实规则说明"), + () -> assertTrue(sqlContent.contains("真实规则:识别单笔结汇金额超过阈值的流水"), + "应同步 FOREX_SELL_AMT 的真实规则说明"), + () -> assertTrue(sqlContent.contains("真实规则:识别单笔三方资管交易金额超过阈值的流水"), + "应同步 LARGE_STOCK_TRADING 的真实规则说明") + ); + } + + private String readProjectFile(String... parts) throws IOException { + Path path = Path.of("..", parts); + return Files.readString(path, StandardCharsets.UTF_8); + } +} diff --git a/docs/reports/implementation/2026-03-20-bank-tag-new-model-validation-record.md b/docs/reports/implementation/2026-03-20-bank-tag-new-model-validation-record.md index 7057e00e..705a6ac6 100644 --- a/docs/reports/implementation/2026-03-20-bank-tag-new-model-validation-record.md +++ b/docs/reports/implementation/2026-03-20-bank-tag-new-model-validation-record.md @@ -38,3 +38,18 @@ ## 执行说明 - 验证过程中若任一层失败,立即停在对应层记录证据,不继续给出“验证通过”结论。 - 本次执行基于当前本地开发环境,不额外引入修复或扩展范围。 + +## 当前进展 +- 2026-03-20 15:21:54 CST 完成阶段 1:已对齐验证范围、读取来源实施记录、选定 `project_id=47`,并创建实施记录与验证记录骨架。 +- 2026-03-20 15:21:54 CST 完成阶段 2:`lsfx-mock-server` 聚焦回归与全量回归全部通过,确认规则命中计划、样本装配、缓存稳定性与集成链路未回退。 +- 2026-03-20 15:23:10 CST 完成阶段 3:`ccdi-project` 第一期真实规则目标测试全部 `BUILD SUCCESS`,规则映射、真实 SQL、规则分发与风险人数刷新链路保持通过。 +- 2026-03-20 15:24 左右执行阶段 4:采购基线脚本成功重跑,`LSFXMOCKPUR001` 基线记录存在且金额满足门槛;但第一期规则元数据查询发现 `indicator_code` 与既有实施记录不一致,判定为“数据基线异常”,按计划停在数据库核验层,不继续执行接口端到端验证。 +- 2026-03-20 15:41:06 CST 完成问题修复与复验: + - 已新增第一期规则元数据 SQL 校验测试与增量修复脚本。 + - 已将修复脚本落库,确认 `FOREX_BUY_AMT`、`FOREX_SELL_AMT`、`LARGE_STOCK_TRADING` 的 `indicator_code` 与 9 条一期真实规则 `remark` 均已对齐。 + - 已完成项目 `47` 的拉取本行信息、手动重算、任务轮询、命中结果查询与流水详情接口复验。 + - Mock 与后端验证进程均已关闭。 +- 2026-03-20 16:01 左右完成补充复验: + - 重新启动 Mock 与后端服务,复跑项目 `47` 的登录、拉取本行信息、手动重算、任务轮询与详情接口链路。 + - 自动任务 `id=39` 与手动任务 `id=40` 均执行成功,`hit_count=3636`,`success_rule_count=33`,`failed_rule_count=0`。 + - 针对之前出现 `selectOne()` 重复结果异常的样例 `bank_statement_id=67279`,详情接口已返回 `code=200`,并正确带出 `GAMBLING_SENSITIVE_KEYWORD` 命中标签与原始文件名。 diff --git a/docs/reports/implementation/2026-03-20-bank-tag-phase1-rule-metadata-fix-record.md b/docs/reports/implementation/2026-03-20-bank-tag-phase1-rule-metadata-fix-record.md new file mode 100644 index 00000000..2bb1e9cb --- /dev/null +++ b/docs/reports/implementation/2026-03-20-bank-tag-phase1-rule-metadata-fix-record.md @@ -0,0 +1,32 @@ +# 第一期银行流水规则元数据修复实施记录 + +## 问题背景 +- 2026-03-20 新增模型打标完整验证在数据库核验阶段发现: + - `FOREX_BUY_AMT.indicator_code` 仍为 `FOREX_BUY_AMT` + - `FOREX_SELL_AMT.indicator_code` 仍为 `FOREX_SELL_AMT` + - `LARGE_STOCK_TRADING.indicator_code` 为 `NULL` +- 同时,第一期已落地真实规则的 `remark` 仍停留在“占位规则,待补充真实SQL”。 + +## 根因分析 +- 主初始化脚本 [`sql/2026-03-16-bank-tagging.sql`](/Users/wkc/Desktop/ccdi/ccdi/sql/2026-03-16-bank-tagging.sql) 已包含第一期真实规则的正确元数据。 +- 老增量脚本 [`sql/migration/2026-03-18-sync-bank-tag-uppercase-and-rules.sql`](/Users/wkc/Desktop/ccdi/ccdi/sql/migration/2026-03-18-sync-bank-tag-uppercase-and-rules.sql) 仍写入旧的占位元数据。 +- 已执行过 2026-03-18 增量脚本、但未补后续迁移的环境,会停留在旧的 `indicator_code` 与 `remark` 状态。 + +## 本次修改 +- 新增 SQL 资产校验测试 [`ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiBankTagRuleSqlMetadataTest.java`](/Users/wkc/Desktop/ccdi/ccdi/ccdi-project/src/test/java/com/ruoyi/ccdi/project/sql/CcdiBankTagRuleSqlMetadataTest.java) + - 先以缺失迁移脚本的红灯方式固定问题。 + - 约束初始化脚本与增量脚本必须同时对齐: + - `FOREX_BUY_AMT -> SINGLE_PURCHASE_AMOUNT` + - `FOREX_SELL_AMT -> SINGLE_SETTLEMENT_AMOUNT` + - `LARGE_STOCK_TRADING -> STOCK_TFR_LARGE` + - 三条规则真实说明文案保持一致。 +- 新增增量脚本 [`sql/migration/2026-03-20-sync-bank-tag-phase1-rule-metadata.sql`](/Users/wkc/Desktop/ccdi/ccdi/sql/migration/2026-03-20-sync-bank-tag-phase1-rule-metadata.sql) + - 使用 `INSERT ... ON DUPLICATE KEY UPDATE` 同步第一期 9 条真实规则元数据。 + - 修复三条规则的 `indicator_code`。 + - 同步 9 条规则的真实规则 `remark`。 +- 将增量脚本通过 `bin/mysql_utf8_exec.sh` 落到当前验证数据库。 + +## 实施结果 +- 规则元数据已对齐到第一期真实规则状态。 +- 新增 SQL 校验测试可在仓库层拦住“只改初始化脚本、遗漏增量脚本”的回归。 +- 修复后重新完成接口链路复验,项目 `47` 的自动拉取、手动重算、命中结果查询与详情接口均已通过。 diff --git a/docs/reports/implementation/2026-03-20-mock-random-logid-fix-record.md b/docs/reports/implementation/2026-03-20-mock-random-logid-fix-record.md new file mode 100644 index 00000000..2dd9c925 --- /dev/null +++ b/docs/reports/implementation/2026-03-20-mock-random-logid-fix-record.md @@ -0,0 +1,28 @@ +# Mock 服务随机 logId 实施记录 + +## 问题背景 +- 2026-03-20 联调过程中,`lsfx-mock-server` 的 `logId` 仍使用进程内递增方式分配。 +- 仓库文档与接口预期要求 Mock 返回随机 `logId`,避免联调时对顺序值形成隐式依赖。 + +## 根因分析 +- [`lsfx-mock-server/services/file_service.py`](/Users/wkc/Desktop/ccdi/ccdi/lsfx-mock-server/services/file_service.py) 中,`upload_file()` 与 `fetch_inner_flow()` 都直接通过 `self.log_counter += 1` 生成 `logId`。 +- 现有测试只覆盖了 `logId` 落在 `10000-99999` 区间内,没有约束“冲突时需要重试并避让已有记录”。 + +## 本次修改 +- 在 [`lsfx-mock-server/tests/test_file_service.py`](/Users/wkc/Desktop/ccdi/ccdi/lsfx-mock-server/tests/test_file_service.py) 先新增红灯测试 `test_generate_log_id_should_retry_when_random_value_conflicts`。 + - 固定随机值第一次命中已存在 `logId` 时必须重试。 + - 同步把行内流水测试中的旧递增断言改为随机区间断言。 +- 在 [`lsfx-mock-server/services/file_service.py`](/Users/wkc/Desktop/ccdi/ccdi/lsfx-mock-server/services/file_service.py) 新增统一 `_generate_log_id()`。 + - 在 `10000-99999` 区间内随机生成。 + - 若命中 `file_records` 中已存在的 `logId`,则继续重试直到拿到未占用值。 + - `upload_file()` 与 `fetch_inner_flow()` 均切换为调用该方法。 + +## 验证结果 +- `python3 -m pytest lsfx-mock-server/tests/test_file_service.py -k "fetch_inner_flow_persists_primary_binding_record or generate_log_id_should_retry_when_random_value_conflicts" -v` + - 结果:`2 passed` +- `python3 -m pytest lsfx-mock-server/tests/test_file_service.py lsfx-mock-server/tests/test_statement_service.py lsfx-mock-server/tests/test_api.py lsfx-mock-server/tests/integration/test_full_workflow.py -v` + - 结果:`39 passed, 20 warnings` + +## 实施结果 +- Mock 服务的新建上传记录与行内流水记录已改为随机 `logId`。 +- 同一 `logId` 下的规则命中计划、流水样本与上传状态复用逻辑保持不变。 diff --git a/docs/tests/records/2026-03-20-bank-tag-new-model-validation-verification.md b/docs/tests/records/2026-03-20-bank-tag-new-model-validation-verification.md index f08cde19..f443c101 100644 --- a/docs/tests/records/2026-03-20-bank-tag-new-model-validation-verification.md +++ b/docs/tests/records/2026-03-20-bank-tag-new-model-validation-verification.md @@ -1,16 +1,224 @@ # 新增模型打标完整验证记录 ## 执行命令 -- 待补充本次实际执行的 pytest、Maven、SQL、curl 与 Python 核验命令。 +```bash +cd lsfx-mock-server +python3 -m pytest tests/test_file_service.py -k "rule_hit_plan or persist_rule_hit_plan" -v +python3 -m pytest tests/test_statement_service.py -k "rule_plan_should_only_include or withdraw_cnt_samples" -v +python3 -m pytest tests/test_statement_service.py -k "follow_rule_hit_plan or fixed_total_count_200 or cached_result" -v +python3 -m pytest tests/integration/test_full_workflow.py -k "same_rule_subset or share_same_primary_binding" -v +python3 -m pytest tests/test_file_service.py tests/test_statement_service.py tests/test_api.py tests/integration/test_full_workflow.py -v + +cd .. +mvn test -pl ccdi-project -Dtest=BankTagRuleConfigResolverTest +mvn test -pl ccdi-project -Dtest=CcdiBankTagAnalysisMapperXmlTest +mvn test -pl ccdi-project -Dtest=CcdiBankTagAnalysisMapperXmlTest,CcdiBankTagServiceImplTest +mvn test -pl ccdi-project -Dtest=CcdiBankTagAnalysisMapperXmlTest,BankTagRuleConfigResolverTest,CcdiBankTagServiceImplTest,CcdiBankTagServiceRiskCountRefreshTest +``` + +## Mock 自动化结果 +- 2026-03-20 15:21:54 CST 完成 Mock 聚焦回归与全量回归。 +- 聚焦回归结果: + - `tests/test_file_service.py -k "rule_hit_plan or persist_rule_hit_plan"`: `2 passed, 4 deselected, 1 warning` + - `tests/test_statement_service.py -k "rule_plan_should_only_include or withdraw_cnt_samples"`: `2 passed, 11 deselected, 1 warning` + - `tests/test_statement_service.py -k "follow_rule_hit_plan or fixed_total_count_200 or cached_result"`: `3 passed, 10 deselected, 1 warning` + - `tests/integration/test_full_workflow.py -k "same_rule_subset or share_same_primary_binding"`: `2 passed, 3 deselected, 3 warnings` +- 全量回归结果: + - `python3 -m pytest tests/test_file_service.py tests/test_statement_service.py tests/test_api.py tests/integration/test_full_workflow.py -v` + - 摘要:`38 passed, 20 warnings in 4.15s` +- warning 摘要: + - `pydantic` 的 class-based config 弃用提示仍存在。 + - `httpx` 的 `app` shortcut 弃用提示仍存在。 + - 两类 warning 与既有 Mock 验证记录一致,本次未新增 failure 或 error。 + +## 主工程自动化结果 +- 2026-03-20 15:22:27 CST 执行 `mvn test -pl ccdi-project -Dtest=BankTagRuleConfigResolverTest`,结果 `BUILD SUCCESS`,`Tests run: 6, Failures: 0, Errors: 0, Skipped: 0`。 +- 2026-03-20 15:22:47 CST 执行 `mvn test -pl ccdi-project -Dtest=CcdiBankTagAnalysisMapperXmlTest`,结果 `BUILD SUCCESS`,`Tests run: 8, Failures: 0, Errors: 0, Skipped: 0`。 +- 2026-03-20 15:22:57 CST 执行 `mvn test -pl ccdi-project -Dtest=CcdiBankTagAnalysisMapperXmlTest,CcdiBankTagServiceImplTest`,结果 `BUILD SUCCESS`,`Tests run: 19, Failures: 0, Errors: 0, Skipped: 0`。 +- 2026-03-20 15:23:10 CST 执行 `mvn test -pl ccdi-project -Dtest=CcdiBankTagAnalysisMapperXmlTest,BankTagRuleConfigResolverTest,CcdiBankTagServiceImplTest,CcdiBankTagServiceRiskCountRefreshTest`,结果 `BUILD SUCCESS`,`Tests run: 27, Failures: 0, Errors: 0, Skipped: 0`。 +- 结果归纳: + - `BankTagRuleConfigResolverTest` 证明第一期规则参数映射保持通过。 + - `CcdiBankTagAnalysisMapperXmlTest` 证明真实 SQL 结构保持通过。 + - `CcdiBankTagServiceImplTest` 证明规则分发和异常路径断言保持通过。 + - `CcdiBankTagServiceRiskCountRefreshTest` 证明风险人数刷新链路保持通过。 +- 日志说明: + - 测试日志中的 `threshold missing` 与 `refresh failed` 为异常路径断言场景产生的预期日志,不代表本轮 Maven 回归失败。 ## 数据库核验 -- 待补充采购基线、规则元数据、任务状态与命中结果查询。 +```bash +bin/mysql_utf8_exec.sh sql/migration/2026-03-20-lsfx-mock-random-hit-rule-purchase-baseline.sql + +python3 - <<'PY' +from pathlib import Path +import pymysql, re + +text = Path('ruoyi-admin/src/main/resources/application-dev.yml').read_text(encoding='utf-8') +match = re.search(r"url:\s*jdbc:mysql://(?P[^:/?#]+):(?P\d+)/(?P[^?\n]+).*?\n\s*username:\s*(?P[^\n]+)\n\s*password:\s*(?P[^\n]+)", text, re.S) +conn = pymysql.connect( + host=match.group('host'), + port=int(match.group('port')), + user=match.group('user').strip(), + password=match.group('pwd').strip(), + database=match.group('db').strip(), + charset='utf8mb4', + cursorclass=pymysql.cursors.DictCursor, +) +with conn, conn.cursor() as cursor: + cursor.execute(""" + SELECT purchase_id, actual_amount, supplier_name + FROM ccdi_purchase_transaction + WHERE purchase_id = 'LSFXMOCKPUR001' + AND actual_amount > 100000 + """) + print(cursor.fetchone()) +PY + +python3 - <<'PY' +from pathlib import Path +import pymysql, re + +TARGET_RULES = ( + 'GAMBLING_SENSITIVE_KEYWORD','SPECIAL_AMOUNT_TRANSACTION','SUSPICIOUS_INCOME_KEYWORD', + 'FOREX_BUY_AMT','FOREX_SELL_AMT','LARGE_PURCHASE_TRANSACTION', + 'STOCK_TFR_LARGE','WITHDRAW_CNT','LARGE_STOCK_TRADING' +) + +text = Path('ruoyi-admin/src/main/resources/application-dev.yml').read_text(encoding='utf-8') +match = re.search(r"url:\s*jdbc:mysql://(?P[^:/?#]+):(?P\d+)/(?P[^?\n]+).*?\n\s*username:\s*(?P[^\n]+)\n\s*password:\s*(?P[^\n]+)", text, re.S) +conn = pymysql.connect( + host=match.group('host'), + port=int(match.group('port')), + user=match.group('user').strip(), + password=match.group('pwd').strip(), + database=match.group('db').strip(), + charset='utf8mb4', + cursorclass=pymysql.cursors.DictCursor, +) +sql = f""" +SELECT model_code, rule_code, indicator_code +FROM ccdi_bank_tag_rule +WHERE rule_code IN ({','.join(['%s'] * len(TARGET_RULES))}) +ORDER BY model_code, sort_order, rule_code +""" +with conn, conn.cursor() as cursor: + cursor.execute(sql, TARGET_RULES) + for row in cursor.fetchall(): + print(row) +PY +``` + +- 采购基线脚本执行结果: + - `bin/mysql_utf8_exec.sh sql/migration/2026-03-20-lsfx-mock-random-hit-rule-purchase-baseline.sql` 执行成功,无报错、无乱码输出。 +- 采购基线查询结果: + - 返回 `{'purchase_id': 'LSFXMOCKPUR001', 'actual_amount': Decimal('186000.00'), 'supplier_name': '兰溪市联调供应链有限公司'}` + - 结论:`LSFXMOCKPUR001` 存在,且 `actual_amount > 100000`,采购基线正常。 +- 规则元数据查询结果: + - 共查询到 9 条目标规则,`rule_code` 均存在。 + - 返回摘要: + - `STOCK_TFR_LARGE -> indicator_code=STOCK_TFR_LARGE` + - `WITHDRAW_CNT -> indicator_code=WITHDRAW_CNT` + - `LARGE_STOCK_TRADING -> indicator_code=NULL` + - `FOREX_BUY_AMT -> indicator_code=FOREX_BUY_AMT` + - `FOREX_SELL_AMT -> indicator_code=FOREX_SELL_AMT` + - 其余 4 条规则 `indicator_code=NULL` +- 异常判定: + - 根据既有实施记录,`FOREX_BUY_AMT` 预期应对齐为 `SINGLE_PURCHASE_AMOUNT`。 + - `FOREX_SELL_AMT` 预期应对齐为 `SINGLE_SETTLEMENT_AMOUNT`。 + - `LARGE_STOCK_TRADING` 预期应对齐为 `STOCK_TFR_LARGE`,当前查询为 `NULL`。 + - 首次执行因此在数据库层判定为“数据基线异常”。 +- 修复后复验: + - 已执行 `bin/mysql_utf8_exec.sh sql/migration/2026-03-20-sync-bank-tag-phase1-rule-metadata.sql` + - 修复后查询结果: + - `FOREX_BUY_AMT -> indicator_code=SINGLE_PURCHASE_AMOUNT` + - `FOREX_SELL_AMT -> indicator_code=SINGLE_SETTLEMENT_AMOUNT` + - `LARGE_STOCK_TRADING -> indicator_code=STOCK_TFR_LARGE` + - 9 条一期真实规则 `remark` 均已同步为真实规则说明 + - 结论:数据库元数据异常已修复,可继续进入接口端到端验证。 ## 接口验证 -- 待补充登录、拉取本行信息、手动重算、流水详情回查与结果摘要。 +```bash +curl -s http://localhost:62318/login/test \ + -H 'Content-Type: application/json' \ + -d '{"username":"admin","password":"admin123"}' + +python3 - <<'PY' +# 读取 3 个有效身份证号并生成 /tmp/bank-tag-pull-request.json +PY + +curl -s http://localhost:62318/ccdi/file-upload/pull-bank-info \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' \ + --data-binary @/tmp/bank-tag-pull-request.json + +curl -s http://localhost:62318/ccdi/project/tags/rebuild \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"projectId":47,"modelCode":null}' + +python3 - <<'PY' +# 轮询 ccdi_bank_tag_task 并查询目标规则命中结果 +PY + +curl -s "http://localhost:62318/ccdi/project/bank-statement/detail/66679" \ + -H "Authorization: Bearer $TOKEN" +``` + +- 登录结果: + - 返回 `code=200`,token 非空。 +- 拉取本行信息结果: + - 选择身份证号:`558455197203132040`、`523342199111246421`、`38056420050404632X` + - 接口返回 `{"msg":"拉取任务已提交","code":200,...}` + - 自动触发任务 `id=36`,`trigger_type=AUTO_PULL_BANK_INFO`,状态 `SUCCESS`。 +- 手动重算结果: + - 首次调用命中项目级重算锁,返回“当前项目标签正在重算中,请稍后再试”。 + - 自动拉取任务完成后再次调用,返回 `{"msg":"标签重算任务已提交","code":200}`。 + - 最新任务 `id=37`,状态 `SUCCESS`,`hit_count=3481`,`success_rule_count=33`,`failed_rule_count=0`。 +- 命中结果查询: + - 已查到目标规则命中,包括: + - `WITHDRAW_CNT` + - `GAMBLING_SENSITIVE_KEYWORD` + - `LARGE_PURCHASE_TRANSACTION` + - 样例明细: + - `rule_code=GAMBLING_SENSITIVE_KEYWORD` + - `bank_statement_id=66679` + - `reason_detail=摘要/对手命中赌博敏感词,摘要“游戏充值”,对手方“欢乐游戏科技有限公司”,支出金额 6888.00 元` +- 详情接口回查: + - `GET /ccdi/project/bank-statement/detail/66679` 返回 `code=200` + - `data.hitTags` 中包含 `GAMBLING_SENSITIVE_KEYWORD` + +## 补充复验 +- 2026-03-20 16:01 左右,基于修复后的详情查询 SQL 再次执行项目 `47` 端到端链路验证。 +- 登录结果: + - `POST /login/test` 返回 `code=200`,token 非空。 +- 拉取本行信息结果: + - 仍使用身份证号 `558455197203132040`、`523342199111246421`、`38056420050404632X` + - `POST /ccdi/file-upload/pull-bank-info` 返回 `{"msg":"拉取任务已提交","code":200,...}` + - 自动触发任务 `id=39`,`trigger_type=AUTO_PULL_BANK_INFO`,状态 `SUCCESS` + - `hit_count=3636`,`success_rule_count=33`,`failed_rule_count=0` +- 手动重算结果: + - `POST /ccdi/project/tags/rebuild` 直接返回 `{"msg":"标签重算任务已提交","code":200}` + - 最新任务 `id=40`,`trigger_type=MANUAL`,状态 `SUCCESS` + - `hit_count=3636`,`success_rule_count=33`,`failed_rule_count=0` +- 命中样例回查: + - 最新 `GAMBLING_SENSITIVE_KEYWORD` 命中样例为 `bank_statement_id=67279` + - `reason_detail=摘要/对手命中赌博敏感词,摘要“游戏充值”,对手方“欢乐游戏科技有限公司”,支出金额 6888.00 元` +- 详情接口回查: + - `GET /ccdi/project/bank-statement/detail/67279` 返回 `code=200` + - 返回结果包含 `originalFileName=558455197203132040_10001.csv` + - `data.hitTags` 中包含 `GAMBLING_SENSITIVE_KEYWORD` ## 结论 -- 待本次验证全部执行完成后补充。 +- 首次执行在数据库核验阶段发现第一期规则元数据异常,问题已定位并修复。 +- 修复后重新验证结果如下: + - Mock 自动化回归通过。 + - 主工程第一期真实规则自动化回归通过。 + - 数据库采购基线与第一期规则元数据核验通过。 + - 项目 `47` 的自动拉取、手动重算、规则命中查询与详情接口回查通过。 + - 补充复验确认:重复上传记录场景下,流水详情接口已不再出现 `selectOne()` 结果重复异常。 +- 最终结论:本次“新增模型打标完整验证”在修复元数据缺口后已通过。 ## 环境清理 -- 待补充本次验证启动的 Mock 与后端进程清理结果。 +- 已停止本次复验启动的 Mock 服务与后端 Jar 服务。 +- 端口复核结果: + - `62318` 无监听进程 + - `8000` 无监听进程 diff --git a/lsfx-mock-server/services/file_service.py b/lsfx-mock-server/services/file_service.py index ec13b0f3..9b42389c 100644 --- a/lsfx-mock-server/services/file_service.py +++ b/lsfx-mock-server/services/file_service.py @@ -94,6 +94,8 @@ class FileService: """文件上传和解析服务""" INNER_FLOW_TOTAL_RECORDS = 200 + LOG_ID_MIN = settings.INITIAL_LOG_ID + LOG_ID_MAX = 99999 def __init__(self, staff_identity_repository=None): self.file_records: Dict[int, FileRecord] = {} # logId -> FileRecord @@ -104,6 +106,18 @@ class FileService: """按 logId 获取已存在的文件记录。""" return self.file_records.get(log_id) + def _generate_log_id(self) -> int: + """生成当前进程内未占用的随机 logId。""" + available_capacity = self.LOG_ID_MAX - self.LOG_ID_MIN + 1 + if len(self.file_records) >= available_capacity: + raise RuntimeError("可用 logId 已耗尽") + + while True: + candidate = random.randint(self.LOG_ID_MIN, self.LOG_ID_MAX) + if candidate not in self.file_records: + self.log_counter = candidate + return candidate + def _infer_bank_name(self, filename: str) -> tuple: """根据文件名推断银行名称和模板名称""" if "支付宝" in filename or "alipay" in filename.lower(): @@ -230,9 +244,8 @@ class FileService: Returns: 上传响应字典 """ - # 生成唯一logId - self.log_counter += 1 - log_id = self.log_counter + # 生成唯一 logId + log_id = self._generate_log_id() # 推断银行信息 bank_name, template_name = self._infer_bank_name(file.filename) @@ -570,9 +583,8 @@ class FileService: data_start_date_id = request.dataStartDateId data_end_date_id = request.dataEndDateId - # 使用递增 logId,确保与上传链路一致 - self.log_counter += 1 - log_id = self.log_counter + # 使用随机 logId,确保与上传链路一致且不覆盖现有记录 + log_id = self._generate_log_id() rule_hit_plan = self._build_rule_hit_plan(log_id) primary_enterprise_name, primary_account_no = self._generate_primary_binding() diff --git a/lsfx-mock-server/tests/test_file_service.py b/lsfx-mock-server/tests/test_file_service.py index 94bd3893..0adb4b9c 100644 --- a/lsfx-mock-server/tests/test_file_service.py +++ b/lsfx-mock-server/tests/test_file_service.py @@ -8,7 +8,7 @@ import io from fastapi import BackgroundTasks from fastapi.datastructures import UploadFile -from services.file_service import FileService +from services.file_service import FileRecord, FileService class FakeStaffIdentityRepository: @@ -139,7 +139,7 @@ def test_fetch_inner_flow_persists_primary_binding_record(monkeypatch): response = service.fetch_inner_flow(request) log_id = response["data"][0] - assert log_id == service.log_counter + assert 10000 <= log_id <= 99999 assert log_id in service.file_records record = service.file_records[log_id] @@ -156,6 +156,24 @@ def test_fetch_inner_flow_persists_primary_binding_record(monkeypatch): assert record.total_records == 200 +def test_generate_log_id_should_retry_when_random_value_conflicts(monkeypatch): + """随机 logId 命中已存在记录时必须重试并返回未占用值。""" + service = FileService(staff_identity_repository=FakeStaffIdentityRepository()) + service.file_records[34567] = FileRecord( + log_id=34567, + group_id=1001, + file_name="existing.csv", + ) + + candidate_values = iter([34567, 45678]) + monkeypatch.setattr( + "services.file_service.random.randint", + lambda start, end: next(candidate_values), + ) + + assert service._generate_log_id() == 45678 + + def test_build_rule_hit_plan_should_be_deterministic_for_same_log_id(): service = FileService(staff_identity_repository=FakeStaffIdentityRepository()) diff --git a/sql/migration/2026-03-20-sync-bank-tag-phase1-rule-metadata.sql b/sql/migration/2026-03-20-sync-bank-tag-phase1-rule-metadata.sql new file mode 100644 index 00000000..ddabaf88 --- /dev/null +++ b/sql/migration/2026-03-20-sync-bank-tag-phase1-rule-metadata.sql @@ -0,0 +1,40 @@ +START TRANSACTION; + +INSERT INTO ccdi_bank_tag_rule ( + model_code, + model_name, + rule_code, + rule_name, + indicator_code, + result_type, + risk_level, + business_caliber, + enabled, + sort_order, + create_by, + remark +) VALUES +('SUSPICIOUS_GAMBLING', '疑似赌博', 'GAMBLING_SENSITIVE_KEYWORD', '疑似敏感交易', NULL, 'STATEMENT', 'HIGH', '备注或交易摘要、对手有“游戏、抖币、体彩、福彩”等字眼。', 1, 20, 'system', '真实规则:识别摘要或对手方命中赌博敏感词的支出流水'), +('SUSPICIOUS_RELATION', '可疑关系', 'SPECIAL_AMOUNT_TRANSACTION', '特殊金额交易', NULL, 'STATEMENT', NULL, '除与配偶、子女外,发生特殊金额交易,如1314元、520元等具有特殊含义的金额。', 1, 10, 'system', '真实规则:识别与非配偶子女发生的特殊金额交易'), +('SUSPICIOUS_PART_TIME', '可疑兼职', 'SUSPICIOUS_INCOME_KEYWORD', '疑似兼职', NULL, 'STATEMENT', 'HIGH', '转入资金摘要有“工资”、“分红”、“红利”、“利息(非银行结息)”等收入', 1, 30, 'system', '真实规则:识别非本行工资代发的收入关键词转入流水'), +('SUSPICIOUS_FOREIGN_EXCHANGE', '可疑外汇交易', 'FOREX_BUY_AMT', '可疑外汇交易', 'SINGLE_PURCHASE_AMOUNT', 'STATEMENT', NULL, '单笔购汇金额超限', 1, 10, 'system', '真实规则:识别单笔购汇金额超过阈值的流水'), +('SUSPICIOUS_FOREIGN_EXCHANGE', '可疑外汇交易', 'FOREX_SELL_AMT', '可疑外汇交易', 'SINGLE_SETTLEMENT_AMOUNT', 'STATEMENT', NULL, '单笔结汇金额超限', 1, 20, 'system', '真实规则:识别单笔结汇金额超过阈值的流水'), +('SUSPICIOUS_PURCHASE', '可疑采购', 'LARGE_PURCHASE_TRANSACTION', '可疑采购', NULL, 'STATEMENT', NULL, '单笔采购金额超过10万元。', 1, 10, 'system', '真实规则:识别单笔采购金额超过10万元的采购事项'), +('ABNORMAL_BEHAVIOR', '异常行为', 'STOCK_TFR_LARGE', '可疑银证大额转账', 'STOCK_TFR_LARGE', 'STATEMENT', NULL, '家庭老人/非关系人银证大额转账', 1, 10, 'system', '真实规则:识别银证转账金额超过阈值的流水'), +('ABNORMAL_BEHAVIOR', '异常行为', 'WITHDRAW_CNT', '微信支付宝频繁提现', 'WITHDRAW_CNT', 'OBJECT', NULL, '微信、支付宝单日提现次数超过设置次数', 1, 20, 'system', '真实规则:识别微信支付宝单日提现次数超过阈值的对象'), +('ABNORMAL_BEHAVIOR', '异常行为', 'LARGE_STOCK_TRADING', '大额炒股', 'STOCK_TFR_LARGE', 'STATEMENT', 'HIGH', '单次三方资管交易金额超过100万元。', 1, 60, 'system', '真实规则:识别单笔三方资管交易金额超过阈值的流水') +ON DUPLICATE KEY UPDATE + model_code = VALUES(model_code), + model_name = VALUES(model_name), + rule_name = VALUES(rule_name), + indicator_code = VALUES(indicator_code), + result_type = VALUES(result_type), + risk_level = VALUES(risk_level), + business_caliber = VALUES(business_caliber), + enabled = VALUES(enabled), + sort_order = VALUES(sort_order), + update_by = 'system', + update_time = NOW(), + remark = VALUES(remark); + +COMMIT;