"""基金转换(convert)代理侧仓储 · T-4(agent 库 risk_convert_detail)。 阶段零占位 / 阶段二回写 / 重试与幂等查询 / 超时清理。仅服务于 convert 模块, **不与 risk_repository 混职责**(后者不动;开发计划 §5.2 DoD)。 写侧全部走 agent 库可写引擎(D20:`xh_agent_rw`,`risk_convert_detail` 在授权表内); 读侧也走同一引擎(本仓储只碰 risk_convert_detail 一张表,无 Core 依赖)。 S2(评审):清理**标记不硬删**——`mark_expired` 置 `status='expired'`,绝不 DELETE, 留痕供对账(与 cleanup_pending_convert.py 的口径一致)。 """ from __future__ import annotations from datetime import date, datetime, timedelta from decimal import Decimal from typing import Any from sqlalchemy import text from sqlalchemy.engine import Engine from app.config.settings import settings from app.utils.db import get_engine def _to_bind(value: Any) -> Any: """Decimal 转 float 再绑定(sqlite 不支持直接绑定 Decimal;MySQL DECIMAL 列自动收口)。 与 T-3 测试教训一致:插入/更新 Decimal 一律转 float,读回侧用 `Decimal(str(...))` 还原。 """ return float(value) if isinstance(value, Decimal) else value _STATUS_PENDING = "pending" _STATUS_COMPLETED = "completed" _STATUS_FAILED = "failed" _STATUS_EXPIRED = "expired" class ConvertRepository: """risk_convert_detail 读写(占位 / 回写 / 查询 / 清理)。""" def __init__(self, engine: Engine | None = None) -> None: self._engine = engine or get_engine(settings.mysql_database, "rw") # ---------- 阶段零:占位(uk_idem 兜底) ---------- def insert_placeholder(self, group_id: str, client_request_id: str | None) -> None: """阶段零占位:插一行 `status='pending'`。 `client_request_id` 为 None 时绑 NULL——MySQL / sqlite 的 UNIQUE 约束均允许多个 NULL, 故「无幂等键的请求」可重复占位、互不冲突(uk_idem 仅对非空键兜底)。 `estimated=0`:convert 占位是真实请求,非风控预估单(与 risk_alert 语义区分)。 """ sql = text( """ INSERT INTO risk_convert_detail (convert_group_id, client_request_id, status, estimated) VALUES (:gid, :cid_req, :status, 0) """ ) with self._engine.begin() as conn: conn.execute( sql, {"gid": group_id, "cid_req": client_request_id, "status": _STATUS_PENDING}, ) # ---------- 阶段二:回写 completed + 详情 ---------- def complete_convert( self, group_id: str, *, out_trade_id: str, in_trade_id: str, related_trade_id: str | None = None, nav: Decimal | None = None, nav_date: date | None = None, fee_amount: Decimal | None = None, hold_days_min: int | None = None, hold_days_max: int | None = None, nav_stale: bool = False, ) -> None: """阶段二回写:置 `status='completed'` + 折算详情(架构 §3 步骤⑧)。 三步法(R-a:弃用方言 UPSERT,兼容 sqlite/MySQL):先查再 INSERT 或 UPDATE。 - 占位已存在(pending/failed)→ UPDATE 为 completed; - 无占位(免幂等键直跑 / 补偿补跑)→ 直接 INSERT 完成行。 本方法只改 risk_convert_detail;主审计写入由 convert_service(T-7)负责,不在此处。 """ params = { "status": _STATUS_COMPLETED, "out": out_trade_id, "in": in_trade_id, "rel": related_trade_id, "nav": _to_bind(nav), "nav_date": nav_date, "fee": _to_bind(fee_amount), "hmin": hold_days_min, "hmax": hold_days_max, "stale": 1 if nav_stale else 0, "gid": group_id, "cid_req": None, } with self._engine.begin() as conn: existing = conn.execute( text("SELECT 1 FROM risk_convert_detail WHERE convert_group_id = :gid"), {"gid": group_id}, ).first() if existing is not None: conn.execute( text( """ UPDATE risk_convert_detail SET status = :status, out_trade_id = :out, in_trade_id = :in, related_trade_id = :rel, nav = :nav, nav_date = :nav_date, fee_amount = :fee, hold_days_min = :hmin, hold_days_max = :hmax, nav_stale = :stale WHERE convert_group_id = :gid """ ), params, ) else: conn.execute( text( """ INSERT INTO risk_convert_detail (convert_group_id, client_request_id, status, estimated, out_trade_id, in_trade_id, related_trade_id, nav, nav_date, fee_amount, hold_days_min, hold_days_max, nav_stale) VALUES (:gid, :cid_req, :status, 0, :out, :in, :rel, :nav, :nav_date, :fee, :hmin, :hmax, :stale) """ ), params, ) def mark_failed(self, group_id: str) -> None: """阶段一失败 → 占位置 `failed`(供巡检/人工补偿,SLA 24h 内)。""" with self._engine.begin() as conn: conn.execute( text("UPDATE risk_convert_detail SET status = :s WHERE convert_group_id = :gid"), {"s": _STATUS_FAILED, "gid": group_id}, ) # ---------- 查询 ---------- def get_by_group_id(self, group_id: str) -> dict[str, Any] | None: """按 convert_group_id 读单行(重试判定 / 阶段二补跑读取)。""" with self._engine.connect() as conn: row = conn.execute( text("SELECT * FROM risk_convert_detail WHERE convert_group_id = :gid"), {"gid": group_id}, ).mappings().first() return dict(row) if row else None def get_by_client_request_id(self, client_request_id: str | None) -> dict[str, Any] | None: """按 client_request_id 读单行(幂等命中读取);None 直接返回 None(WHERE = NULL 永不命中)。""" if client_request_id is None: return None with self._engine.connect() as conn: row = conn.execute( text("SELECT * FROM risk_convert_detail WHERE client_request_id = :cid_req"), {"cid_req": client_request_id}, ).mappings().first() return dict(row) if row else None # ---------- 清理(S2:标记不硬删) ---------- def list_expired_candidates(self, hours: int) -> list[dict[str, Any]]: """取超 `hours` 小时的 `pending` 孤儿(供 cleanup_pending_convert.py 巡检)。 cutoff = 当前本地时间 - hours;sqlite 默认以 localtime 落 created_at, 与 Python datetime.now()(本地)口径一致(B5 评审 P3-4 同口径)。 """ cutoff = datetime.now() - timedelta(hours=hours) with self._engine.connect() as conn: return [ dict(r) for r in conn.execute( text( "SELECT * FROM risk_convert_detail " "WHERE status = :s AND created_at < :cutoff " "ORDER BY created_at ASC" ), {"s": _STATUS_PENDING, "cutoff": cutoff}, ).mappings() ] def mark_expired(self, group_id: str) -> None: """超时 pending 孤儿 → 置 `status='expired'`(标记不硬删,留痕供对账,S2)。""" with self._engine.begin() as conn: conn.execute( text("UPDATE risk_convert_detail SET status = :s WHERE convert_group_id = :gid"), {"s": _STATUS_EXPIRED, "gid": group_id}, )