- 新增 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,零回归
205 lines
8.4 KiB
Python
205 lines
8.4 KiB
Python
"""基金转换(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},
|
||
)
|