"""sqlite 测试 DDL 单一事实源(B8 · B4 评审 P3-12 收敛 / B5 评审 P3-4 时区)。 以 VARCHAR/TEXT/INTEGER/REAL 近似 MySQL 的 ENUM/SMALLINT/JSON/DECIMAL, 枚举防线不在测试 DB 层(由 service 白名单保证,见 test_profile_l3 口径注释)。 时间默认值统一 `(datetime('now','localtime'))`:sqlite CURRENT_TIMESTAMP 为 UTC, 本地 23:00~24:00 跑测试时与 MySQL localtime 口径错位一个日界(B5 评审 P3-4)。 列集为各测试所需列的并集;新表/新列只改此处,禁止再回测试文件手写。 """ from sqlalchemy import create_engine, text from sqlalchemy.pool import StaticPool _TS = "TIMESTAMP DEFAULT (datetime('now','localtime'))" SQLITE_TABLES: dict[str, str] = { # ---- Core 模拟库(只读侧) ---- "core_customer": """ CREATE TABLE core_customer ( customer_id VARCHAR(64) PRIMARY KEY, display_name VARCHAR(128), age INTEGER, occupation VARCHAR(64), open_date DATE, is_active TINYINT DEFAULT 1, is_hnw TINYINT DEFAULT 0, service_tier VARCHAR(16) DEFAULT 'normal') """, "core_customer_risk": """ CREATE TABLE core_customer_risk ( customer_id VARCHAR(64), risk_code VARCHAR(8), evaluated_at TIMESTAMP, expires_at TIMESTAMP, is_authoritative TINYINT DEFAULT 1, source VARCHAR(32) DEFAULT 'risk_questionnaire', investor_category VARCHAR(32) DEFAULT 'ordinary', questionnaire_score INTEGER, max_loss_tolerance_pct REAL, investment_goal VARCHAR(128), investment_horizon VARCHAR(16), professional_approved_at TIMESTAMP) """, "core_customer_advisor": """ CREATE TABLE core_customer_advisor ( advisor_id VARCHAR(64), customer_id VARCHAR(64), rel_status VARCHAR(16)) """, "core_suitability_rule": """ CREATE TABLE core_suitability_rule ( id INTEGER PRIMARY KEY AUTOINCREMENT, customer_risk_code VARCHAR(8), product_risk_code VARCHAR(8), match_result VARCHAR(32), rule_ref VARCHAR(32) DEFAULT 'JR-AST-012') """, "core_product": """ CREATE TABLE core_product ( product_id VARCHAR(64) PRIMARY KEY, product_name VARCHAR(128), min_risk_code VARCHAR(8), product_type VARCHAR(32), min_subscribe_amount DECIMAL DEFAULT 1.00, term_days INTEGER, requires_disclosure TINYINT DEFAULT 0, can_subscribe TINYINT DEFAULT 1, can_redeem TINYINT DEFAULT 1, min_hold_qty DECIMAL DEFAULT 0, min_redeem_qty DECIMAL DEFAULT 0, min_hold_action VARCHAR(32) DEFAULT 'force_transfer', subscribe_fee_rate DECIMAL DEFAULT 0, fund_company VARCHAR(64), ta_code VARCHAR(32)) """, # T-0(R1 门禁):以 scripts/core/01-ddl.sql:132-145 为权威逐列对齐 —— # id PK / customer_id / product_id / qty / cost_amount / market_value / # pnl_pct / as_of + UNIQUE(customer_id, product_id)。 # 原列名 quantity 已废弃(MySQL 恒为 qty,列名失配是 convert 首个阻断项)。 # 不建外键:sqlite 测试库既有表均无 FK,保持一致(避免插入顺序约束)。 "core_holding": """ CREATE TABLE core_holding ( id INTEGER PRIMARY KEY AUTOINCREMENT, customer_id VARCHAR(64) NOT NULL, product_id VARCHAR(64) NOT NULL, qty DECIMAL NOT NULL, cost_amount DECIMAL NOT NULL, market_value DECIMAL NOT NULL, pnl_pct DECIMAL NOT NULL, as_of DATE NOT NULL, UNIQUE (customer_id, product_id)) """, "core_trade": """ CREATE TABLE core_trade ( trade_id VARCHAR(64) PRIMARY KEY, customer_id VARCHAR(64), product_id VARCHAR(64), trade_type VARCHAR(16), amount DECIMAL, qty DECIMAL, convert_group_id VARCHAR(64), trade_status VARCHAR(16), traded_at TIMESTAMP) """, # ---- convert 新增 4 表(T-1):MySQL 侧落点为 # scripts/core/01-ddl.sql(前 3 张)与 docs/项目框架设计/表设计/02-mysql-agent专用.sql # (risk_convert_detail)。列集为测试所需列的并集,新列只改此处。 "core_fee_rule": """ CREATE TABLE core_fee_rule ( id INTEGER PRIMARY KEY AUTOINCREMENT, product_id VARCHAR(64) NOT NULL, fee_type VARCHAR(16) NOT NULL, min_hold_days INTEGER, max_hold_days INTEGER, min_amount DECIMAL, max_amount DECIMAL, rate DECIMAL NOT NULL, to_fund_ratio DECIMAL NOT NULL DEFAULT 1.0) """, "core_share_lot": f""" CREATE TABLE core_share_lot ( lot_id VARCHAR(64) PRIMARY KEY, customer_id VARCHAR(64) NOT NULL, product_id VARCHAR(64) NOT NULL, qty DECIMAL NOT NULL, remain_qty DECIMAL NOT NULL, nav DECIMAL NOT NULL, confirmed_at TIMESTAMP NOT NULL, source_trade_id VARCHAR(64), created_at {_TS}) """, "core_convert_lot_detail": f""" CREATE TABLE core_convert_lot_detail ( id INTEGER PRIMARY KEY AUTOINCREMENT, convert_group_id VARCHAR(64) NOT NULL, lot_id VARCHAR(64) NOT NULL, qty DECIMAL NOT NULL, hold_days INTEGER NOT NULL, amount DECIMAL NOT NULL, fee_rate DECIMAL NOT NULL, fee_amount DECIMAL NOT NULL, nav DECIMAL NOT NULL, nav_date DATE NOT NULL, created_at {_TS}) """, "risk_convert_detail": f""" CREATE TABLE risk_convert_detail ( id INTEGER PRIMARY KEY AUTOINCREMENT, convert_group_id VARCHAR(64) NOT NULL, status VARCHAR(16) NOT NULL DEFAULT 'pending', out_trade_id VARCHAR(64), in_trade_id VARCHAR(64), related_trade_id VARCHAR(64), nav DECIMAL, nav_date DATE, fee_amount DECIMAL, hold_days_min INTEGER, hold_days_max INTEGER, estimated TINYINT NOT NULL DEFAULT 1, nav_stale TINYINT NOT NULL DEFAULT 0, client_request_id VARCHAR(64), cancelled_at TIMESTAMP, created_at {_TS}, UNIQUE (convert_group_id), UNIQUE (client_request_id)) """, # T-0(R-f):core_product_nav 与 MySQL scripts/core/01-ddl.sql:186-194 对齐 # (get_latest_nav 及新增 get_as_of 的读取目标;此前 sqlite 侧整表缺失)。 "core_product_nav": """ CREATE TABLE core_product_nav ( id INTEGER PRIMARY KEY AUTOINCREMENT, product_id VARCHAR(64) NOT NULL, nav DECIMAL NOT NULL, daily_chg_pct DECIMAL NOT NULL, nav_date DATE NOT NULL, UNIQUE (product_id, nav_date)) """, # ---- agent 库 ---- "audit_log": f""" CREATE TABLE audit_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, trace_id VARCHAR(64), event_type VARCHAR(64), agent_type VARCHAR(16), actor_id VARCHAR(64), customer_id VARCHAR(64), rule_id VARCHAR(64), input_summary TEXT, decision VARCHAR(64), risk_score INTEGER, handler_id VARCHAR(64), handler_result VARCHAR(64), handler_comment VARCHAR(512), created_at {_TS}) """, "input_guard_log": f""" CREATE TABLE input_guard_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, trace_id VARCHAR(64), session_id VARCHAR(64), agent_type VARCHAR(16), actor_id VARCHAR(64), guard_type VARCHAR(24), raw_excerpt VARCHAR(1024), action VARCHAR(16), created_at {_TS}) """, "agent_session": f""" CREATE TABLE agent_session ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id VARCHAR(64) UNIQUE, trace_id VARCHAR(64), agent_type VARCHAR(16), actor_id VARCHAR(64), actor_role VARCHAR(32), customer_id VARCHAR(64), advisor_id VARCHAR(64), title VARCHAR(256), status VARCHAR(16) DEFAULT 'active', created_at {_TS}, closed_at TIMESTAMP) """, "agent_message": f""" CREATE TABLE agent_message ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id VARCHAR(64), trace_id VARCHAR(64), seq_no INTEGER, role VARCHAR(16), content TEXT, has_disclaimer TINYINT DEFAULT 0, token_est INTEGER, created_at {_TS}) """, "agent_tool_call": f""" CREATE TABLE agent_tool_call ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id VARCHAR(64), trace_id VARCHAR(64), message_id INTEGER, tool_name VARCHAR(128), tool_input TEXT, tool_output TEXT, status VARCHAR(16), error_code VARCHAR(64), latency_ms INTEGER, created_at {_TS}) """, "risk_alert": f""" CREATE TABLE risk_alert ( alert_id VARCHAR(64) PRIMARY KEY, trace_id VARCHAR(64), customer_id VARCHAR(64), trade_id VARCHAR(64), alert_type VARCHAR(16), triggered_rules TEXT, risk_score INTEGER, status VARCHAR(24) DEFAULT 'pending_review', payload TEXT, handler_id VARCHAR(64), handler_result VARCHAR(64), handler_comment VARCHAR(512), created_at {_TS}, handled_at TIMESTAMP) """, "risk_suitability_log": f""" CREATE TABLE risk_suitability_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, trace_id VARCHAR(64) NOT NULL, customer_id VARCHAR(64) NOT NULL, product_id VARCHAR(64) NOT NULL, product_name VARCHAR(128), customer_risk_level VARCHAR(8) NOT NULL, product_risk_level VARCHAR(8) NOT NULL, investor_category VARCHAR(32) DEFAULT 'ordinary', match_result VARCHAR(32) NOT NULL, mismatch_type VARCHAR(32) DEFAULT 'none', is_matched TINYINT, is_blocked TINYINT DEFAULT 0, requires_disclosure TINYINT DEFAULT 0, needs_branch_confirm TINYINT DEFAULT 0, risk_was_expired TINYINT DEFAULT 0, block_reason VARCHAR(512), block_response_code VARCHAR(32), check_source VARCHAR(16) DEFAULT 'r02_trade', actor_id VARCHAR(64), request_ref VARCHAR(64), profile_l1_version VARCHAR(32), rule_refs TEXT, created_at {_TS}) """, "customer_profile_l3": f""" CREATE TABLE customer_profile_l3 ( customer_id VARCHAR(64) PRIMARY KEY, monitor_tier VARCHAR(16) NOT NULL, risk_score INTEGER, score_dimensions TEXT, monitor_tags TEXT, last_alert_id VARCHAR(64), computed_at TIMESTAMP NOT NULL, updated_at {_TS}) """, "risk_aml_list": f""" CREATE TABLE risk_aml_list ( id INTEGER PRIMARY KEY AUTOINCREMENT, list_id VARCHAR(64), list_type VARCHAR(16), full_name VARCHAR(128), id_no VARCHAR(32), bank_card_no VARCHAR(32), match_threshold REAL, source VARCHAR(64), list_version VARCHAR(16), effective_date DATE, is_active TINYINT DEFAULT 1, created_at {_TS}) """, } # ---------- T-0 建库自校验(R1 门禁)---------- # 与 scripts/core/01-ddl.sql 人工同步的权威结构清单(逐列对照,见上表注释): # core_holding id BIGINT PK / customer_id / product_id / qty / cost_amount / # market_value / pnl_pct / as_of / UNIQUE KEY uk_cust_product # core_product_nav id BIGINT PK / product_id / nav / daily_chg_pct / nav_date / # UNIQUE KEY uk_product_date # 失配在建库那一刻即 AssertionError,不留到集成测试期。 EXPECTED_CORE_HOLDING_COLUMNS = { "id", "customer_id", "product_id", "qty", "cost_amount", "market_value", "pnl_pct", "as_of", } REQUIRED_CORE_TABLES = {"core_holding", "core_product_nav", "core_trade", "core_product"} # T-1:convert 新增 4 表同样纳入存在性门禁。落点:前 3 张在 scripts/core/01-ddl.sql, # risk_convert_detail 在 docs/项目框架设计/表设计/02-mysql-agent专用.sql。 REQUIRED_CONVERT_TABLES = { "core_fee_rule", "core_share_lot", "core_convert_lot_detail", "risk_convert_detail", } def _assert_ddl_aligned(engine) -> None: """建库后自校验:缺列/缺表立即 AssertionError(R1 门禁)。 触发场景(真实踩过的坑):sqlite 列名曾为 quantity,而 MySQL 是 qty —— 单测全绿但 convert 首次 UPDATE core_holding.qty 才炸;本校验把失败点前移。 """ with engine.connect() as conn: tables = { row[0] for row in conn.execute(text("SELECT name FROM sqlite_master WHERE type = 'table'")) } missing_tables = REQUIRED_CORE_TABLES - tables assert not missing_tables, ( f"sqlite DDL 缺表:{sorted(missing_tables)}(以 scripts/core/01-ddl.sql 为准)" ) missing_convert = REQUIRED_CONVERT_TABLES - tables assert not missing_convert, ( f"sqlite DDL 缺 convert 表:{sorted(missing_convert)}" "(以 scripts/core/01-ddl.sql 与 docs/项目框架设计/表设计/02-mysql-agent专用.sql 为准)" ) columns = {row[1] for row in conn.execute(text("PRAGMA table_info(core_holding)"))} missing_columns = EXPECTED_CORE_HOLDING_COLUMNS - columns assert not missing_columns, ( f"core_holding 缺列:{sorted(missing_columns)}(以 scripts/core/01-ddl.sql 为准)" ) def create_sqlite_engine(): """内存 sqlite + 全部表(StaticPool 单连接共享,同现有测试模式)。 建表后经 `_assert_ddl_aligned` 自校验(T-0 门禁):结构失配在建库即失败, 而不是留到集成测试期。 """ engine = create_engine( "sqlite://", poolclass=StaticPool, connect_args={"check_same_thread": False} ) with engine.begin() as conn: for ddl in SQLITE_TABLES.values(): conn.execute(text(ddl)) _assert_ddl_aligned(engine) return engine # C×R 适当性矩阵测试种子(与 scripts/core/02-seed-base.sql 25 行逐格一致; # AL-05 换核后 check_suitability 以矩阵表为 L0 权威,缺失即全量 forbidden) SUITABILITY_MATRIX_SEED: list[tuple[str, str, str]] = [ ("C1", "R1", "allowed"), ("C1", "R2", "allowed"), ("C1", "R3", "forbidden"), ("C1", "R4", "forbidden"), ("C1", "R5", "forbidden"), ("C2", "R1", "allowed"), ("C2", "R2", "allowed"), ("C2", "R3", "allowed"), ("C2", "R4", "forbidden"), ("C2", "R5", "forbidden"), ("C3", "R1", "allowed"), ("C3", "R2", "allowed"), ("C3", "R3", "allowed"), ("C3", "R4", "allowed_with_disclosure"), ("C3", "R5", "forbidden"), ("C4", "R1", "allowed"), ("C4", "R2", "allowed"), ("C4", "R3", "allowed"), ("C4", "R4", "allowed"), ("C4", "R5", "allowed_with_disclosure"), ("C5", "R1", "allowed"), ("C5", "R2", "allowed"), ("C5", "R3", "allowed"), ("C5", "R4", "allowed"), ("C5", "R5", "allowed"), ] def seed_suitability_matrix(engine) -> None: """给 sqlite 测试库灌 25 行 C×R 矩阵(各涉及适当性的测试 fixture 调用)。""" with engine.begin() as conn: for c, r, m in SUITABILITY_MATRIX_SEED: conn.execute( text( "INSERT INTO core_suitability_rule " "(customer_risk_code, product_risk_code, match_result) " "VALUES (:c, :r, :m)" ), {"c": c, "r": r, "m": m}, )