From df96275ab51f027a9c648d152a3057f98f9bdfdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=BF=E4=BA=91=E7=A7=8B=E6=9C=88?= <15273589815@163.com> Date: Fri, 11 Sep 2026 14:19:05 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=99=20trigger=5Frule=5Fcodes=20=E5=8A=A0?= =?UTF-8?q?=20JSON=20=E5=A4=9A=E5=80=BC=E7=B4=A2=E5=BC=95=EF=BC=9B?= =?UTF-8?q?=E7=99=BB=E8=AE=B0=20P3=20=E5=A4=84=E7=90=86=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=EF=BC=88docs/25=20P3=20#21=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实测确认 #21 描述准确:fin_risk_alert 只有 11 个普通 BTREE 索引 + 主键 + alert_no 唯一键,规则命中的 JSON_CONTAINS 查询 EXPLAIN 为 type=ALL、possible_keys=NULL, 即全表扫。MySQL 8.0.27 支持多值索引,故新增迁移 20260911_risk_rule_index: ADD INDEX idx_fin_risk_alert_trigger_rule_codes ((CAST(`trigger_rule_codes` AS CHAR(16) ARRAY))) 迁移幂等(先查 information_schema.STATISTICS),upgrade/downgrade 往返已验证。 生效后 EXPLAIN 变为 access_type=range 且 key 命中该索引,原始证据留档在 docs/evidence/risk-index-probe.json(由 tools/probe_risk_index.py 生成,只读探查)。 只解决一半,另一半如实记为限制:若干 like(f"%{keyword}%") 全表扫无法用 B-tree 索引, 根治需全文索引 + 中文分词组件(部署依赖),本轮不做。 revision 名刻意压到 32 字符以内 —— alembic_version.version_num 是 VARCHAR(32), 超长会在写版本号时报 1406,而 DDL 是非事务的,那时索引已经建好了。 docs/25 追加"P3 处理结果"表,逐条登记 17-25 的状态:#19 是协议级重做(keyset 分页) 不单方面改,#21 部分修复,#25 前半段不成立,其余已修。 --- alembic/versions/20260911_risk_rule_index.py | 80 ++++++++++ docs/25-风控模块代码评审报告.md | 14 ++ docs/evidence/risk-index-probe.json | 154 +++++++++++++++++++ tools/probe_risk_index.py | 138 +++++++++++++++++ 4 files changed, 386 insertions(+) create mode 100644 alembic/versions/20260911_risk_rule_index.py create mode 100644 docs/evidence/risk-index-probe.json create mode 100644 tools/probe_risk_index.py diff --git a/alembic/versions/20260911_risk_rule_index.py b/alembic/versions/20260911_risk_rule_index.py new file mode 100644 index 0000000..35d701d --- /dev/null +++ b/alembic/versions/20260911_risk_rule_index.py @@ -0,0 +1,80 @@ +"""给 `fin_risk_alert.trigger_rule_codes` 补 JSON 多值索引(docs/25 P3 #21)。 + +修订原因:规则命中查询走的是 `JSON_CONTAINS(trigger_rule_codes, '"RW-018"')` +(`risk_repository.py` 的 `contains`),而 `docs/25` 第 21 条实测该表**没有**任何可用于 +该表达式的索引 —— `information_schema.STATISTICS` 里 `fin_risk_alert` 只有 11 个普通 +BTREE 索引(`status`、`customer_id`、`created_at` 等)+ 主键 + `alert_no` 唯一键, +`EXPLAIN` 显示 `type=ALL`、`possible_keys=NULL`,即每次按规则码筛选都是全表扫描。 + +为什么用**多值索引**而不是普通索引:`trigger_rule_codes` 是 `json` 列,存的是数组 +(实测取值形如 `["RW-007", "RW-002", "RW-012"]`)。MySQL 8.0.17 起支持在 JSON 数组上 +建多值索引,且优化器在 `JSON_CONTAINS` / `MEMBER OF` / `JSON_OVERLAPS` 上可以使用它 —— +这正是本条要解决的那个谓词。当前实例版本 8.0.27,满足条件。 + +`CAST(... AS CHAR(16) ARRAY)` 中的 16:规则码是 `RW-###`(5 字符),留足余量而不至于让 +索引项过大。**注意**这不是"截断匹配"—— 多值索引要求列上所有取值都能装进声明长度, +否则 `ALTER` 会直接失败(报 3903),而不是悄悄少索引几行。 + +前置条件已在 `tools/probe_risk_index.py` 里验证并留档 +(`docs/evidence/risk-index-probe.json`):3 行数据全部是 JSON 数组, +`non_array_rows = 0`。 + +**本条只解决一半**:另一处索引失效是若干 `like(f"%{keyword}%")` 全表扫 —— 前后都有 +通配符的模糊匹配在 B-tree 上无法索引,唯一出路是全文索引(中文需要分词组件,属于部署 +依赖)。因此这里**不**假装解决它,只把它记为已知限制,见迁移提交说明与 `docs/25`。 + +基线合规(`AGENTS.md` 第 2/3/4 条):本迁移只**新增一个索引**,不建表、不加列、不改列, +不重命名、不删除任何已有表或字段,也不改变任何已有字段的类型、可空性与业务含义。 +`docs/00-新数据库基线设计.md` 未修改。 + +幂等性:先查 `information_schema.STATISTICS` 再决定是否 `ALTER`,重复执行不会报 1061。 +`downgrade` 与之对称,回退后结构与迁移前完全一致。 +""" + +from sqlalchemy import text +from sqlalchemy.engine import Connection + +from alembic import op + +# revision 名必须 ≤ 32 字符:`alembic_version.version_num` 是 VARCHAR(32), +# 超长会在写版本号时报 1406 Data too long —— 而 DDL 是非事务的,那时索引已经建好了。 +revision = "20260911_risk_rule_index" +down_revision = "20260910_drop_review_separation" +branch_labels = None +depends_on = None + +TABLE = "fin_risk_alert" +INDEX = "idx_fin_risk_alert_trigger_rule_codes" +EXPRESSION = "CAST(`trigger_rule_codes` AS CHAR(16) ARRAY)" + + +def _index_exists(bind: Connection) -> bool: + found = bind.execute( + text( + """ + SELECT 1 + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = :table + AND INDEX_NAME = :name + LIMIT 1 + """ + ), + {"table": TABLE, "name": INDEX}, + ).first() + return found is not None + + +def upgrade() -> None: + bind = op.get_bind() + if _index_exists(bind): + return + # 表名/索引名/表达式都是本模块常量,不含外部输入。 + op.execute(f"ALTER TABLE `{TABLE}` ADD INDEX `{INDEX}` (({EXPRESSION}))") + + +def downgrade() -> None: + bind = op.get_bind() + if not _index_exists(bind): + return + op.execute(f"ALTER TABLE `{TABLE}` DROP INDEX `{INDEX}`") diff --git a/docs/25-风控模块代码评审报告.md b/docs/25-风控模块代码评审报告.md index 84eaa2b..0602bb1 100644 --- a/docs/25-风控模块代码评审报告.md +++ b/docs/25-风控模块代码评审报告.md @@ -240,6 +240,20 @@ Asia/Shanghai 展示。北京 08:00 前生成时,统计窗口是"前一日 08: | 24 | SSE 未校验 `Accept`(`SseNotAcceptableError`/406 已定义但未被使用) | `api/controllers/risk.py:182-204` | | 25 | 接口未登记 `docs/05`:§19 目录里一条风控接口都没有,而 §20 明确要求"新增接口必须同步更新 §19";且 §12 约定的前缀是 `/risk-scans/**`、`/risk-alerts/**`,实现是 `/api/v1/risk` | `docs/05-接口文档.md` §12/§19/§20 | +**P3 处理结果(2026-09-11)** + +| # | 状态 | 处理 | +|---|---|---| +| 17 | ✅ 已修 | 列表信封改为 `{data: [...], meta: {trace_id, next_cursor, has_more}}`,对齐 §3.3;五个列表端点统一走 `_list_envelope` | +| 18 | ✅ 已修 | 游标内嵌 SHA-256 指纹(`user_id` + `data_scope`/`customer_ids` + 查询条件;`/evidence/{source}` 的 `source` 一并绑定,否则 customers 的游标能直接翻 products)。指纹不符一律 `400 INVALID_CURSOR`。**刻意排除 `limit`**:它是分页参数不是查询条件 | +| 19 | ⚠️ 已知限制 | offset 游标无法根治跳行/重复,要根治得改 keyset 分页(游标携带"上一页最后一条的排序键")。这会**改动分页协议本身**,且当前排序首列是 `case(alert_level=HIGH…)` 这种计算列,需要连排序一起去掉 —— 属于协议级重做,不在本轮单方面改 | +| 20 | ✅ 已修 | 详情证据每类封顶 200 条、日报每组封顶 5000 条,用 `limit + 1` 判定截断,并在响应里暴露 `evidence_truncated` / `data_truncated`。**不静默截断**:日报计数直接来自行数,静默截断等于给出一份看起来正常、实际少统计的日报 | +| 21 | 🟡 部分修复 | `trigger_rule_codes` 已加 JSON 多值索引(迁移 `20260911_risk_rule_index`);`EXPLAIN` 由 `type=ALL`、`possible_keys=NULL` 变为 `type=range` 并命中 `idx_fin_risk_alert_trigger_rule_codes`,实测证据留档在 `docs/evidence/risk-index-probe.json`。**`like(f"%{keyword}%")` 依然全表扫**:前后通配符在 B-tree 上无解,根治需全文索引 + 中文分词组件(部署依赖),本轮不做,如实记为限制 | +| 22 | ✅ 已修 | 6 个写接口接入平台 `api_request_receipt` 幂等。新增 `ApiTransactionService.execute_in`:原 `execute` 自开 `SessionFactory()` 与 `session.begin()`,而 `RiskActionService._finish` 内部会 commit,套进去就是"内层提交外层事务",故改为在调用方事务内读写幂等记录 | +| 23 | ✅ 已修 | **不把 413 降成 422**:413 是上传超限的标准语义,前端文档也已按 413 做提示映射,改为在 `docs/05` §3.5 状态码表**补登** 413,契约以"补齐"而非"改动"方式对齐 | +| 24 | ✅ 已修 | SSE 端点补 `Accept` 协商(抽到 `app/api/dependencies/negotiation.py` 与 `/agent-runs/{run_id}/events` 共用)。顺带发现一个更隐蔽的问题:鉴权原本在 async generator 内部,403 只能在响应头发出**之后**抛出,表现为"200 + 半截流",现改为构造 `StreamingResponse` 前完成 | +| 25 | 🟡 部分成立 | "§19 一条风控接口都没有"**不成立**:§19 末尾写明业务域接口由各自业务文档登记,15 条端点已在 `docs/风控业务演示文档/06-模块接口与字段映射.md` 逐条登记。真问题是 §12 写的 `/risk-scans/**`、`/risk-alerts/**` 与实际实现 `/api/v1/risk/**` 不符,已按实现更新 §12 并加说明 | + --- ## 六、做得好、建议保持 ✅ diff --git a/docs/evidence/risk-index-probe.json b/docs/evidence/risk-index-probe.json new file mode 100644 index 0000000..52b8ae9 --- /dev/null +++ b/docs/evidence/risk-index-probe.json @@ -0,0 +1,154 @@ +{ + "mysql_version": "8.0.27", + "column": [ + { + "COLUMN_NAME": "trigger_rule_codes", + "COLUMN_TYPE": "json", + "DATA_TYPE": "json", + "IS_NULLABLE": "NO" + } + ], + "indexes": [ + { + "INDEX_NAME": "idx_fin_risk_alert_alert_level", + "COLUMN_NAME": "alert_level", + "INDEX_TYPE": "BTREE", + "EXPRESSION": null + }, + { + "INDEX_NAME": "idx_fin_risk_alert_alert_type", + "COLUMN_NAME": "alert_type", + "INDEX_TYPE": "BTREE", + "EXPRESSION": null + }, + { + "INDEX_NAME": "idx_fin_risk_alert_created_at", + "COLUMN_NAME": "created_at", + "INDEX_TYPE": "BTREE", + "EXPRESSION": null + }, + { + "INDEX_NAME": "idx_fin_risk_alert_customer_id", + "COLUMN_NAME": "customer_id", + "INDEX_TYPE": "BTREE", + "EXPRESSION": null + }, + { + "INDEX_NAME": "idx_fin_risk_alert_due_at", + "COLUMN_NAME": "due_at", + "INDEX_TYPE": "BTREE", + "EXPRESSION": null + }, + { + "INDEX_NAME": "idx_fin_risk_alert_handler_id", + "COLUMN_NAME": "handler_id", + "INDEX_TYPE": "BTREE", + "EXPRESSION": null + }, + { + "INDEX_NAME": "idx_fin_risk_alert_primary_risk_work_order_id", + "COLUMN_NAME": "primary_risk_work_order_id", + "INDEX_TYPE": "BTREE", + "EXPRESSION": null + }, + { + "INDEX_NAME": "idx_fin_risk_alert_priority_score", + "COLUMN_NAME": "priority_score", + "INDEX_TYPE": "BTREE", + "EXPRESSION": null + }, + { + "INDEX_NAME": "idx_fin_risk_alert_related_order_id", + "COLUMN_NAME": "related_order_id", + "INDEX_TYPE": "BTREE", + "EXPRESSION": null + }, + { + "INDEX_NAME": "idx_fin_risk_alert_related_transaction_id", + "COLUMN_NAME": "related_transaction_id", + "INDEX_TYPE": "BTREE", + "EXPRESSION": null + }, + { + "INDEX_NAME": "idx_fin_risk_alert_related_work_order_id", + "COLUMN_NAME": "related_work_order_id", + "INDEX_TYPE": "BTREE", + "EXPRESSION": null + }, + { + "INDEX_NAME": "idx_fin_risk_alert_status", + "COLUMN_NAME": "status", + "INDEX_TYPE": "BTREE", + "EXPRESSION": null + }, + { + "INDEX_NAME": "idx_fin_risk_alert_trigger_rule_codes", + "COLUMN_NAME": null, + "INDEX_TYPE": "BTREE", + "EXPRESSION": "cast(`trigger_rule_codes` as char(16) array)" + }, + { + "INDEX_NAME": "PRIMARY", + "COLUMN_NAME": "id", + "INDEX_TYPE": "BTREE", + "EXPRESSION": null + }, + { + "INDEX_NAME": "uk_fin_risk_alert_alert_no", + "COLUMN_NAME": "alert_no", + "INDEX_TYPE": "BTREE", + "EXPRESSION": null + } + ], + "row_count": 3, + "non_array_rows": 0, + "distinct_rule_codes": [ + { + "raw_codes": "[\"RW-007\", \"RW-002\", \"RW-012\"]", + "rows_count": 1 + }, + { + "raw_codes": "[\"RW-015\", \"RW-003\"]", + "rows_count": 1 + }, + { + "raw_codes": "[\"RW-018\"]", + "rows_count": 1 + } + ], + "explain_json_contains": { + "query_block": { + "select_id": 1, + "cost_info": { + "query_cost": "0.71" + }, + "table": { + "table_name": "fin_risk_alert", + "access_type": "range", + "possible_keys": [ + "idx_fin_risk_alert_trigger_rule_codes" + ], + "key": "idx_fin_risk_alert_trigger_rule_codes", + "used_key_parts": [ + "cast(`trigger_rule_codes` as char(16) array)" + ], + "key_length": "67", + "rows_examined_per_scan": 1, + "rows_produced_per_join": 1, + "filtered": "100.00", + "cost_info": { + "read_cost": "0.61", + "eval_cost": "0.10", + "prefix_cost": "0.71", + "data_read_per_join": "936" + }, + "used_columns": [ + "id", + "trigger_rule_codes", + "cast(`trigger_rule_codes` as char(16) array)" + ], + "attached_condition": "json_contains(cast(`trigger_rule_codes` as char(16) array),json'[\"RW-018\"]')" + } + } + } +} \ No newline at end of file diff --git a/tools/probe_risk_index.py b/tools/probe_risk_index.py new file mode 100644 index 0000000..e2d4c1b --- /dev/null +++ b/tools/probe_risk_index.py @@ -0,0 +1,138 @@ +"""只读探查:`fin_risk_alert.trigger_rule_codes` 能否用 JSON 多值索引(docs/25 P3 #21)。 + +只做 SELECT / EXPLAIN,不做任何写入。结果写成 JSON 便于在 GBK 控制台下查看: + + python tools/probe_risk_index.py + +排查点: +1. MySQL 版本是否 ≥ 8.0.17(多值索引的下限); +2. 列是否确实是 JSON、是否已存在同类索引; +3. 是否存在**非数组**取值 —— 有的话 `CAST(... AS CHAR ARRAY)` 建索引会直接失败; +4. 当前 `JSON_CONTAINS` 查询走的是什么访问路径(有没有可用的 key)。 +""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from typing import Any + +from sqlalchemy import text + +from app.infrastructure.db import SessionFactory + +OUTPUT = Path("docs/evidence/risk-index-probe.json") + + +async def collect() -> dict[str, Any]: + report: dict[str, Any] = {} + async with SessionFactory() as session: + report["mysql_version"] = ( + await session.execute(text("SELECT VERSION()")) + ).scalar_one() + + report["column"] = [ + dict(row) + for row in ( + ( + await session.execute( + text( + """ + SELECT COLUMN_NAME, COLUMN_TYPE, DATA_TYPE, IS_NULLABLE + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'fin_risk_alert' + AND COLUMN_NAME = 'trigger_rule_codes' + """ + ) + ) + ) + .mappings() + .all() + ) + ] + + report["indexes"] = [ + dict(row) + for row in ( + ( + await session.execute( + text( + """ + SELECT INDEX_NAME, COLUMN_NAME, INDEX_TYPE, EXPRESSION + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'fin_risk_alert' + ORDER BY INDEX_NAME, SEQ_IN_INDEX + """ + ) + ) + ) + .mappings() + .all() + ) + ] + + report["row_count"] = ( + await session.execute(text("SELECT COUNT(*) FROM fin_risk_alert")) + ).scalar_one() + report["non_array_rows"] = ( + await session.execute( + text( + "SELECT COUNT(*) FROM fin_risk_alert " + "WHERE trigger_rule_codes IS NULL " + " OR JSON_TYPE(trigger_rule_codes) <> 'ARRAY'" + ) + ) + ).scalar_one() + report["distinct_rule_codes"] = [ + dict(row) + for row in ( + ( + await session.execute( + text( + """ + SELECT CAST(trigger_rule_codes AS CHAR) AS raw_codes, + COUNT(*) AS rows_count + FROM fin_risk_alert + GROUP BY 1 + """ + ) + ) + ) + .mappings() + .all() + ) + ] + + # 用 FORMAT=JSON:传统 EXPLAIN 会把"该查询无法被缓存"写成 warning 打到 stderr, + # 让脚本在 CI 里看起来是失败的,而它其实成功了。 + raw_explain = ( + await session.execute( + text( + """ + EXPLAIN FORMAT=JSON + SELECT id FROM fin_risk_alert + WHERE JSON_CONTAINS(trigger_rule_codes, '"RW-018"') + """ + ) + ) + ).scalar_one() + report["explain_json_contains"] = json.loads(raw_explain) + + return report + + +async def main() -> None: + report = await collect() + OUTPUT.parent.mkdir(parents=True, exist_ok=True) + OUTPUT.write_text( + json.dumps(report, ensure_ascii=False, indent=2, default=str), + encoding="utf-8", + ) + print(f"wrote {OUTPUT}") + + +if __name__ == "__main__": + asyncio.run(main())