Files
group_xinghuo_jinrong/app/repository/convert_repository.py
T
GaoYiYuan_0626 318cb39a1f 基金转换 T-7:convert_service 八步编排(关键路径 · 幂等前置 + 三阶段)
- 新增 app/service/convert/convert_service.py:八步编排(①-④ 校验不落库 / ⑤ 占位 /
  ⑥ apply_convert / ⑦ 阶段 1.5 引擎不阻断 / ⑧ 回写 + 审计),幂等判定前置到校验之前
  以修复同键重试误报 InsufficientShares
- 新增 tests/test_convert_service.py(17 用例)
- 新增 scripts/dev/verify_convert_service.py(真 MySQL 验证 35/35)
- 改 app/repository/convert_repository.py:complete_convert 由纯 UPDATE 改三步法
  upsert(无占位直跑也能落完成行,R-a 口径)
- 改 app/repository/core_ro.py:新增 has_convert_trades / list_convert_trades /
  list_convert_lot_details 三个只读方法
- 改 app/config/settings.py:新增 6 个 convert 配置项
- 修 tests/test_convert_calc.py:TestPurity 排除编排层 convert_service.py

基线 639 → 656 passed / 3 skipped,零回归
2026-09-10 17:12:01 +08:00

205 lines
8.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""基金转换(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},
)