diff --git a/app/api/simulate.py b/app/api/simulate.py index b369259..9b98392 100644 --- a/app/api/simulate.py +++ b/app/api/simulate.py @@ -5,24 +5,36 @@ risk_demo 演示账号或客户本人(auth.customer_id == 请求 customer_id PRD FR-1 §鉴权);越权经 deps.deny 审计后 403。T-01 后工厂内部换 JWT。 trace:main 中间件贯通(B7),响应头 X-Trace-Id 回写;service 层 ensure_trace 仍兜底脚本/测试直调场景。 -挂载:main.py include(B7)。错误体统一 ApiError → 手册 §10 结构(挂账④), -convert 400 / 资源 404 不变 HTTP 语义。 +挂载:main.py include(B7)。错误体统一 ApiError → 手册 §10 结构(挂账④)。 + +T-9 起支持三型 trade_type(架构 §8.1): +- `subscribe` / `redeem`:`product_id` + `amount`(原有行为不变); +- `convert`:`from_product_id` + `to_product_id` + `qty`(+ 可选 `client_request_id`), + 交网关分派至 `convert_service`;**未抢到执行权 → 202**, + 其余业务结果(含 `blocked=true`)一律 200(HTTP 语义与 FR-1 一致)。 """ from __future__ import annotations from decimal import Decimal +from typing import Any from fastapi import APIRouter, Depends -from pydantic import BaseModel, Field +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field, model_validator from app.api.deps import AuthContext, deny, get_auth_context from app.gateway.trade_gateway import UnsupportedTradeType, submit_trade from app.repository.risk_repository import RiskRepository -from app.utils.exceptions import ApiError +from app.service.convert.convert_service import PROCESSING +from app.utils.exceptions import ApiError, NotFoundError +from app.utils.trace import HEADER_ID_PATTERN router = APIRouter(prefix="/api/simulate", tags=["simulate"]) +#: 走「网关直写 core_trade」的普通申赎类型;convert 有独立分支(T-9)。 +SIMPLE_TRADE_TYPES = ("subscribe", "redeem") + def _repo() -> RiskRepository: """审计仓储(deny 留痕用;测试 monkeypatch 点)。""" @@ -30,24 +42,92 @@ def _repo() -> RiskRepository: class TradeRequest(BaseModel): + """模拟交易请求(架构 §8.1)。 + + **字段按 `trade_type` 分池**(三型互斥,由 `_check_by_trade_type` 把关): + - `subscribe` / `redeem` → `product_id` + `amount`; + - `convert` → `from_product_id` + `to_product_id` + `qty`(+ 可选 `client_request_id`)。 + + `amount` / `qty` 的 `gt=0` **必须保留**(R5):pydantic v2 对 `None` 不触发 + 数值校验、对 `0`/负数触发 → 「`amount=0` → 422」用例零改动通过(2.13.4 已实测)。 + """ + customer_id: str = Field(..., min_length=1) - product_id: str = Field(..., min_length=1) - trade_type: str = Field(..., max_length=16, description="subscribe | redeem;convert 显式拒绝") - amount: Decimal = Field(..., gt=0, description="交易金额(元),必须为正数") + trade_type: str = Field(..., max_length=16, description="subscribe | redeem | convert") + + # ── subscribe / redeem 专用 ── + product_id: str | None = Field(None, description="subscribe/redeem 必填") + amount: Decimal | None = Field(None, gt=0, description="交易金额(元),必须为正数") + + # ── convert 专用 ── + from_product_id: str | None = Field(None, description="convert 必填:转出基金") + to_product_id: str | None = Field(None, description="convert 必填:转入基金") + qty: Decimal | None = Field(None, gt=0, description="convert 必填:申请转出份额") + client_request_id: str | None = Field( + None, + description="幂等键;白名单与 X-Trace-Id 共用同一份(S4,避免两套正则漂移)", + ) + + @model_validator(mode="after") + def _check_by_trade_type(self) -> TradeRequest: + """按 `trade_type` 分支校验(架构 §8.1)。 + + **未知类型不拦**:交网关抛 `UnsupportedTradeType` → 400,保持既有 + `purchase → 400` 断言(R3)。校验失败抛 `ValueError` → FastAPI 统一 422。 + """ + if self.trade_type in SIMPLE_TRADE_TYPES: + if self.product_id is None or self.amount is None: + raise ValueError(f"{self.trade_type} 需同时提供 product_id 与 amount") + elif self.trade_type == "convert": + if ( + self.from_product_id is None + or self.to_product_id is None + or self.qty is None + ): + raise ValueError("convert 需同时提供 from_product_id / to_product_id / qty") + if self.client_request_id is not None and not HEADER_ID_PATTERN.fullmatch( + self.client_request_id + ): + raise ValueError( + "client_request_id 仅允许字母、数字与 . _ - ,长度 1~64(同 X-Trace-Id 白名单)" + ) + return self @router.post("/trade") -def submit_trade_api(req: TradeRequest, auth: AuthContext = Depends(get_auth_context)) -> dict: - """模拟交易(FR-1):适当性阻断或放行+引擎判定,返回 blocked + trade_id。""" - if not (auth.has_role("risk_demo") or (auth.is_customer() and auth.customer_id == req.customer_id)): +def submit_trade_api( + req: TradeRequest, auth: AuthContext = Depends(get_auth_context) +) -> Any: + """模拟交易(FR-1):适当性阻断或放行 + 引擎判定,返回 blocked + trade_id。 + + convert(T-9):未抢到执行权 → **202** + `{convert_group_id, status}`; + 其余一切业务结果(含 `blocked=true`、`engine_error=true`)**均 200**。 + """ + if not ( + auth.has_role("risk_demo") + or (auth.is_customer() and auth.customer_id == req.customer_id) + ): deny( auth, "AUTH_403_ROLE", _repo(), customer_id=req.customer_id, message="risk_demo or owner customer only", agent_type="platform", # 网关越权与放行审计同口径(复审 P3) ) try: - return submit_trade(req.model_dump(), actor_id=auth.actor_id) + # exclude_none:convert 请求不带 product_id/amount,申赎请求不带 from/to/qty, + # 与改造前 `model_dump()` 的输出逐键等价(新字段全为 None 时被剔除)。 + result = submit_trade( + req.model_dump(exclude_none=True), actor_id=auth.actor_id + ) except UnsupportedTradeType as exc: raise ApiError(400, "BAD_REQUEST", str(exc)) from exc - except LookupError as exc: # NotFoundError 亦为其子类;已统一(B6 评审 P3-5) + except NotFoundError as exc: + # B6 评审 P3-5 的收敛锚点:服务层抛的是 `NotFoundError`(精确 404)。 + # T-9 由 `except LookupError` 收窄至此 —— `KeyError` 同为 `LookupError` + # 子类,原写法会把服务层「字段缺失」这类**编程错误静默转成 404** + # (本次实测:convert 分支 KeyError 被吞成 NOT_FOUND,掩盖真实诊断)。 raise ApiError(404, "NOT_FOUND", str(exc)) from exc + + if result.get("status") == PROCESSING: + # 架构 §8.3:并发同键请求正在执行 → 202 + {convert_group_id, status: "processing"} + return JSONResponse(status_code=202, content=result) + return result diff --git a/app/gateway/trade_gateway.py b/app/gateway/trade_gateway.py index 1410a18..f19c589 100644 --- a/app/gateway/trade_gateway.py +++ b/app/gateway/trade_gateway.py @@ -1,10 +1,16 @@ """交易网关服务(PRD FR-1 · 架构 §3.1)。 -submit_trade 为唯一入口:参数校验 → suitability_check(FR-2,落校验日志; -不匹配→ suitability 预警单 + 阻断响应,交易不落 core_trade)→ 匹配 → -INSERT core_trade → 同步调规则引擎(FR-3)→ 返回 blocked + trade_id + -触发规则。阻断/放行全量审计(agent_type='platform',FR-1 §6); -convert/未知类型属参数校验失败(400),不落审计(PRD 审计口径仅阻断/放行)。 +`submit_trade` 为唯一入口:**按 `trade_type` 分派**—— +- `subscribe` / `redeem`:参数校验 → suitability_check(FR-2,落校验日志; + 不匹配→ suitability 预警单 + 阻断响应,交易不落 core_trade)→ 匹配 → + INSERT core_trade → 同步调规则引擎(FR-3)→ 返回 blocked + trade_id + + 触发规则。阻断/放行全量审计(agent_type='platform',FR-1 §6); +- `convert`(T-9 起走通):交 `convert_service.convert_fund` 八步编排 + (含转入端适当性、幂等、三阶段、阶段 1.5)。**审计由该服务落 + `convert_request`**,本层不重复写 `trade_request`;未抢到执行权时 + 透传 `{status: "processing"}`,由路由层回 202(架构 §8.3)。 + +未知 `trade_type` 属参数校验失败(400),不落审计(PRD 审计口径仅阻断/放行)。 引擎异常兜底(架构 §5.3):交易已成立(core_trade 已提交),审计 decision='risk_engine_error' + logger.exception,响应带 engine_error=true @@ -22,9 +28,12 @@ from decimal import Decimal from typing import Any, Callable from uuid import uuid4 +from app.gateway.convert_core_repository import ConvertCoreRepository from app.gateway.gateway_repository import GatewayRepository +from app.repository.convert_repository import ConvertRepository from app.repository.core_ro import CoreReadOnlyRepository from app.repository.risk_repository import RiskRepository +from app.service.convert.convert_service import convert_fund from app.service.risk.engine import process_trade_event from app.service.risk.rules import RiskThresholds from app.service.risk.alert_service import record_suitability_alert @@ -34,13 +43,13 @@ from app.utils.trace import ensure_trace logger = logging.getLogger(__name__) SUPPORTED_TRADE_TYPES = ("subscribe", "redeem") -CONVERT_MESSAGE = "转换交易暂不支持,请分别发起申购/赎回" +CONVERT = "convert" ADVICE = "请联系持证投资顾问" RECORDED_NOTICE = "本次请求已记录" class UnsupportedTradeType(ValueError): - """trade_type 非法(convert 显式 400;未知类型兜底拒绝,PRD FR-1)。""" + """trade_type 非法(未知类型兜底拒绝,PRD FR-1;convert 自 T-9 起走通)。""" def _new_trade_id(now: datetime) -> str: @@ -83,6 +92,47 @@ def _audit( ) +def _submit_convert( + req: dict[str, Any], + *, + core: CoreReadOnlyRepository, + repo: RiskRepository, + thresholds: RiskThresholds, + actor_id: str | None, + now: datetime | None, +) -> dict[str, Any]: + """convert 分派(T-9 · 架构 §3.1/§8.3):只做参数映射 + 仓储装配。 + + **职责边界**:八步编排(①产品 ②份额 ③折算 ④转入端适当性 ⑤占位 + ⑥阶段一 ⑦阶段二 ⑧阶段 1.5)与 `convert_request` 审计**全部**在 + `convert_service.convert_fund` 内 —— 网关不重复校验、不重复审计、 + 不重复出单(否则一次转换会被记成 `trade_request` + `convert_request` 两条)。 + + `ConvertRepository` / `ConvertCoreRepository` **按模块级符号引用** + (与 `CoreReadOnlyRepository` 同款),测试经 + `monkeypatch.setattr(tg, "ConvertRepository", ...)` 注入 sqlite 实例。 + + 返回:走通 → PRD §5.3 响应体(Decimal 已全部 str);未抢到执行权 → + `{"status": "processing", "convert_group_id": None}`(路由层转 202)。 + """ + return convert_fund( + { + "customer_id": req["customer_id"], + "from_product_id": req["from_product_id"], + "to_product_id": req["to_product_id"], + "qty": req["qty"], + "client_request_id": req.get("client_request_id"), + }, + core_ro=core, + risk_repo=repo, + convert_repo=ConvertRepository(), + core_writer=ConvertCoreRepository(), + thresholds=thresholds, + now=now, + actor_id=actor_id, + ) + + def submit_trade( req: dict[str, Any], core_ro: CoreReadOnlyRepository | None = None, @@ -104,15 +154,23 @@ def submit_trade( """ core = core_ro or CoreReadOnlyRepository() repo = risk_repo or RiskRepository() - writer = gateway_repo or GatewayRepository() th = thresholds or RiskThresholds.from_settings() ensure_trace() trade_type = str(req.get("trade_type", "")) - if trade_type == "convert": - raise UnsupportedTradeType(CONVERT_MESSAGE) + if trade_type == CONVERT: + # convert 分派(T-9):不构造 GatewayRepository —— convert 的 core_trade + # 写路径在 `convert_core_repository.apply_convert`(阶段一单事务), + # 与普通申赎的 `gateway_repository.insert_trade` 是两条独立入口。 + return _submit_convert( + req, core=core, repo=repo, thresholds=th, actor_id=actor_id, now=now + ) if trade_type not in SUPPORTED_TRADE_TYPES: - raise UnsupportedTradeType(f"不支持的交易类型: {trade_type}(仅 subscribe/redeem)") + raise UnsupportedTradeType( + f"不支持的交易类型: {trade_type}(仅 subscribe/redeem/convert)" + ) + + writer = gateway_repo or GatewayRepository() now = now or datetime.now() trade_id = (trade_id_factory or _new_trade_id)(now) diff --git a/app/main.py b/app/main.py index f2f0c24..3a24036 100644 --- a/app/main.py +++ b/app/main.py @@ -14,7 +14,6 @@ audit_middleware;X-Request-Id 独立生成(B7 复审 P3-4)。 from __future__ import annotations import logging -import re from contextlib import asynccontextmanager from fastapi import FastAPI, Request @@ -31,6 +30,7 @@ from app.service.risk import redis_gateway from app.utils.db import dispose_engines from app.utils.response import error_body, register_error_handlers from app.utils.trace import ( + HEADER_ID_PATTERN, bind_request_id, new_trace, reset_request_id, @@ -40,9 +40,6 @@ from app.utils.trace import ( logger = logging.getLogger(__name__) -# 透传外部 X-Trace-Id 的格式白名单(防响应头注入;不合规一律新生成) -_TRACE_ID_PATTERN = re.compile(r"^[A-Za-z0-9._-]{1,64}$") - @asynccontextmanager async def lifespan(_: FastAPI): @@ -100,7 +97,7 @@ async def trace_middleware(request: Request, call_next): 直接产出统一错误体,保证 500 也带 X-Trace-Id/X-Request-Id。 """ incoming = request.headers.get("X-Trace-Id", "") - trace_id = incoming if _TRACE_ID_PATTERN.fullmatch(incoming) else new_trace() + trace_id = incoming if HEADER_ID_PATTERN.fullmatch(incoming) else new_trace() token = set_trace(trace_id) request_id, rid_token = bind_request_id(request.headers.get("X-Request-Id", "")) try: diff --git a/app/service/convert/convert_service.py b/app/service/convert/convert_service.py index 56f00ea..55a8600 100644 --- a/app/service/convert/convert_service.py +++ b/app/service/convert/convert_service.py @@ -33,7 +33,7 @@ from __future__ import annotations import logging from dataclasses import dataclass from datetime import date, datetime, timedelta -from decimal import Decimal +from decimal import ROUND_HALF_UP, Decimal from typing import Any, Callable from uuid import uuid4 @@ -81,6 +81,10 @@ logger = logging.getLogger(__name__) PROCESSING = "processing" CONFIRM_BASIS = "natural_day_approx" # T+1 用自然日近似(模拟库无交易日历,D14) +# ── 对外展示规格(**唯一定义点**,理由与外部依据见 `_q()` docstring)────────── +_D2 = Decimal("0.01") # 金额 · 份额 +_D4 = Decimal("0.0001") # 净值 · 费率 · 份额尾差 + @dataclass(frozen=True) class _Quote: @@ -114,9 +118,31 @@ def _new_id(prefix: str, now: datetime) -> str: return f"{prefix}-{now:%Y%m%d}-{uuid4().hex[:8].upper()}" -def _s(value: Decimal | None) -> str | None: - """响应字段一律字符串(架构 §1 原则 11);None 原样透出。""" - return None if value is None else str(value) +def _q(value: Decimal | None, unit: Decimal) -> str | None: + """按**展示规格**量化后转字符串(对外唯一出口;None 原样透出)。 + + 展示位数不是随手定的,取真实 TA 公告口径(PRD §2.5 · 架构 §1 原则 11): + + ================== ===== ================================================== + 字段类 位数 现实依据 + ================== ===== ================================================== + 金额(转出额/费用) 2 「转出金额以四舍五入的方式保留至小数点后两位」 + 份额(申请/转入) 2 「转入份额以四舍五入的方式保留至小数点后两位」; + 「申请转换份额精确到小数点后两位」 + 净值 4 份额净值保留 4 位、第 5 位四舍五入(估值环节完成) + 费率 4 公告以百分比 2 位表示(0.30% ↔ 0.0030) + 份额尾差 4 需与净值同级(PRD §5.3 示例 `-0.0026`) + ================== ===== ================================================== + + ⚠️ **库内一律 `DECIMAL(18,4)`**(TA 内部精度高于展示位),故展示必须经此处收敛 —— + 否则同一响应在「首次按公式算」与「按库重建」两条路径下会吐出两种位数 + (实测:首次 `53456.95` vs 重放 `53456.9500`)。 + 首次路径的值已由 `calc` 按同一规格量化 → 此处**幂等**(逐字节不变); + 重放路径的值直读 `DECIMAL(18,4)` → 由此处收敛。 + """ + if value is None: + return None + return str(to_decimal(value).quantize(unit, rounding=ROUND_HALF_UP)) def _audit( @@ -289,42 +315,42 @@ def _build_response( in_trade_id: str, engine_result: dict[str, Any] | None = None, ) -> dict[str, Any]: - """PRD §5.3 响应体(Decimal 全部转 str)。""" + """PRD §5.3 响应体(Decimal 全部按展示规格转 str,见 `_q()`)。""" lo, hi = quote.hold_days_range return { "blocked": False, "estimated": True, # T 日未知价法:申请时金额均为预估 "convert_group_id": group_id, "client_request_id": req.get("client_request_id"), - "requested_qty": _s(quote.plan.requested_qty), - "actual_qty": _s(quote.plan.actual_qty), + "requested_qty": _q(quote.plan.requested_qty, _D2), + "actual_qty": _q(quote.plan.actual_qty, _D2), "forced_full_transfer": quote.plan.forced_full_transfer, "min_hold_action": quote.plan.action, "out_trade_id": out_trade_id, - "out_nav": _s(quote.out_nav), - "out_amount": _s(quote.out_amount), + "out_nav": _q(quote.out_nav, _D4), + "out_amount": _q(quote.out_amount, _D2), "lot_count": len(quote.charges), "lot_breakdown": [ { "lot_id": c.lot_id, - "qty": _s(c.qty), + "qty": _q(c.qty, _D2), "hold_days": c.hold_days, - "fee_rate": _s(c.fee_rate), - "fee_amount": _s(c.fee_amount), - "nav": _s(c.nav), + "fee_rate": _q(c.fee_rate, _D4), + "fee_amount": _q(c.fee_amount, _D2), + "nav": _q(c.nav, _D4), } for c in quote.charges ], - "redeem_fee": _s(quote.redeem_fee), + "redeem_fee": _q(quote.redeem_fee, _D2), "in_trade_id": in_trade_id, - "in_nav": _s(quote.in_nav), - "convert_amount": _s(quote.convert_amount), - "diff_fee": _s(quote.diff_fee), - "in_amount": _s(quote.in_amount), - "in_qty": _s(quote.in_qty), - "rounding_diff": _s(quote.rounding_diff), - "out_subscribe_fee_rate": _s(quote.out_subscribe_fee_rate), - "in_subscribe_fee_rate": _s(quote.in_subscribe_fee_rate), + "in_nav": _q(quote.in_nav, _D4), + "convert_amount": _q(quote.convert_amount, _D2), + "diff_fee": _q(quote.diff_fee, _D2), + "in_amount": _q(quote.in_amount, _D2), + "in_qty": _q(quote.in_qty, _D2), + "rounding_diff": _q(quote.rounding_diff, _D4), + "out_subscribe_fee_rate": _q(quote.out_subscribe_fee_rate, _D4), + "in_subscribe_fee_rate": _q(quote.in_subscribe_fee_rate, _D4), "nav_date": str(quote.nav_date), "nav_stale": quote.nav_stale, "confirm_basis": CONFIRM_BASIS, @@ -446,7 +472,7 @@ def convert_fund( summary={ "from_product_id": req["from_product_id"], "to_product_id": req["to_product_id"], - "requested_qty": _s(quote.plan.requested_qty), + "requested_qty": _q(quote.plan.requested_qty, _D2), "block_reason": suit.block_reason, "block_response_code": suit.block_response_code, "reasons": list(suit.reasons), @@ -582,9 +608,9 @@ def convert_fund( "convert 阶段二失败(交易已成立,待补偿)group_id=%s quote=%s", group_id, { - "out_amount": _s(quote.out_amount), - "in_amount": _s(quote.in_amount), - "in_qty": _s(quote.in_qty), + "out_amount": _q(quote.out_amount, _D2), + "in_amount": _q(quote.in_amount, _D2), + "in_qty": _q(quote.in_qty, _D2), "out_trade_id": out_trade_id, "in_trade_id": in_trade_id, }, @@ -705,17 +731,17 @@ def _write_main_audit( summary={ "from_product_id": req.get("from_product_id"), "to_product_id": req.get("to_product_id"), - "requested_qty": _s(quote.plan.requested_qty), - "actual_qty": _s(quote.plan.actual_qty), + "requested_qty": _q(quote.plan.requested_qty, _D2), + "actual_qty": _q(quote.plan.actual_qty, _D2), "forced_full_transfer": quote.plan.forced_full_transfer, - "out_amount": _s(quote.out_amount), - "redeem_fee": _s(quote.redeem_fee), - "convert_amount": _s(quote.convert_amount), - "diff_fee": _s(quote.diff_fee), - "in_amount": _s(quote.in_amount), - "in_qty": _s(quote.in_qty), - "rounding_diff": _s(quote.rounding_diff), - "nav": _s(quote.in_nav), + "out_amount": _q(quote.out_amount, _D2), + "redeem_fee": _q(quote.redeem_fee, _D2), + "convert_amount": _q(quote.convert_amount, _D2), + "diff_fee": _q(quote.diff_fee, _D2), + "in_amount": _q(quote.in_amount, _D2), + "in_qty": _q(quote.in_qty, _D2), + "rounding_diff": _q(quote.rounding_diff, _D4), + "nav": _q(quote.in_nav, _D4), "nav_date": str(quote.nav_date), "nav_stale": quote.nav_stale, "estimated": True, diff --git a/app/utils/response.py b/app/utils/response.py index c0449cd..7bf06c5 100644 --- a/app/utils/response.py +++ b/app/utils/response.py @@ -13,6 +13,8 @@ main 与测试 app 共用 register_error_handlers。 from __future__ import annotations +from typing import Any + from fastapi import FastAPI, Request from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse @@ -25,8 +27,12 @@ from app.utils.trace import current_request_id, current_trace, new_trace, new_re _HTTP_ERROR_CODES = {404: "NOT_FOUND", 405: "METHOD_NOT_ALLOWED"} -def error_body(error_code: str, message: str) -> dict[str, str]: - """手册 §10 错误体(trace/request 缺失时兜底生成,保证响应可归因)。""" +def error_body(error_code: str, message: str) -> dict[str, Any]: + """手册 §10 错误体(trace/request 缺失时兜底生成,保证响应可归因)。 + + 返回 `dict[str, Any]`:基础四字段均为 str,但 `_api_error_handler` 会 + 继续合入异常的结构性附加字段(如 `batch_count`/`max_lots`,T-9)。 + """ tid = current_trace() or new_trace() rid = current_request_id() or new_request_id() return {"error_code": error_code, "message": message, "trace_id": tid, "request_id": rid} @@ -37,7 +43,14 @@ def register_error_handlers(app: FastAPI) -> None: @app.exception_handler(ApiError) async def _api_error_handler(request: Request, exc: ApiError) -> JSONResponse: - return JSONResponse(status_code=exc.status_code, content=error_body(exc.error_code, exc.message)) + body = error_body(exc.error_code, exc.message) + # T-9:合入结构性附加字段(架构 §8.3 契约)。 + # 仅 convert 的错误子类(`ConvertError`)带 `extra` 属性,既有 ApiError + # 无此属性 → `getattr` 取 None → 既有错误体**逐字节不变**。 + extra = getattr(exc, "extra", None) + if extra: + body.update(extra) + return JSONResponse(status_code=exc.status_code, content=body) @app.exception_handler(PermissionDenied) async def _permission_denied_handler(request: Request, exc: PermissionDenied) -> JSONResponse: diff --git a/app/utils/trace.py b/app/utils/trace.py index d8f9f99..374e13b 100644 --- a/app/utils/trace.py +++ b/app/utils/trace.py @@ -12,8 +12,11 @@ import re from contextvars import ContextVar, Token from uuid import uuid4 -# 透传外部 X-Trace-Id / X-Request-Id 的格式白名单(防响应头注入;不合规一律新生成) -_HEADER_ID_PATTERN = re.compile(r"^[A-Za-z0-9._-]{1,64}$") +# HTTP 头部 ID 格式白名单(防响应头注入;不合规一律新生成)。 +# **单点定义 · 三处共用**:X-Trace-Id(main 中间件)· X-Request-Id(bind_request_id) +# · client_request_id 幂等键(api/simulate,S4)。原先 main.py:44 另有一份同名副本 +# (`_TRACE_ID_PATTERN`,内容与本行完全相同)→ T-9 已收敛到此处,避免白名单漂移。 +HEADER_ID_PATTERN = re.compile(r"^[A-Za-z0-9._-]{1,64}$") _trace_id: ContextVar[str] = ContextVar("trace_id", default="") # 独立请求级 ID(T-02 · B7 复审 P3-4):trace_id 贯通链路,request_id 标识单次 @@ -58,7 +61,7 @@ def new_request_id() -> str: def bind_request_id(request_id: str | None) -> tuple[str, Token]: """绑定请求 request_id(合法透传否则生成);返回 (id, token) 供中间件复位。""" - rid = request_id if _HEADER_ID_PATTERN.fullmatch(request_id or "") else f"req-{uuid4().hex[:16]}" + rid = request_id if HEADER_ID_PATTERN.fullmatch(request_id or "") else f"req-{uuid4().hex[:16]}" return rid, _request_id.set(rid) diff --git a/docs/PRD/PRD-基金转换交易.md b/docs/PRD/PRD-基金转换交易.md index d0c7d79..23f4a3b 100644 --- a/docs/PRD/PRD-基金转换交易.md +++ b/docs/PRD/PRD-基金转换交易.md @@ -1,6 +1,6 @@ # PRD · 基金转换(convert)交易 -> 版本:**v0.9.1(费率分类修正 · 定稿)** · 日期:2026-09-10 +> 版本:**v0.9.2(展示位数补全 · 定稿)** · 日期:2026-09-10 > 分支:`risk-control-agent` > 状态:第 2 步产出。**v0.7 已冻结**(架构 v0.2 §0 查证发现 4 项合规硬伤,外审判定「❌ 不建议进入编码」) > → 回退修订出 **v0.8**(按处置表全量修订,33 条闭环)→ 架构 v1.0 独立评审**通过**后,回填 2 处契约出 **v0.9**。 @@ -33,6 +33,22 @@ > 把 `mixed` 的 `PROD-005827` 标成「主动偏股」并直接取到 0.8% 上限 —— **这是分类错误**。 > 五轮外审与我方逐条核对**只验证了「数值 ≤ 上限」,从未验证「该产品是否属于这一档」**。 > → 新增**自检第 12 问:类型与口径必须匹配**,并把该约束固化为**可执行断言 ⑧** + +> **v0.9.1 → v0.9.2 变更(展示位数补全 · 2026-09-10 执行期发现 + 联网核验)** +> +> | # | 变更 | 性质 | 依据 | +> | --- | --- | --- | --- | +> | 1 | **§2.5 拆为 2.5.1 计算精度 / 2.5.2 展示位数**,新增净值 4 位、费率 4 位、份额尾差 4 位、申请/逐批份额 2 位的**分类规格表** | 契约(**补缺位**) | 真实公告:净值保留 4 位第 5 位四舍五入(中欧/国泰「由 3 位提高至 4 位」);「申请转换份额精确到小数点后两位」(中银);费率以百分比 2 位表示 | +> | 2 | §5.3 示例 `requested_qty` / `actual_qty` / `lot_breakdown[].qty` **4 位 → 2 位** | 契约(示例订正) | 原示例与 §2.5「计算与对外展示按 2 位」**自相矛盾**(漏改);且「申请转换份额精确到小数点后两位」 | +> | 3 | §5.3 字段类型约定补「字符串位数不自由,按 §2.5.2 分类;两条产出路径必须逐字节一致」 | 说明(非契约) | 本次缺陷的防复发约束 | +> +> **根因(必须记住)**:v0.9.1 只规定了「金额/份额 2 位」,**净值、费率、申请份额的回显位数完全没定义**。 +> 契约缺位 → 实现只能「把 `Decimal` 直接 `str()` 出去」→ **位数随数据来源漂移**: +> 按公式算出的值是 2 位、按 `DECIMAL(18,4)` 库值回读的值是 4 位, +> 于是**同一逻辑响应出现 `"53456.95"` 与 `"53456.9500"` 两种写法**(数值相等、字符串不等)。 +> → 教训:**凡是 `str(Decimal)` 直接出网的字段,都必须先问「这个字段的展示位数写在哪」**; +> 没有答案就是契约缺位,必须在设计期补,而不是等执行期发现。 +> → 不统一是**真实现状**(易方达 ETF 场外份额取整数位、南方基金取截断),本期按主流口径并记入已知差异。 > (`scripts/dev/verify_convert_seed.py`,越档即 FAIL),不再依赖人工比对。 > > **变更门规(防版本循环)**:只有「**对外契约 / 表结构 / 枚举**」变更才回改本 PRD; @@ -193,19 +209,45 @@ B 亦为新近公告的主流写法。 > 一期**不模拟资金暂存账户路径**。真实 Core 接入后若需还原资金流, > 应改写入 `core_cash_flow`(该表已存在,见 `scripts/core/01-ddl.sql`)。 -### 2.5 舍入顺序与精度(第二轮评审第 10 条 + v0.8 合规校准) +### 2.5 舍入顺序与精度(第二轮评审第 10 条 + v0.8 合规校准 + **v0.9.2 展示位数补全**) **逐批次先舍入、后求和**(贴合真实 TA:每笔费用独立计算入账)。 +#### 2.5.1 计算精度(决定数值) + | 对象 | 精度 | 舍入方式 | 依据 | | --- | --- | --- | --- | | 每批次 `amount` / `fee_amount` | 2 位 | **四舍五入** | 真实公告:转出金额、赎回费、补差费均保留 2 位、第三位四舍五入 | | 汇总 `redeem_fee` | — | Σ(各批次**已舍入**的 `fee_amount`) | 第二轮第 10 条 | | **转入份额 `in_qty`** | **2 位** | **四舍五入(ROUND_HALF_UP)** | 真实公告一致:**「转入份额以四舍五入的方式保留至小数点后两位,由此产生的误差在转入基金的基金资产中列支」**(中银/东海/中欧/财通/鹏华) | +#### 2.5.2 展示位数(决定响应字符串 · **v0.9.2 新增**) + +> **为什么单列**:v0.9.1 及以前只规定了「金额/份额 2 位」,**未规定净值、费率、申请份额的回显位数**。 +> 契约缺位的直接后果是**同类响应按数据来源吐出两种位数**:走公式算出来的值是 2 位, +> 而按 `DECIMAL(18,4)` 库值回读的值是 4 位(实测 `"53456.95"` vs `"53456.9500"`,数值相等但字符串不等)。 +> 故展示位数必须**按字段分类集中定义**,实现见 `app/service/convert/convert_service.py::_q`。 + +| 字段类(响应字段) | 位数 | 舍入 | 现实依据(联网核验,2026-09-10) | +| --- | --- | --- | --- | +| 金额:`out_amount` / `redeem_fee` / `convert_amount` / `diff_fee` / `in_amount` / 逐批 `fee_amount` | **2** | 四舍五入 | 「转出金额以四舍五入的方式保留至小数点后两位,由此产生的误差在转出基金的基金资产中列支」(中银/人保/浦银安盛/中欧/南方/申万菱信一致) | +| 份额:`requested_qty` / `actual_qty` / `in_qty` / 逐批 `qty` | **2** | 四舍五入 | 「转入份额以四舍五入的方式保留至小数点后两位」(同上多家一致);「**申请转换份额精确到小数点后两位**,单笔转换份额不得低于 1000 份」(中银);「申购份数四舍五入,保留至 0.01 个基金份额」 | +| 净值:`out_nav` / `in_nav` / 逐批 `nav` | **4** | 四舍五入 | 份额净值**保留 4 位、第 5 位四舍五入**(中欧、国泰公告:由 3 位提高至 4 位);巨额赎回极端情形可保留 8 位 | +| 费率:`out_subscribe_fee_rate` / `in_subscribe_fee_rate` / 逐批 `fee_rate` | **4** | 四舍五入 | 公告以百分比 2 位表示(`0.30%` ↔ `0.0030`),小数形式即 4 位 | +| 份额尾差:`rounding_diff` | **4** | 四舍五入 | 需与净值同级才能表达尾差(§5.3 示例 `-0.0026`) | + +> **⚠️ 现实业务的已知不统一(本期按主流口径,不做多套)**:转入份额并非全行业统一 —— +> **易方达(ETF 场外)**为「保留到**整数位**」、**南方基金**为「两位以后**舍弃**」(截断而非四舍五入)。 +> 本期取**主流口径(2 位四舍五入)**并在 §10 记为已知差异;若未来接真实 TA,应按基金合同/管理人配置化。 + > **v0.8 变更**:v0.7 写「份额 4 位**向下舍去**」——**不真实**,已改为 **2 位四舍五入**。 > 影响:`in_qty` 示例值、`core_share_lot.remain_qty` 的计算口径。 -> **存储精度仍为 `DECIMAL(18,4)`**(真实 TA 内部精度高于展示位),**计算与对外展示按 2 位**。 +> **存储精度仍为 `DECIMAL(18,4)`**(真实 TA 内部精度高于展示位),**计算与对外展示按上表分类**。 + +> **v0.9.2 订正记录(2026-09-10)**:① 本节新增 **2.5.2 展示位数**;② §5.3 示例中 +> `requested_qty` / `actual_qty` / `lot_breakdown[].qty` 由 4 位改为 **2 位**(原示例与本节 +> 「计算与对外展示按 2 位」**自相矛盾**,属漏改);③ 订正触发点为 T-9 执行期发现的 +> 「幂等重放响应位数与首次不一致」缺陷(根因即本节契约缺位)。 **尾差处理(真实表述)**:舍入产生的误差**在基金资产中列支**(不是"归基金资产"的单向归属)。 本期**不建「基金资产」表**(第五轮第 12 条),响应可选返回 @@ -539,18 +581,18 @@ POST /api/simulate/trade "client_request_id": "CLI-20260909-0001", // ── 申请与实转 ── - "requested_qty": "50000.0000", - "actual_qty": "50000.0000", // 与 requested_qty 不等 = 发生过强制全转 + "requested_qty": "50000.00", // 份额 2 位(§2.5.2;「申请转换份额精确到小数点后两位」) + "actual_qty": "50000.00", // 与 requested_qty 不等 = 发生过强制全转 "forced_full_transfer": false, // ── 转出端(视同赎回)── "out_trade_id": "TRD-20260909-XXXXXXXX", - "out_nav": "1.0300", // PROD-110022 成交净值 + "out_nav": "1.0300", // PROD-110022 成交净值(净值 4 位,§2.5.2) "out_amount": "51500.00", // 原始转出额 = Σ(各批份额 × 1.0300),未扣赎回费 "lot_count": 2, // 跨 2 个批次,持有期不同 → 费率不同 "lot_breakdown": [ - {"qty": "30000.0000", "hold_days": 100, "fee_rate": "0.0050", "fee_amount": "154.50"}, - {"qty": "20000.0000", "hold_days": 3, "fee_rate": "0.0150", "fee_amount": "309.00"} + {"qty": "30000.00", "hold_days": 100, "fee_rate": "0.0050", "fee_amount": "154.50"}, + {"qty": "20000.00", "hold_days": 3, "fee_rate": "0.0150", "fee_amount": "309.00"} ], "redeem_fee": "463.50", // 154.50 + 309.00(逐批舍入后求和,非单一档) @@ -592,11 +634,14 @@ POST /api/simulate/trade > 因 Q8 已定选①(新增独立列),字段来源恒为 `core_product.subscribe_fee_rate`, > **不得取 `fee_rate`(管理费率,见 §4.2)**。 > -> **字段类型约定(v0.7 定稿)**:本响应用例中**所有金额、费率、份额字段一律 JSON 字符串** +> **字段类型约定(v0.7 定稿,v0.9.2 补位数)**:本响应用例中**所有金额、费率、份额字段一律 JSON 字符串** > (含 `lot_breakdown.fee_rate`、`out/in_subscribe_fee_rate`、各 `amount`/`qty`), > 对应后端 `Decimal`——Pydantic v2 对 `Decimal` 的默认序列化即为字符串, > 与真实接口返回一致;统一字符串化可避免浮点精度误差与前端解析歧义 > (若改 `out/in_subscribe_fee_rate` 为裸数值 `0.0030`,会与 `lot_breakdown.fee_rate: "0.0050"` 类型不一致)。 +> **字符串的位数不自由**:按 **§2.5.2 展示位数表**分类 —— 金额/份额 2 位、净值/费率/尾差 4 位。 +> **同一响应的两条产出路径(首次按公式算 / 按库 `DECIMAL(18,4)` 重建)必须逐字节一致**; +> 实现集中在 `app/service/convert/convert_service.py::_q`。 ### 5.4 错误码 diff --git a/docs/memory/2026-09-10.md b/docs/memory/2026-09-10.md index 047def5..598e125 100644 --- a/docs/memory/2026-09-10.md +++ b/docs/memory/2026-09-10.md @@ -274,3 +274,117 @@ T-7 落地时 `process_convert_event` 不存在 → `_run_engine` 走 `ImportErr **下一步 = T-9(`api/simulate.py` 模型与错误码 + `trade_gateway` convert 分派 · 关键路径)/ T-11(`core_tools` 与 `sum_trades_on_date` 汇总去重 · 依赖 T-8 已解锁)**。 + +--- + +## 深夜 · 基金转换线 T-9(API 模型 + 网关分派 · 关键路径)完成 + +**范围**:开发计划 §7.2 —— `api/simulate.py` 三型模型 + `trade_gateway` convert 分派 + 错误码映射。**至此 HTTP 层 convert 端到端走通**。 + +### 改码 5 处 + +- `app/utils/trace.py`:`_HEADER_ID_PATTERN` → **公开 `HEADER_ID_PATTERN`**(单点定义)。 + 执行期发现 `trace.py:16` 与 `main.py:44` **各有一份内容完全相同的正则** —— S4 要防的「白名单漂移」**其实已经发生**。 +- `app/main.py`:删掉那份重复副本 + `import re`,改 import 上面的常量。 +- `app/utils/response.py`:`_api_error_handler` 合入 `exc.extra`(`getattr` 取;既有 `ApiError` 无此属性 → 错误体逐字节不变)。 +- `app/gateway/trade_gateway.py`:移除 convert 显式拒绝;新增 `_submit_convert`(只做参数映射 + 仓储装配,模块级符号作 monkeypatch 注入点)。 +- `app/api/simulate.py`:`TradeRequest` 三型字段分池 + `@model_validator` 分支校验(`client_request_id` 复用同一白名单); + `model_dump(exclude_none=True)`;**`except LookupError` 收窄为 `except NotFoundError`**;`PROCESSING` → 202。 + +### ⚠️ 3 条与计划原文的出入(已写入开发计划 §7.2 执行记录) + +1. **正则落点**:计划写「复用 `main.py:44`」,但 `app.main` → `app.api.simulate` 单向导入链,反向 import 成环 + → 上移 `trace.py`。连带突破 §12 **R14「本次不动 main.py」**(中间件注册顺序未动,守卫用例仍绿)。 +2. **`gateway_repository.insert_trade` 加列:裁定不需要** —— convert 写路径在 `convert_core_repository.apply_convert`,该文件零改动。 +3. **R7 归 T-10**;**R16 零改动通过**。 + +### 另一条实施级发现(**已上报,未顺手改**) + +**幂等重放响应的数值位数与首次不一致**:首次走 `calc`(2 位)vs 重放 `_rebuild_quote` 直读 `DECIMAL(18,4)`(4 位) +→ `"53456.95"` vs `"53456.9500"`(**数值相等**)。违反 PRD「对外一律 2 位」展示契约,属 T-7 范畴。 +集成测试已改为**比数值不比字符串**并在 docstring 钉住偏差。 + +### 验证 + +- 新增 **`tests/test_convert_integration.py`(7 条真 MySQL 集成)**:端到端与 PRD §5.3 逐项吻合(调生产纯函数算期望,禁手算) + · 两条流水同组同前缀 · 持仓与批次如实变动(转出归零保留行 / 转入新建)· 明细 completed + 审计 + · 幂等重试不产生第二组 · 跨主体 400 · 未知类型 400。 +- `test_trade_gateway.py` **+17**(11 条错误码映射全表参数化含 `extra` 展开 · 202 · 200 透传 · 不写 `trade_request` 审计 · 3 条 422 分支)。 +- `test_integration_risk.py` **R15 处置**:端到端已迁入新文件;原槽位**未删除**,改造为 + `test_invalid_type_400_and_no_new_trade_audit`(改用 `purchase` 触发),保住「校验失败不落审计」不变量。 +- `pytest -q` → **696 passed / 3 skipped**(基线 672 **+24**,零回归)。 +- **突变验证 3 组**:① 关掉 convert 分派 → **21 条红**;② 关掉错误体 `extra` 展开 → **精准 1 条**; + ③ 关掉 `client_request_id` 正则 → **精准 1 条**。三处已恢复,`grep MUTATION-TEST app/` 为空。 +- **真库验证载体 = 集成测试本身**(非脚本):T-9 **不新增 SQL、不涉方言语义**,故无需另写 `verify_convert_*.py`; + 该文件经 `ensure_risk_demo_ready()` 在无 MySQL 环境整模块 skip,不炸 CI。 + +### 🔴 本日第二次误报「远程分支不见了」(教训) + +提交 T-8 时依据 `git branch -vv` / `git branch -r` 判定远程 `risk-control-agent` gone、仅剩 `main`/`dev`, +据此向用户报警。**结论错误** —— `git ls-remote --heads origin` 实测远程 `risk-control-agent` = **`fffb78a`,健在**; +`git branch -r` 只剩两个分支纯属**本地远程跟踪引用 stale**。 +**本日第 368-371 行已记录同款教训(`037ce7e` 误报),当日复发第二次** → 已升格为 +`.workbuddy/memory/MEMORY.md` 铁律 + `docs/memory/MEMORY.md` 交接清单第 7 问。 + +### 文档回写 + +开发计划(§0 速览标 ✅ + 基线 696 · **§7.2 DoD 全勾 + 执行记录 + 4 条裁定 + 1 条发现 + 3 组突变** · §12 R15 补实际处置 +并订正事件名 `trade_accepted`→`convert_accepted`)· 根 `交接文档.md` §B → **v2.0**(§0 导航 · §B 头部 · §B.1 状态与代码改动 · +**新增 §B.6.2 T-9 小节** · §B.6 任务树与基线)· `docs/memory/{MEMORY,TODO}` · `.workbuddy/memory/{MEMORY,2026-09-10}` · 本条。 + +**下一步 = T-10(普通申赎批次维护 · 改 `trade_gateway` 主流程 · **回归风险最大,先跑基线再动**)/ +T-11(`core_tools` 与 `sum_trades_on_date` 汇总去重 · 依赖 T-8 已解锁)**。 + +--- + +## 深夜 · T-9 收尾:**展示位数口径修复**(用户「你先改问题,按照贴近现实业务改」) + +**问题**:幂等重放响应的数值位数与首次不一致(首次 `"53456.95"` vs 重放 `"53456.9500"`,**数值相等、字符串不等**)。 + +### 根因不是 T-7 写错,是**契约缺位** + +§2.5 只规定了「金额/份额 2 位」,**净值、费率、申请份额的回显位数根本没定义** → +实现只能把 `Decimal` 原样 `str()` 出网 → **位数随数据来源漂移**: +- 首次路径走 `calc` 纯函数(已 2 位量化) +- 重放路径由 `_rebuild_quote` 从 `core_trade`/`core_convert_lot_detail`(`DECIMAL(18,4)`)重建后直读 + +### 联网核验(7 家管理人公告,2026-09-10) + +| 口径 | 依据 | +| --- | --- | +| 金额 2 位四舍五入 | 「转出金额以四舍五入的方式保留至小数点后两位」(中银/人保/浦银安盛/中欧/南方/申万菱信一致) | +| 份额 2 位四舍五入 | 「转入份额以四舍五入的方式保留至小数点后两位」;「**申请转换份额精确到小数点后两位**」(中银) | +| 净值 4 位 | 「份额净值保留 4 位、第 5 位四舍五入」(中欧/国泰公告:**由 3 位提高至 4 位**);巨额赎回极端可 8 位 | +| 费率 4 位 | 公告以百分比 2 位表示(`0.30%` ↔ `0.0030`) | +| ⚠️ **已知不统一** | **易方达(ETF 场外)份额取整数位**、**南方基金取截断**(非四舍五入)→ 本期取主流口径 + 记入 PRD §10 已知差异 | + +### 修复(贴近现实业务) + +`convert_service` 新增 **`_q(value, unit)` + `_D2`/`_D4` 规格常量**作**对外唯一出口**: +- **金额 / 份额 → 2 位**(`_D2`) +- **净值 / 费率 / 份额尾差 → 4 位**(`_D4`) +- 响应、审计 `summary`、异常日志**共用同一出口**;原 `_s()` 已全部替换(`grep _s(` 为空) +- 首次路径**幂等**:除 `requested_qty`/`actual_qty`/`lot[].qty` 由 4 位**补齐至 2 位**外逐字节不变 + +### 文档订正(用户「文档不准,你去联网查」) + +- **PRD → v0.9.2**:§2.5 拆 **2.5.1 计算精度 / 2.5.2 展示位数**(新增分类规格表 + 外部依据 + 已知差异); + §5.3 示例 `requested_qty`/`actual_qty`/`lot_breakdown[].qty` **4 位 → 2 位**(原示例与 §2.5「对外展示按 2 位」**自相矛盾**,属漏改); + §5.3 字段类型约定补「位数不自由 + 两条产出路径必须逐字节一致」;新增 v0.9.1→v0.9.2 变更表与根因 +- **架构 → v1.0.1**:§1 原则 11 补「`str()` 前必须按 §2.5.2 量化」 +- 开发计划 §7.2「发现」条改为「**已修复**」并补完整证据链;交接文档 §B.6.2 同步 + +### 验证 + +- 集成测试改用**逐字段逐字节比对**(`REPLAY_IDENTICAL_FIELDS` + `lot_breakdown` 整体相等) + + 新增**位数规格断言**(`test_convert_response_field_scales`,逐字段验 `split(".")[1]` 位数,并卡住 `requested_qty == "50000.00"`) +- `pytest -q` → **697 passed / 3 skipped**(零回归) +- **真库全复跑**:T-6 `verify_convert_apply` **24/24** · T-7 `verify_convert_service` **35/35** · T-8 `verify_convert_engine` **31/31** +- `calc_convert_demo.py` **15/15** 与 PRD §5.3 一致 +- **突变验证**:把 `_q()` 的量化去掉 → **2 条红**,`assert '50000.0000' == '50000'` 直接复现修复前现象;已恢复,`grep MUTATION-TEST` 为空 + +### 教训已固化 + +skill `design-doc-selfcheck` → **v1.2.0**:新增 **「二补 · 格式契约最低三问」** + **铁律 6(格式契约铁律)**: +**凡 `str(Decimal)` 直接出网的字段,先问「这个字段的展示位数写在哪」—— 没答案就是契约缺位; +存储精度 ≠ 展示精度;多条产出路径必须共用一个格式出口 + 写逐字节相等断言。** diff --git a/docs/memory/MEMORY.md b/docs/memory/MEMORY.md index db34e42..e48b1ec 100644 --- a/docs/memory/MEMORY.md +++ b/docs/memory/MEMORY.md @@ -11,7 +11,7 @@ **当前进度:** 需求与表设计已定 · **风控模块 B1~B9b 全部完成(M2 tag risk-m2),M4 复核已闭环(2026-09-07),风控阶段 B 正式完结** · **Wave 0 已完成(2026-09-07,经独立 AI 评审闭环)**:T-01 JWT 鉴权(auth_service Auth SDK + deps 工厂替换 + X-Agent-Type 准入矩阵)/ T-02 审计中间件(http_access + 独立 request_id + 4xx/500 统一错误体 + input_guard_log 双写)/ T-06 chat 最小闭环(POST /api/chat + 会话落库 + Redis 窗口)/ T-07 LangGraph StateGraph 骨架 + DeepSeek(无 key 降级)· **T-04 Core RO Tool 节点已完成(2026-09-07)**:app/tool/core_tools.py 三只读 Tool + tool_service(意图/归属校验/run_tool)+ 图 tool 节点 + agent_tool_call 落库 + `utils/authz.py` 公共鉴权留痕,**321 测试绿(首评+复审双闭环)**。风控阶段 C 已完成(2026-09-07,345 绿,tag `risk-m3`,A-6 对话线验收通过,独立 AI 评审 PASS P0=0)。T-03 输入防护已完成(2026-09-07,378 绿,独立 AI 评审 PASS with findings P0=0):app/service/input_guard.py 注入词表 45 条纯函数检测 + oversize 4000 + actor 级 Redis 固定窗口限流 30 次/分(fail-open);chat 链路顺序 = 鉴权→准入→空白→限流 429→注入/超长 400→归属→会话,被拒 fail-fast 不建会话,blocked 落 input_guard_log(ENUM 四值已用满)。**阶段一「对齐 main 基准」AL-01~AL-08 已完成(2026-09-07,逐项独立 commit bb244f4~b5fd52e):适当性判定换核为 main 的 core_ro.check_suitability(C×R 矩阵表数据驱动,match_result 五值/JR-AST-012/FM-01/FM-03/JR-AST-PRO 契约,SUIT-001~008 退役);risk_suitability_log 重建 21 列;Core 表加 is_hnw/风评七新列(expires_at);种子 33 客户/14 产品;全量 406 passed 0 failed 0 skipped + uvicorn 冒烟三端点通过;risk-m1 已补打(指向 3c07de6)**。**下一步:阶段一验收门(**合并 main 前的最终交付检查点,非分支开发阻塞**;用户浏览器目视确认 UI)→ AL-09 合并 main → AL-10 PRD v1.2 → AL-11 docx 登记 → 阶段二 C4~C6(**已完成并打 `risk-m4` tag**)/ 前端 React 多 Agent 入口(HashRouter `web/` init)。****开发在分支 `risk-control-agent`(与 origin/main 已分叉:领先 84 提交 / 落后 0,origin/main 为分支祖先;**分支已推送远程,origin/risk-control-agent 同步于 `1d00e53`,本地跟踪已建立**;**架构改进与稳定性加固 T-101~T-202 已于 2026-09-09 完成并推送 origin/risk-control-agent(2d0e2fa..f733fc2 快进,pytest 510 绿,较 503 基线 +7 例:T-201.3 双层锁 6 例 + T-202 trace 顺序守卫 1 例),含 Redis 双层分布式锁 T-201 与审计中间件 trace 顺序守卫 T-202,均为文档/告警/锁原语修正、接口契约与表结构零变更;远程已提示走 PR 合并 main;**合并(risk-control-agent → main)由合并执行人负责,不归用户管**——AI 只负责把分支推到远程 + 更新文档,不得主动发起 PR / 执行合并,移交执行人按《合并注意事项-风控模块并入main.md》操作。** 旧文档中的 `feature/risk` 为过时口径)。** -**⚡ 并行新线 · 基金转换(convert)交易(2026-09-10 设计+开发计划闭环,**第 5 步进行中:T-0 / T-0b / T-1 已完成**):** `PRD-风控监测Agent` FR-1 一期显式拒收 convert,本线将其放开。**AIcoding 第 1~4 步已完成**:PRD **v0.9.1 定稿**(33 条外审闭环 + 2 处架构回填 + **费率分类修正**)→ 架构 **v1.0 定稿**(独立评审通过,13 条建议 **0 悬空**,接受 10 / 修正性接受 3 / 驳回 0)→ 门控 **M-7 已满足** → **开发计划 v1.0 已产出(`docs/项目框架设计/开发计划-基金转换交易.md`,**1,047 行**)并经**四轮**独立子代理审核收敛(**12 条意见 → 接受 11 / 驳回 1(附实测证据)/ 0 悬空**)。**第 5 步进行中:T-0 + T-0b + T-1 + T-2 + T-2b + T-3 + T-4 + T-5 + T-6 + T-7 + T-8 已于 2026-09-10 完成(672 passed / 3 skipped;T-1 断言 8/8 PASS;T-2 纯函数包 7 文件 / 93 用例;T-2b 实算脚本 15/15 一致;T-6 真库 24/24;T-7 真库 35/35;T-8 真库 31/31)**,**下一步 = T-9(`api/simulate.py` + `trade_gateway` 分派 · 关键路径)/ T-11(汇总去重 · 依赖 T-8 已解锁)**。**开工前置两个阻断项(✅ 2026-09-10 均已完成)**:**T-0**(sqlite/MySQL 结构对齐:`core_holding` 列名+PK **+ 补 `core_product_nav`**,建库自校验,**同步改 2 处测试 INSERT 并补 3 个 NOT NULL 列**)与 **T-0b**(**DB 账号分离 D20**:`xh_core_ro` SELECT 全库 / `xh_core_rw` **4 表写**无 DELETE·DDL / `xh_agent_rw` **`audit_log` 只授 SELECT+INSERT**(不可改删);**`conftest.py` 四处 engine 已显式 `role="admin"`**)。**遗留环境操作**:`scripts/core/00-grant.sql` 需管理员执行一次 + 写 `.env`,未执行时 3 条权限断言自动 skip,不阻塞 T-1。设计资产五件套:`docs/PRD/PRD-基金转换交易.md` · `docs/项目框架设计/架构设计-基金转换交易.md` · **`docs/项目框架设计/开发计划-基金转换交易.md`(新)** · `评审待办-风控主架构与基金转换.md` · `基金转换-审查意见处置表.md`。**开工前必读开发计划 §1.4(15 条代码事实)/ §1.5(7 条实现级裁定 R-a~R-g)/ §12(16 条回归面)** —— 尤其是 **R-a(弃用方言 UPSERT)· R-b(流水写 redeem/subscribe 不写 convert)· R-e(conftest 用 admin)** 三条,不读必踩。**交接入口:项目根 `交接文档.md` §B(三线合并版唯一入口)**。合规基准 = 证监会公告〔2025〕22 号。 +**⚡ 并行新线 · 基金转换(convert)交易(2026-09-10 设计+开发计划闭环,**第 5 步进行中:T-0 / T-0b / T-1 已完成**):** `PRD-风控监测Agent` FR-1 一期显式拒收 convert,本线将其放开。**AIcoding 第 1~4 步已完成**:PRD **v0.9.2 定稿**(33 条外审闭环 + 2 处架构回填 + **费率分类修正** + **展示位数补全**)→ 架构 **v1.0.1 定稿**(独立评审通过,13 条建议 **0 悬空**,接受 10 / 修正性接受 3 / 驳回 0)→ 门控 **M-7 已满足** → **开发计划 v1.0 已产出(`docs/项目框架设计/开发计划-基金转换交易.md`,**1,047 行**)并经**四轮**独立子代理审核收敛(**12 条意见 → 接受 11 / 驳回 1(附实测证据)/ 0 悬空**)。**第 5 步进行中:T-0 + T-0b + T-1 + T-2 + T-2b + T-3 + T-4 + T-5 + T-6 + T-7 + T-8 + T-9 已于 2026-09-10 完成(697 passed / 3 skipped;T-1 断言 8/8 PASS;T-2 纯函数包 7 文件 / 93 用例;T-2b 实算脚本 15/15 一致;T-6 真库 24/24;T-7 真库 35/35;T-8 真库 31/31;T-9 真 MySQL 集成 8 条 + 11 条错误码映射 + 4 组突变验证 + **展示位数口径修复**)**,**下一步 = T-10(普通申赎批次维护 · 回归风险最大)/ T-11(汇总去重 · 依赖 T-8 已解锁)**。**开工前置两个阻断项(✅ 2026-09-10 均已完成)**:**T-0**(sqlite/MySQL 结构对齐:`core_holding` 列名+PK **+ 补 `core_product_nav`**,建库自校验,**同步改 2 处测试 INSERT 并补 3 个 NOT NULL 列**)与 **T-0b**(**DB 账号分离 D20**:`xh_core_ro` SELECT 全库 / `xh_core_rw` **4 表写**无 DELETE·DDL / `xh_agent_rw` **`audit_log` 只授 SELECT+INSERT**(不可改删);**`conftest.py` 四处 engine 已显式 `role="admin"`**)。**遗留环境操作**:`scripts/core/00-grant.sql` 需管理员执行一次 + 写 `.env`,未执行时 3 条权限断言自动 skip,不阻塞 T-1。设计资产五件套:`docs/PRD/PRD-基金转换交易.md` · `docs/项目框架设计/架构设计-基金转换交易.md` · **`docs/项目框架设计/开发计划-基金转换交易.md`(新)** · `评审待办-风控主架构与基金转换.md` · `基金转换-审查意见处置表.md`。**开工前必读开发计划 §1.4(15 条代码事实)/ §1.5(7 条实现级裁定 R-a~R-g)/ §12(16 条回归面)** —— 尤其是 **R-a(弃用方言 UPSERT)· R-b(流水写 redeem/subscribe 不写 convert)· R-e(conftest 用 admin)** 三条,不读必踩。**交接入口:项目根 `交接文档.md` §B(三线合并版唯一入口)**。合规基准 = 证监会公告〔2025〕22 号。 **仓库地图:** @@ -44,7 +44,7 @@ | `docs/项目框架设计/表设计/` | 已定 | Agent 共用 11 表 + agent 专用 SQL | | `docs/项目框架设计/Core模拟底座/` | 已定 | 无真实 Core 时的 L0 方案 | | `web/` | **不存在** | 前端 React 待 init | -| `docs/PRD/PRD-基金转换交易.md` | **已定稿(v0.9.1)** | 基金转换线需求权威(FR-C1~C16 / §4 表结构 / §5 接口 / §9 验收);⚠️ **代码未实现** | +| `docs/PRD/PRD-基金转换交易.md` | **已定稿(v0.9.2)** | 基金转换线需求权威(FR-C1~C16 / §4 表结构 / §5 接口 / §9 验收);⚠️ **代码未实现** | | `docs/项目框架设计/架构设计-基金转换交易.md` | **已定稿(v1.0)** | 基金转换实现依据(D1~D20 / §5 事务 / §11.1 DB 账号 / §15 任务 T-0~T-13);⚠️ **代码未实现** | | `docs/项目框架设计/开发计划-基金转换交易.md` | **已定稿(v1.0 · 2026-09-10)** | **基金转换实现计划**(T-0~T-13 逐任务改法 + DoD + §1.4 **十五条代码事实核对表** + §1.5 **七条实现级裁定 R-a~R-g** + §12 **十六条回归面** + 四轮审核记录 + **§3.3 第 0 批 + §4.1 第 1 批执行记录**);**T-0/T-0b/T-1 已实现**,T-2 起待实现 · **开工必读** | | 项目根 `交接文档.md` | **v3.0(2026-09-10 三线合并)** | **全仓唯一交接入口**(§0 公共层 + §A 风控主线 + §B 基金转换线 + §C 架构改进线)——**给下一会话 AI,读完即可开工**;⚠️ 在 `.gitignore:47` 内、**不入库**,是本地文件。`docs/交接文档-基金转换.md` / `docs/交接文档-架构改进.md` 为**历史留档,内容已过期,勿读** | @@ -70,7 +70,7 @@ **下一步开发(见 TODO):** **模块侧交付完毕(2026-09-07:全量 pytest 482 绿 + 接口实调验收通过——suitability/check 阻断+放行、simulate/trade 阻断、三条鉴权边界 401/403/403 契约零偏差;`risk-m1~m4` tag 齐)。合并 main 已移交合并执行人,操作手册《docs/项目框架设计/合并注意事项-风控模块并入main.md》(含基底锁定/20 冲突裁决/14 静默文件/三硬伤/合并后必测,实测数据编制)。模块侧开放项:chat 链路 risk_suitability_log.actor_id 落 SYSTEM 待评估 / 前端 React 多 Agent 入口(`web/` 未 init,归属待拍板)。**演示走查按 `docs/项目框架设计/演示SOP-风控模块.md`(debug 头通道仍有效;JWT 通道签发用 `scripts/dev/issue_dev_token.py`;演示库已按 AL-08 expires_at 新口径重灌)。知识库入库:`python scripts/kb/build_kb.py`(先启 Ollama;**Milvus 数据路径必须纯英文**——faiss 不支持中文路径,本机 .env 已配 C:/Users/YUAN/.jinrong/milvus/)。 -**另(2026-09-10 待办)**:① **基金转换线**第 5 步进行中(**T-0~T-8 已完成、672 绿,下一步 = T-9(API+网关分派)/ T-11(汇总去重)**;设计 + 开发计划均已闭环,入口 项目根 `交接文档.md` §B);② ~~架构改进线收尾~~ —— **2026-09-10 已闭环结项**:§7.2 七项手工冒烟补跑 **7/7 PASS**、冒烟残留按 SOP §2 重灌双库清除、全量 **510 passed** 复绿;**「`037ce7e` 未 push」的旧表述已作废**(实测 `git ls-remote`:远程 `risk-control-agent` = `fffb78a` = 本地 HEAD,`037ce7e` 在其祖先链上,早已推送;本地 `git branch -vv` 显示 `origin/risk-control-agent: gone` 只是远程跟踪引用失效,`git fetch` 即恢复,非远程分支被删)。入口 **项目根 `交接文档.md` §C**。 +**另(2026-09-10 待办)**:① **基金转换线**第 5 步进行中(**T-0~T-9 已完成、697 绿,下一步 = T-10(批次维护 · 回归风险最大)/ T-11(汇总去重)**;设计 + 开发计划均已闭环,入口 项目根 `交接文档.md` §B);② ~~架构改进线收尾~~ —— **2026-09-10 已闭环结项**:§7.2 七项手工冒烟补跑 **7/7 PASS**、冒烟残留按 SOP §2 重灌双库清除、全量 **510 passed** 复绿;**「`037ce7e` 未 push」的旧表述已作废**(实测 `git ls-remote`:远程 `risk-control-agent` = `fffb78a` = 本地 HEAD,`037ce7e` 在其祖先链上,早已推送;本地 `git branch -vv` 显示 `origin/risk-control-agent: gone` 只是远程跟踪引用失效,`git fetch` 即恢复,非远程分支被删)。入口 **项目根 `交接文档.md` §C**。 **禁止(改代码前必记):** Core 正式 C1~C5 不可被画像覆盖 · 审计表只 INSERT · 代理人草稿不外发 · 仅 R-02 可阻断交易 · 四 Agent 不互调 LLM。 @@ -181,7 +181,7 @@ RBAC 联调账号:scripts/dev/rbac-seed-reference.md | `docs/项目框架设计/` | 表结构、JWT 手册、Core 模拟、技术版本 | | `docs/业务记忆管理/` | Redis 短期 vs MySQL/Milvus/Neo4j 权威记忆 | | 项目根 `交接文档.md` §B | **基金转换线开工入口** —— 接手本线先读这一节(不必重读代码) | -| `docs/PRD/PRD-基金转换交易.md` · `docs/项目框架设计/架构设计-基金转换交易.md` | 基金转换需求与实现依据(**v0.9.1 / v1.0,代码未实现**) | +| `docs/PRD/PRD-基金转换交易.md` · `docs/项目框架设计/架构设计-基金转换交易.md` | 基金转换需求与实现依据(**v0.9.2 / v1.0.1,代码未实现**) | 缺 `docs/memory/*` 文件:按 project-memory-kit 同名补回,**禁止空模板盖进度**。 @@ -200,6 +200,7 @@ RBAC 联调账号:scripts/dev/rbac-seed-reference.md 3. 是否需 customer_id 归属与 JWT RBAC? 4. Core 是模拟库只读还是 agent 库读写? 5. 如何验证?(`python -m pytest` 全量(当前 **516 passed / 3 skipped**,基线 510)· uvicorn 启动 + /health · SQL / sync 脚本 · 对照 REQUIREMENTS 验收列) -6. **当前有哪两条并行线?**(① 风控/架构改进线:**已结项**(510 基线绿、§7.2 七项冒烟 7/7 PASS、`037ce7e` 已核实早已推送);② **基金转换线**:设计闭环,**第 5 步进行中 —— T-0~T-8 已完成(672 passed),下一步 = T-9 / T-11**)——动代码前先确认自己属于哪条线,别混淆前置条件。 +6. **当前有哪两条并行线?**(① 风控/架构改进线:**已结项**(510 基线绿、§7.2 七项冒烟 7/7 PASS、`037ce7e` 已核实早已推送);② **基金转换线**:设计闭环,**第 5 步进行中 —— T-0~T-9 已完成(697 passed),下一步 = T-10 / T-11**)——动代码前先确认自己属于哪条线,别混淆前置条件。 +7. **远程分支到底还在不在?**(**在**。`git ls-remote --heads origin` 实测 `refs/heads/risk-control-agent` = `fffb78a`。⚠️ **判断远程存亡只能用 `git ls-remote`**;本仓本地远程跟踪引用会失效(`git branch -r` 只剩 `dev`/`main`、`git branch -vv` 显示 `gone`),那是 stale ref,`git fetch` 即恢复 —— 2026-09-10 **同日误报两次**,勿再踩。) 大任务:FRAMEWORK/FLOW 与实现状态不符时先更新 memory 再编码(用户确认跳过除外)。 diff --git a/docs/memory/TODO.md b/docs/memory/TODO.md index 1a1e20f..8d6c544 100644 --- a/docs/memory/TODO.md +++ b/docs/memory/TODO.md @@ -7,7 +7,7 @@ **阶段一 AL-01~AL-08 与阶段二 C4~C6 均已完成(2026-09-07)**:全量 pytest **482 passed 0 failed 0 skipped**(真库集成)✓ · uvicorn 冒烟三端点 ✓ · 接口实调验收 ✓(2026-09-07:suitability/check 阻断+放行、simulate/trade 阻断、三条鉴权边界 401/403/403,契约零偏差)· risk-m1~m4 tag 齐。**合并 main 已移交合并执行人**(操作手册:《docs/项目框架设计/合并注意事项-风控模块并入main.md》,随分支上传),后续模块侧待办见下方。 -**⚡ 并行新线 · 基金转换(convert)**(2026-09-10):**设计 + 开发计划均已闭环** —— PRD **v0.9.1** + 架构 **v1.0** + 独立评审 13 条 **0 悬空**(接受 10 / 修正性接受 3 / 驳回 0),门控 **M-7 已满足**;**第 4 步开发计划 v1.0 已产出并经独立审核**(4 条意见全接受、**驳回 0**,含新增 2 条回归面 R15/R16 + R-c 双条修订);**第 5 步进行中**:**T-0 + T-0b + T-1 + T-2 + T-2b + T-3 + T-4 + T-5 + T-6 + T-7 + T-8 均已于 2026-09-10 完成**(**672 passed / 3 skipped**;T-1 断言 **8/8 PASS**、T-2 纯函数 **93 用例**、T-2b 实算 **15/15 一致**、T-6 真库 **24/24**、T-7 真库 **35/35**、**T-8 真库 31/31**),**下一步 = T-9(`api/simulate.py` + `trade_gateway` 分派 · 关键路径)/ T-11(`core_tools` 与 `sum_trades_on_date` 汇总去重 · 依赖 T-8 已解锁)**。两个阻断前置(**T-0** sqlite/MySQL 列名统一 + 建库自校验 · **T-0b** DB 账号分离 D20:`xh_core_ro`/`xh_core_rw`/`xh_agent_rw`)**均已落地**。**入口:项目根 `交接文档.md` §B(三线合并版唯一入口,读这一节即可开工)**;**开工前必读开发计划 §1.4(15 条代码事实)+ §1.5(8 条实现级裁定 R-a~R-h)+ §12(16 条回归面)**。 +**⚡ 并行新线 · 基金转换(convert)**(2026-09-10):**设计 + 开发计划均已闭环** —— PRD **v0.9.2** + 架构 **v1.0.1** + 独立评审 13 条 **0 悬空**(接受 10 / 修正性接受 3 / 驳回 0),门控 **M-7 已满足**;**第 4 步开发计划 v1.0 已产出并经独立审核**(4 条意见全接受、**驳回 0**,含新增 2 条回归面 R15/R16 + R-c 双条修订);**第 5 步进行中**:**T-0 ~ T-9 均已于 2026-09-10 完成**(**697 passed / 3 skipped**;T-1 断言 **8/8 PASS**、T-2 纯函数 **93 用例**、T-2b 实算 **15/15 一致**、T-6 真库 **24/24**、T-7 真库 **35/35**、**T-8 真库 31/31**、**T-9 真 MySQL 集成 8 条 + 4 组突变验证 + 展示位数修复**),**下一步 = T-10(普通申赎批次维护 · 回归风险最大)/ T-11(`core_tools` 与 `sum_trades_on_date` 汇总去重 · 依赖 T-8 已解锁)**。两个阻断前置(**T-0** sqlite/MySQL 列名统一 + 建库自校验 · **T-0b** DB 账号分离 D20:`xh_core_ro`/`xh_core_rw`/`xh_agent_rw`)**均已落地**。**入口:项目根 `交接文档.md` §B(三线合并版唯一入口,读这一节即可开工)**;**开工前必读开发计划 §1.4(15 条代码事实)+ §1.5(8 条实现级裁定 R-a~R-h)+ §12(16 条回归面)**。 ### 基金转换线待办(推荐顺序) @@ -16,11 +16,12 @@ - [x] **【T-0b · 阻断前置】DB 账号分离(D20)** —— **完成(2026-09-10)**:`scripts/core/00-grant.sql` 新建(3 账号逐表授权,**不进 reset.ps1**);`settings.py` +6 项;`db.py` 改 `get_engine(db, role)` + `_resolve_credentials`(缓存键 `(db, role)`,未配置回退 `mysql_user`);`core_ro`→`ro` · `gateway_repository`→`rw` · `risk_repository`/`session_repository`→`rw` 显式;`conftest.py` 4 处→`admin`(R-e)。**两处口径修正**:① `audit_log` 实授 **`SELECT, INSERT`**(字面「只授 INSERT」会剥夺读,致 `has_engine_error_audit`/`list_audit_events` 失权)② conftest 必须 admin。**遗留环境操作**:`00-grant.sql` 需管理员执行 + 写 `.env`,未执行时 3 条权限断言自动 skip - [x] **【T-1 · 第 1 批】DDL + 种子 + sqlite 同步** —— **完成(2026-09-10)**:`scripts/core/01-ddl.sql` 新建 `core_fee_rule`/`core_share_lot`/`core_convert_lot_detail` + `core_trade` 加 `convert_group_id`+索引 + `core_product` 加 8 列(`subscribe_fee_rate` 等)+ `fee_rate` 补 COMMENT;**新增 `07-seed-fee-rule.sql`**(14 产品 × 5 档,按 22 号文 §10)/ **`08-seed-share-lot.sql`**(58 行持仓 → 61 行批次,Σ remain_qty 恒等于 qty,CUST-9527 跨批次)/ **`09-seed-org.sql`**(管理人 + TA + 申购费率 + 最低持有余额);`reset.ps1` 追加 07/08/09;`02-mysql-agent专用.sql` 追加 `risk_convert_detail`(status ENUM 建表即 5 值);`tests/_ddl.py` 同步 4 表 + `REQUIRED_CONVERT_TABLES` 门禁。**验证**:新增 `scripts/dev/verify_convert_seed.py`(pymysql 等价 reset 流程 + 8 条断言)→ **8/8 PASS**;`pytest -q` → **516 passed / 3 skipped(零回归)**。**3 点需注意**:① mysql 不在 PATH → 用该脚本替代 reset.ps1;② `core_fee_rule` 读取走只读账号(T-6 遵守);③ ~~`PROD-005827` 费率分类口径差异(`mixed` vs 主动偏股)待裁定~~ → **已裁定并修正(PRD v0.9.1)**:`mixed` 归位 `0.0050`(其他混合型),主示例转入方改真主动偏股 `PROD-003095`,`09-seed-org.sql` 升 **v1.1** 按「管理人全产品线」重排,并新增断言 ⑧ 机器化卡口 - [x] **【T-2 · 第 2 批】`service/convert/` 纯函数包** —— **完成(2026-09-10)**:新建 `app/service/convert/` **7 文件**(`__init__` / `types`(`Lot`/`FeeRule`/`LotAllocation`/`PlanResult` frozen dataclass + 3 个归一工具)/ `calc`(`plan_lots`/`lot_amount`/`lot_fee`/`convert_amount`/`in_qty`/`rounding_diff`/`diff_fee`/`hold_days`/`ensure_batch_limit`)/ `fee`(`pick_fee_rate` 左闭右开)/ `nav`(`ensure_nav_ready`→503 / `is_stale`)/ `lot_bootstrap`(D18 单点,`crc32` 确定性偏移)/ `errors`(`ConvertError` + 11 子类))。**新增 `tests/test_convert_calc.py` 93 用例**(12 类:精度 HALF_UP 反向自证 / 分档边界 6-7-29-30-179-180-364-365 / FIFO 含同 `confirmed_at` tiebreak / 跨批计费 / 双口径 252.40 vs 253.91 / 强制全转与强制赎回 / 恰好等于阈值不触发 / **零剩余不触发(新裁定 R-h)** / PRD §5.3 全链自证 / T+1 起算 / 净值 503 与 stale 分家 / D18 确定性 / §8.3 错误码 / **纯函数零 IO 依赖断言**)。**验证**:`pytest -q` → **609 passed / 3 skipped(+93,零回归)**;`calc_convert_demo.py` → **15/15 与 PRD §5.3 一致**(退出码 0) -- [x] **【T-3 ~ T-8 已完成】** 仓储与锁(并行组 A:T-3 / T-4 / T-5 ✅)→ T-6 ✅(阶段一事务 · 真库 24/24)→ T-7 ✅(八步编排 · 真库 35/35)→ **T-8 ✅(规则引擎改造 · 真库 31/31;⭐ 阶段 1.5 从「跳过」变「真跑」)** -- [ ] **【T-9 起】剩余任务**:T-9(`api/simulate.py` 模型与错误码 + `trade_gateway` convert 分派)→ T-11(`core_tools` / `sum_trades_on_date` 汇总去重)→ **T-10 高风险单列** → T-12 → **T-13「50 并发压测 + 性能补录」**(最后跑,产出 PRD §9 第 18 条实测值)。**T-13 内部顺序**:先 50 并发压测 → 再性能实测补录 → 最后 PRD §5.3 数字回填(详见架构 §15 + 开发计划 §2~§10) +- [x] **【T-3 ~ T-9 已完成】** 仓储与锁(并行组 A:T-3 / T-4 / T-5 ✅)→ T-6 ✅(阶段一事务 · 真库 24/24)→ T-7 ✅(八步编排 · 真库 35/35)→ **T-8 ✅(规则引擎改造 · 真库 31/31;⭐ 阶段 1.5 从「跳过」变「真跑」)** → **T-9 ✅(API 模型 + 网关分派 · **HTTP 层 convert 已走通**;新增真 MySQL 集成 7 条 + 11 条错误码映射 + 3 组突变验证)** +- [ ] **【T-10 起】剩余任务**:**T-10 高风险单列**(普通申赎批次维护 · 改 `trade_gateway` 主流程 · 先跑基线再动)→ T-11(`core_tools` / `sum_trades_on_date` 汇总去重)→ T-12(补偿脚本)→ **T-13「50 并发压测 + 性能补录」**(最后跑,产出 PRD §9 第 18 条实测值)。**T-13 内部顺序**:先 50 并发压测 → 再性能实测补录 → 最后 PRD §5.3 数字回填(详见架构 §15 + 开发计划 §2~§10) +- [ ] **【待用户裁定】幂等重放响应的数值位数偏差**(T-9 执行期发现,**未顺手改**):首次响应 2 位(`calc` 量化)vs 重放响应 4 位(`core_trade` `DECIMAL(18,4)` 直读)→ `"53456.95"` vs `"53456.9500"`,**数值相等**,违反 PRD「对外一律 2 位」展示契约,属 T-7 `_rebuild_quote` 范畴。集成测试已「比数值不比字符串」并钉住偏差 > **第 0~2 批结果(2026-09-10)**:基线 **510 passed** → 批 0 后 **516 passed / 3 skipped**(+3 T-0 用例 +3 T-0b 引擎用例)→ 批 1(T-1)后**仍 516 passed / 3 skipped**(只加表与种子,未加用例 → **零回归**);**T-1 数据层断言 8/8 PASS**(含新增断言 ⑧:费率档 ↔ `product_type` 匹配,越档即 FAIL)→ 批 2(T-2 + T-2b)后 **609 passed / 3 skipped**(**+93 纯函数用例**,零回归);T-2b 实算脚本 15/15 与 PRD §5.3 一致(退出码 0)。 -> **下一步 = T-3**(`core_ro` 五个新方法:`get_nav_as_of` / `get_redeem_fee_rules` / `list_share_lots` / `sum_remain_qty` / `get_holding` + 新增 `app/repository/share_lot_repository.py`;DoD 见开发计划 §5.1)。**并行组 A 的 T-3 / T-4 / T-5 可同时开工**。 +> **下一步 = T-10**(普通申赎批次维护 · FR-C16 + D8 兜底补建 + `rebuild_lots.py`;**改 `trade_gateway` 主流程,回归风险最大 → 先跑基线再动**;DoD 见开发计划 §8)。**可并行**:T-11(`core_tools` / `sum_trades_on_date` 汇总去重 · 依赖 T-8 已解锁)。 > ⚠️ **基金转换的 T-0b 与下方「架构改进第 3/4 批」的 `core_ro` 只读账号是同一件事** —— 已由本线定案为 D20,**不再挂在架构改进线**(该线原「不要做」清单已更新)。 diff --git a/docs/项目框架设计/开发计划-基金转换交易.md b/docs/项目框架设计/开发计划-基金转换交易.md index 69babf8..8a25f89 100644 --- a/docs/项目框架设计/开发计划-基金转换交易.md +++ b/docs/项目框架设计/开发计划-基金转换交易.md @@ -124,17 +124,17 @@ T-7 幂等窗口 · T-13 的 50 并发压测与性能补录 · PRD §5.3 实算 | **第 3 批 · 事务与编排** | **T-6** ✅ | `convert_core_repository.apply_convert`(阶段一单事务)—— **真库 24/24** | T-1/T-3 | **高(方言 + 并发)** | | | **T-7** ✅ | `convert_service` 编排(八步 + 执行权 + 幂等 + 三阶段 + 阶段 1.5)—— **17 用例 + 真库 35/35** | T-2~T-6 | **高(关键路径)** | | **第 4 批 · 引擎与网关** | **T-8** ✅ | `_amount_view` + `engine.process_convert_event` + `alert_service.events` —— **2026-09-10 完成(15 用例 + 真库 31/31)** | 无(可与 T-2 并行) | 中 | -| | T-9 | `api/simulate.py` 模型与错误码 + `trade_gateway` convert 分派 | T-7 | 中 | +| | **T-9** ✅ | `api/simulate.py` 模型与错误码 + `trade_gateway` convert 分派 + **展示位数口径修复** —— **2026-09-10 完成(新增集成 8 条 / 11 条错误码映射)** | T-7 | 中 | | | T-11 | `core_tools` 汇总去重 + 持仓 `qty <= 0` 过滤 + `sum_trades_on_date` 去重 | T-8 | 中 | | **第 5 批 · 高风险专项** | **T-10** | 普通申赎批次维护(FR-C16,含 D8 兜底补建)+ `rebuild_lots.py` | T-3(排在 T-7 后) | **最高(打穿 510)** | | **第 6 批 · 补偿** | T-12 | `rebuild_alerts --convert-group` + `cleanup_pending_convert.py` | T-4/T-7 | 低 | | **第 7 批 · 收口** | T-13 | 全量回归 + 集成测试 + 50 并发压测 + 性能实测补录 | 全部 | 中 | -**关键路径**:`T-0 → T-1 → T-2 → T-6 → T-7 → T-13`(**T-7 已通,T-9 已解锁**) +**关键路径**:`T-0 → T-1 → T-2 → T-6 → T-7 → T-13`(**T-7 已通;T-9 已完成**) **并行组 A**:T-3 / T-4 / T-5(✅ 全部完成) **并行组 B**:T-8 全程可与 T-2 之后任意任务并行(✅ 已完成) **硬门禁**:`T-0` 与 `T-0b` **双双绿**才允许启动 T-1 及之后(T-0 用例 = `test_db.py::test_core_holding_columns`) -**测试基线**:**672**(2026-09-10 T-8 后;批 0~3 路线 510 → 516 → 609 → 634 → 639 → 656 → **672**)→ 剩余任务(T-9~T-13)预计再加 **25~55** → **700~730**(估算) +**测试基线**:**697**(2026-09-10 T-9 后;批 0~3 路线 510 → 516 → 609 → 634 → 639 → 656 → 672 → **697**)→ 剩余任务(T-10~T-13)预计再加 **10~40** → **707~737**(估算) --- @@ -1090,14 +1090,85 @@ sqlite 无 gap lock,故该分支由 `tests/test_convert_core.py` 用注入点 - **不改路由**:仍为 `POST /api/simulate/trade`(F-12/§1.2,路由清单断言零影响) **DoD** -- [ ] `tests/test_trade_gateway.py` 的 4 处断言按 §12 清单改写完毕(R3/R4/R5 + R7 补批次断言) -- [ ] `tests/test_integration_risk.py:426-437` 的 `test_convert_400_and_no_new_trade_audit` 按 §12 **R15** 处置(改写为端到端走通,或迁入 `test_convert_integration.py`) -- [ ] `test_convert_integration.py`:真 MySQL `CNV-TEST-`/`TRD-TEST-` 前缀隔离,端到端折算与 PRD §5.3 示例逐项吻合(~6 条) -- [ ] 错误码映射 8 条各有断言(含 `CROSS_ENTITY_NOT_SUPPORTED`,验收 14) -- [ ] 未知 trade_type(如 `purchase`)仍 400 +- [x] `tests/test_trade_gateway.py` 的 4 处断言按 §12 清单改写完毕(**R3/R4/R5 已改;R7 归 T-10**,见执行记录裁定③) +- [x] `tests/test_integration_risk.py:426-437` 的 `test_convert_400_and_no_new_trade_audit` 按 §12 **R15** 处置(端到端迁入 `test_convert_integration.py`;原槽位改造为 `test_invalid_type_400_and_no_new_trade_audit`,用 `purchase` 触发以保住「校验失败不落审计」不变量) +- [x] `test_convert_integration.py`:真 MySQL `CNV-TEST-`/`TRD-TEST-` 前缀隔离,端到端折算与 PRD §5.3 示例逐项吻合 + **重放逐字节一致** + **展示位数规格**(**8 条**) +- [x] 错误码映射 **11 条**各有断言(架构 §8.3 全表 + `extra` 展开;含 `CROSS_ENTITY_NOT_SUPPORTED`,验收 14) +- [x] 未知 trade_type(如 `purchase`)仍 400 **依赖**:T-7 +**执行记录(2026-09-10 · 已完成)** + +*改码 5 处* + +| 文件 | 动作 | +| --- | --- | +| `app/utils/trace.py` | 【改】`_HEADER_ID_PATTERN` → **公开 `HEADER_ID_PATTERN`**(单点定义,三处共用) | +| `app/main.py` | 【改】删掉第 44 行那份**重复副本** `_TRACE_ID_PATTERN` 与 `import re`,改为 import 上面的常量 | +| `app/utils/response.py` | 【改】`_api_error_handler` 合入 `exc.extra`(`getattr` 取,既有 `ApiError` 无此属性 → 错误体逐字节不变);`error_body` 返回类型放宽 `dict[str, Any]` | +| `app/gateway/trade_gateway.py` | 【改】移除 convert 显式拒绝;新增 `_submit_convert` 分派(参数映射 + 仓储装配,模块级符号作 monkeypatch 注入点);`UnsupportedTradeType` 文案含 convert | +| `app/api/simulate.py` | 【改】`TradeRequest` 三型字段分池 + `@model_validator` 分支校验(`client_request_id` 走同一白名单);`model_dump(exclude_none=True)`;`except LookupError` **收窄为 `except NotFoundError`**;`PROCESSING` → 202 | + +*实施级裁定 3 条(与计划原文有出入,逐条给理由)* + +1. **正则落点:上移 `trace.py`,不是「复用 `main.py:44`」**。计划关键实现点写的是复用 `main.py` 的 `_TRACE_ID_PATTERN`, + 但 `app.main` → `app.api.simulate` 是**单向导入链**,反向 import 成环。上移到 `trace.py`(叶子工具模块、零 app 依赖) + 反而更彻底地满足 S4 的**真实意图**——执行期发现 `trace.py:16` 早有**内容完全相同**的一份副本, + `main.py:44` 是第二份,「两套白名单漂移」**其实已经发生**;现收敛为单点定义。 + ⚠️ 连带影响:§12 **R14 写的「本次不动 `main.py`」在此被突破**(中间件**注册顺序未动**, + `test_main.py` 中间件守卫用例仍绿,R14 的实际保护对象未受影响)。 +2. **`gateway_repository.insert_trade` 增 `qty`/`convert_group_id`:裁定为不需要**。 + convert 的 `core_trade` 写路径在 `convert_core_repository.apply_convert`(阶段一单事务), + 与普通申赎的 `insert_trade` 是两条独立入口,故该文件**零改动**(`git diff` 可证)。 +3. **R7「补批次断言」归 T-10**。R7 的失效前提是 T-10 改 `trade_gateway` 主流程, + T-9 不动该主流程,故 R7 计入 T-10 结项(§8 已列)。 + 同理 **R16 零改动通过**(`test_redeem_accepted_without_alert` 未改,全量绿可证)。 +4. **`lot_count` 与 `batch_count` 是两个不同位置的字段名,均**按文档保留,**不是笔误**: + 成功响应(PRD §5.3 权威命名)用 **`lot_count`**;错误体 `TOO_MANY_LOTS`(架构 §8.3 契约)用 **`batch_count` + `max_lots`**。 + 本节「关键实现点」把两者并列为 `batch_count`/`max_lots` 属**口语化表述**(把成功响应的批次数字段也写成了 `batch_count`)。 + 实现与前缀保持一致 —— **成功看 PRD、报错看架构 §8.3**,勿「统一」成同一个名字(会同时打破两份契约)。 + +*实施级发现 1 条 —— **已按用户指令修复(2026-09-10,同日)*** + +**幂等重放响应的数值位数与首次响应不一致**(发现 → 上报 → 修复,全程留痕): + +- **现象**:同一逻辑响应出现两种写法 —— 首次 `"53456.95"`(2 位)vs 重放 `"53456.9500"`(4 位),**数值相等、字符串不等**。 + 影响字段:`requested_qty` / `actual_qty` / `out_amount` / `redeem_fee` / `in_amount` / `in_qty` / `lot_breakdown[]`。 +- **根因(不是 T-7 写错,是契约缺位)**:首次响应走 `calc` 纯函数(已按 2 位量化),重放响应由 + `_rebuild_quote` 从 `core_trade` / `core_convert_lot_detail`(**`DECIMAL(18,4)`**)重建后**直接 `str()` 出去**。 + §2.5 只规定了「金额/份额 2 位」,**净值、费率、申请份额的回显位数根本没有定义** → + 实现只能把 `Decimal` 原样转串 → **位数随数据来源漂移**。 +- **修复(贴近现实业务,联网核验后定口径)**:新增 **`convert_service._q(value, unit)` + + `_D2` / `_D4` 两个规格常量**作为**对外唯一出口**,按字段语义量化: + **金额/份额 2 位**(公告「转出金额/转入份额四舍五入保留至小数点后两位」;「申请转换份额精确到小数点后两位」)、 + **净值/费率/份额尾差 4 位**(净值保留 4 位第 5 位四舍五入,中欧/国泰公告由 3 位提高至 4 位;费率以百分比 2 位表示)。 + 响应、审计 `summary`、异常日志**共用同一出口**。首次路径的输入已由 `calc` 按同规格量化 → **幂等**, + 除 `requested_qty`/`actual_qty`/`lot[].qty` 由 4 位补齐至 2 位外**逐字节不变**。 +- **外部依据(2026-09-10 联网核验,7 家管理人公告)**:中银 / 人保 / 浦银安盛 / 中欧 / 南方 / 申万菱信 / 易方达。 + **已知不统一**:易方达(ETF 场外)份额取**整数位**、南方基金取**截断**(非四舍五入)→ 本期按主流口径, + 并已在 PRD §2.5.2 / §10 记为已知差异(未来接真实 TA 需按基金合同配置化)。 +- **文档订正**:PRD → **v0.9.2**(§2.5 拆 2.5.1 计算精度 / **2.5.2 展示位数**,新增分类规格表 + 外部依据; + §5.3 示例份额字段 4 位 → 2 位,**修掉示例与 §2.5 的自相矛盾**;§5.3 字段类型约定补「位数不自由」) + · 架构 → **v1.0.1**(§1 原则 11 补「`str()` 前必须按 §2.5.2 量化」)。 +- **验证**:集成测试改用**逐字段逐字节比对**(`test_convert_idempotent_retry_returns_byte_identical_response`) + + 新增**位数规格断言**(`test_convert_response_field_scales`);`pytest -q` → **697 passed / 3 skipped(零回归)**; + T-7 真库脚本复跑 **35/35**;`calc_convert_demo.py` **15/15**; + **突变验证**:把 `_q()` 的量化去掉 → **2 条红**,`assert '50000.0000' == '50000'` 直接复现修复前现象。 + +*测试与验证* + +- `pytest -q` → **697 passed / 3 skipped**(基线 672 **+25**:`test_trade_gateway.py` +17、新增 `test_convert_integration.py` +8,**零回归**) +- **突变验证(3 组,防假绿)**:① 关掉 convert 分派(改回拒绝)→ **21 条变红**(7 集成 + 11 错误码 + 3 其余); + ② 关掉错误体 `extra` 展开 → **精准 1 条**(`TOO_MANY_LOTS` 体); + ③ 关掉 `client_request_id` 正则校验 → **精准 1 条**(`..._bad_client_request_id_returns_422`)。三处均已恢复,`grep MUTATION-TEST app/` 为空。 +- **真库验证载体 = `tests/test_convert_integration.py` 本身**(真 MySQL、非脚本)。 + 理由:T-9 **不新增任何 SQL / 不涉方言语义**(不触碰 SET 求值顺序、DECIMAL 写入精度、gap lock), + 故无需另写 `scripts/dev/verify_convert_*.py`;集成测试经 `ensure_risk_demo_ready()` + 在无 MySQL 环境整模块 skip,**不炸 CI**(与 `test_integration_risk.py` 同约定)。 +- 集成测试隔离三条件(自建 `CNVTEST` 种子 + `CNV-TEST-`/`TRD-TEST-` 前缀 + teardown 全清)已落在文件 docstring, + 规避了 `risk_demo_env` **不还原 `core_share_lot`/`core_holding`** 的已知坑。 + --- ### 7.3 T-11 · 工具汇总去重 + SQL 求和去重 @@ -1244,7 +1315,7 @@ sqlite 无 gap lock,故该分支由 `tests/test_convert_core.py` 用注入点 | **R12** | `app/service/risk/rules.py:78-83` `_eligible` | `trade_type in ('subscribe','redeem')` | convert 两条流水若是 `convert` 类型 → 被过滤 | **不改 `_eligible`**;改为**约束写入端**(R-b:流水写 `redeem`/`subscribe`) | | **R13** | `app/repository/core_ro.py:390/432` | `trade_type IN ('subscribe','redeem')` 硬写 | —(不需改:convert 两条流水本就是这两类) | **不改**(`:432` 的求和在 R-d 中另加 gid 条件) | | **R14** | `app/main.py:88/94` 中间件顺序 | audit 先注册 / trace 后注册(T-202 守卫) | 本次不动 `main.py` | **零改动**(若因 T-0b 误改 `main.py`,`test_main.py` 的守卫用例会红 → 属自发现) | -| **R15** | `tests/test_integration_risk.py:426-437` `test_convert_400_and_no_new_trade_audit` | convert → **400** + `BAD_REQUEST` + **不落审计**(`after["n"] == before["n"]`) | T-9 移除 convert 拒绝后,**状态码与审计计数双重失败** | **改写为端到端走通用例**(`convert → 200` + 断言落一条 `trade_accepted` 审计),或**整体迁入** `test_convert_integration.py` 后从本文件删除。
※ 本条为**第一轮审核补入**(初版 §12 漏列该真 MySQL 集成用例) | +| **R15** | `tests/test_integration_risk.py:426-437` `test_convert_400_and_no_new_trade_audit` | convert → **400** + `BAD_REQUEST` + **不落审计**(`after["n"] == before["n"]`) | T-9 移除 convert 拒绝后,**状态码与审计计数双重失败** | **改写为端到端走通用例**(`convert → 200` + 断言落一条 `convert_accepted` 审计;
※ 计划初稿写的 `trade_accepted` 是误写,**实际决策字面量为 `convert_accepted`**,T-9 执行期以代码为准更正),或**整体迁入** `test_convert_integration.py` 后从本文件删除。
※ 本条为**第一轮审核补入**(初版 §12 漏列该真 MySQL 集成用例)。
✅ **T-9 实际处置**:端到端已迁入 `test_convert_integration.py`;原槽位**未删除**,改造为 `test_invalid_type_400_and_no_new_trade_audit`(改用 `purchase` 触发),以保住原用例真正保护的不变量「校验失败不落审计」 | | **R16** | `tests/test_trade_gateway.py:145-154` `test_redeem_accepted_without_alert` | redeem 1000 元放行、`core_trade` 计 1 条、无预警 | T-10 给 redeem 加 FIFO 扣减后,`env` **无持仓无批次**(F-13)→ 无 R-c(1) 降级则直接失败 | 由 **R-c(1)「redeem 既无批次也无持仓 → warning 跳过扣减」** 保住,**该用例零改动**;其**真实扣减路径**由 `test_share_lot.py` 自建种子覆盖(R-c(2))
※ 本条为**第一轮审核补入** | --- diff --git a/docs/项目框架设计/架构设计-基金转换交易.md b/docs/项目框架设计/架构设计-基金转换交易.md index 5a327b1..8608770 100644 --- a/docs/项目框架设计/架构设计-基金转换交易.md +++ b/docs/项目框架设计/架构设计-基金转换交易.md @@ -1,6 +1,6 @@ # 架构设计说明书 · 基金转换(convert)交易 -> 版本:**v1.0(PRD v0.9 配套 · 已过独立评审)** · 日期:2026-09-10 +> 版本:**v1.0.1(PRD v0.9.2 配套 · 已过独立评审;v1.0.1 仅补 §1 原则 11 展示位数,无结构变更)** · 日期:2026-09-10 > 上游:`docs/PRD/PRD-基金转换交易.md`(**v0.9** —— v0.8 经 33 条外审闭环;v0.9 为架构评审回填 2 处契约) > 分支:`risk-control-agent` · 关联:`docs/项目框架设计/架构设计-风控模块.md` > 技术选型不变:FastAPI + SQLAlchemy Core(text SQL) + MySQL 双库 + Redis + LangGraph + DeepSeek。 @@ -122,6 +122,11 @@ 舍入误差**在基金资产列支**,响应可选回传 `rounding_diff`(**可正可负**)。 ⚠️ Python `Decimal.quantize()` 默认 `ROUND_HALF_EVEN`(银行家舍入),**必须显式传 `ROUND_HALF_UP`**(D·红线) 11. **响应金额全部 `str()` 化**:路由未声明 `response_model` 时 FastAPI `jsonable_encoder` 会把 `Decimal` 转 float(精度风险)。convert 分支返回前全部显式 `str()` + - **(v1.0.1 补充)`str()` 之前必须按 §2.5.2 展示位数量化**:库内一律 `DECIMAL(18,4)`, + 直接 `str()` 会让**位数随数据来源漂移** —— 按公式算出的值是 2 位、按库值回读的值是 4 位, + 同一逻辑响应出现 `"53456.95"` 与 `"53456.9500"` 两种写法(实测缺陷)。 + 展示规格**单点定义**在 `convert_service._q()`(金额/份额 2 位、净值/费率/尾差 4 位), + 响应、审计 summary、日志**共用同一出口**,保证「首发」与「幂等重放」逐字节一致。 12. **单次转换最多跨 `convert_batch_max_lots` 个批次**(默认 200):超限返回 400 `TOO_MANY_LOTS`,防事务膨胀。 一期**不做自动分拆**(一次请求 = 一个 `convert_group_id` = 一个 core 事务);自动拆成多笔留二期(评审 R4) 13. **Core 读写账号物理分离**(主架构评审 **C1** / §三 交叉点 · **已拍板「按真实项目走」**): diff --git a/tests/test_convert_integration.py b/tests/test_convert_integration.py new file mode 100644 index 0000000..5d94727 --- /dev/null +++ b/tests/test_convert_integration.py @@ -0,0 +1,486 @@ +"""T-9 真 MySQL 端到端集成测试(架构 §8/§10 · 开发计划 §7.2 DoD · §12 R15 迁入)。 + +链路:`TestClient(main app)` → HTTP → `trade_gateway` 分派 → `convert_service` +八步编排 → 真 `jinrong_core` / `jinrong_agent`。 + +**为什么必须有这一层**:单测层只验到「路由层不加工、原样透传」(见 +`test_trade_gateway.py`),而折算数字是否与 PRD §5.3 逐项吻合、两条流水是否 +真的同组同事务、持仓/批次是否真的扣减——只有真库能证明。 + +隔离策略(架构 §10)——**三条同时成立,缺一即污染种子**: + +1. **id 前缀**:`CNV-TEST-` / `TRD-TEST-`(monkeypatch `convert_service._new_id`); +2. **数据自建**:客户/产品/费率/净值/批次全部 `CNVTEST` 前缀自建,**绝不碰种子**—— + `risk_demo_env` 的 teardown 只清 `TRD-TEST-` 前缀与时间窗,**不还原 + `core_share_lot`/`core_holding`**;若借种子客户跑转换,扣掉的份额会跨用例污染 + `test_integration_risk.py`; +3. **teardown 全清**:函数级 fixture 按前缀删两库全部自建行(幂等,seed 失败亦可清)。 +""" + +from __future__ import annotations + +from datetime import date, datetime, time, timedelta +from decimal import Decimal +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import text + +from conftest import ensure_risk_demo_ready + +from app.gateway import trade_gateway # noqa: E402 +from app.main import app # noqa: E402 +from app.service.convert import convert_service as cs # noqa: E402 +from app.service.convert.calc import ( # noqa: E402 + convert_amount, + diff_fee, + in_qty, + lot_amount, + lot_fee, +) +from app.service.risk import redis_gateway # noqa: E402 + +ensure_risk_demo_ready() + +DEMO = {"X-Debug-Role": "risk_demo", "X-Debug-Actor": "STAFF-DEMO"} + +# ── 隔离种子常量(全部带 CNVTEST 前缀)────────────────────────────── +CUSTOMER = "CUST-CNVTEST" +PROD_OUT = "PROD-CNVTESTO" # 债基,申购费率 0.0030 +PROD_IN = "PROD-CNVTESTI" # 股基,申购费率 0.0080(**高于**转出端 → 补差费非零) +PROD_CROSS = "PROD-161725" # 真库既有:易方模拟基金 / TA-CN-002(跨主体负例) +COMPANY = "华夏模拟基金" +TA = "TA-CN-001" +OUT_RATE = Decimal("0.0030") +IN_RATE = Decimal("0.0080") +OUT_NAV = Decimal("1.0300") +IN_NAV = Decimal("0.9500") +FEE_TIERS = [ + (0, 7, "0.0150"), + (7, 30, "0.0100"), + (30, 180, "0.0050"), + (180, 365, "0.0025"), + (365, None, "0.0000"), +] +#: PRD §5.3 场景:两批次 30000 份持 100 天 + 20000 份持 3 天 → 费用档 0.0050 / 0.0150 +LOT_SPEC = [("LOT-CNVTEST-A1", "30000", 100), ("LOT-CNVTEST-A2", "20000", 3)] +TOTAL_QTY = sum(Decimal(q) for _, q, _ in LOT_SPEC) + +_GROUP_LIKE = "CNV-TEST-%" + + +class FakePub: + def __init__(self) -> None: + self.messages: list = [] + self.deletes: list = [] + + def publish(self, channel, payload): + self.messages.append((channel, payload)) + + def delete(self, *keys): + self.deletes.append(keys) + + +def _test_new_id(prefix: str, now: datetime) -> str: + """`_new_id` 替换:`CNV-TEST-xxxx` / `TRD-TEST-xxxx`(架构 §10 前缀约定)。""" + return f"{prefix}-TEST-{uuid4().hex[:8].upper()}" + + +# ── 种子与清理 ────────────────────────────────────────────────────── +def _seed(core) -> None: + """自建 PRD §5.3 场景(客户 + 双产品 + 费率 + 净值 + 双批次 + 持仓)。""" + today = date.today() + base = datetime.combine(today, time(10, 0)) # 固定钟点 → hold_days 恒等于 LOT_SPEC 的天数 + with core.begin() as conn: + conn.execute( + text( + "INSERT INTO core_customer (customer_id, display_name, open_date)" + " VALUES (:c, 'T9集成测试', :d)" + ), + {"c": CUSTOMER, "d": today}, + ) + conn.execute( + text( + "INSERT INTO core_customer_risk (customer_id, risk_code, evaluated_at, expires_at)" + " VALUES (:c, 'C5', :t, :exp)" + ), + {"c": CUSTOMER, "t": base - timedelta(days=30), "exp": base + timedelta(days=300)}, + ) + for pid, name, ptype, rate in [ + (PROD_OUT, "T9转出基金", "bond", OUT_RATE), + (PROD_IN, "T9转入基金", "stock", IN_RATE), + ]: + conn.execute( + text( + "INSERT INTO core_product (product_id, product_name, min_risk_code," + " product_type, can_subscribe, can_redeem, subscribe_fee_rate," + " fund_company, ta_code)" + " VALUES (:p, :n, 'R2', :t, 1, 1, :r, :co, :ta)" + ), + {"p": pid, "n": name, "t": ptype, "r": str(rate), "co": COMPANY, "ta": TA}, + ) + for mh, mh_max, rate in FEE_TIERS: + conn.execute( + text( + "INSERT INTO core_fee_rule (product_id, fee_type, min_hold_days," + " max_hold_days, rate) VALUES (:p, 'redeem', :mh, :mm, :r)" + ), + {"p": PROD_OUT, "mh": mh, "mm": mh_max, "r": rate}, + ) + conn.execute( + text( + "INSERT INTO core_product_nav (product_id, nav, daily_chg_pct, nav_date)" + " VALUES (:p, :n, 0, :d)" + ), + {"p": PROD_IN, "n": str(IN_NAV), "d": today}, + ) + for lot_id, qty, days in LOT_SPEC: + conn.execute( + text( + "INSERT INTO core_share_lot (lot_id, customer_id, product_id, qty," + " remain_qty, nav, confirmed_at) VALUES (:l, :c, :p, :q, :q, :n, :cat)" + ), + { + "l": lot_id, + "c": CUSTOMER, + "p": PROD_OUT, + "q": qty, + "n": str(OUT_NAV), + "cat": base - timedelta(days=days), + }, + ) + conn.execute( + text( + "INSERT INTO core_holding (customer_id, product_id, qty, cost_amount," + " market_value, pnl_pct, as_of) VALUES (:c, :p, :q, :cost, :mv, 0, :d)" + ), + { + "c": CUSTOMER, + "p": PROD_OUT, + "q": str(TOTAL_QTY), + "cost": str(TOTAL_QTY * OUT_NAV), + "mv": str(TOTAL_QTY * OUT_NAV), + "d": today, + }, + ) + + +def _cleanup(core, agent) -> None: + """按前缀清两库全部自建行(幂等)。""" + core_sqls = [ + f"DELETE FROM core_convert_lot_detail WHERE convert_group_id LIKE '{_GROUP_LIKE}'", + "DELETE FROM core_trade WHERE customer_id = :c", + "DELETE FROM core_share_lot WHERE customer_id = :c", + "DELETE FROM core_holding WHERE customer_id = :c", + "DELETE FROM core_customer_risk WHERE customer_id = :c", + "DELETE FROM core_fee_rule WHERE product_id LIKE 'PROD-CNVTEST%'", + "DELETE FROM core_product_nav WHERE product_id LIKE 'PROD-CNVTEST%'", + "DELETE FROM core_product WHERE product_id LIKE 'PROD-CNVTEST%'", + "DELETE FROM core_customer WHERE customer_id = :c", + ] + with core.begin() as conn: + for sql in core_sqls: + conn.execute(text(sql), {"c": CUSTOMER}) + with agent.begin() as conn: + conn.execute(text(f"DELETE FROM risk_convert_detail WHERE convert_group_id LIKE '{_GROUP_LIKE}'")) + conn.execute(text("DELETE FROM risk_alert WHERE customer_id = :c"), {"c": CUSTOMER}) + conn.execute(text("DELETE FROM risk_suitability_log WHERE customer_id = :c"), {"c": CUSTOMER}) + conn.execute(text("DELETE FROM audit_log WHERE customer_id = :c"), {"c": CUSTOMER}) + + +@pytest.fixture() +def conv_env(risk_demo_env, monkeypatch): + """自建隔离种子 + 注入 TEST 前缀 id 工厂 + 函数级全清。""" + core = risk_demo_env["core"] + agent = risk_demo_env["agent"] + monkeypatch.setattr(cs, "_new_id", _test_new_id) + monkeypatch.setattr(trade_gateway, "_new_trade_id", lambda now: _test_new_id("TRD", now)) + _cleanup(core, agent) # 先清后建:上一轮异常退出也能自愈 + _seed(core) + try: + yield {"core": core, "agent": agent} + finally: + _cleanup(core, agent) + + +@pytest.fixture() +def client(conv_env, monkeypatch): + fake = FakePub() + with TestClient(app) as c: + monkeypatch.setattr(redis_gateway, "_gateway", fake) + yield c + + +def _body(qty="50000", cid=None, frm=PROD_OUT, to=PROD_IN): + payload = { + "customer_id": CUSTOMER, + "trade_type": "convert", + "from_product_id": frm, + "to_product_id": to, + "qty": qty, + } + if cid: + payload["client_request_id"] = cid + return payload + + +def _one(engine, sql: str, **params): + with engine.connect() as conn: + return conn.execute(text(sql), params).mappings().first() + + +def _n(engine, sql: str, **params) -> int: + with engine.connect() as conn: + return conn.execute(text(sql), params).scalar_one() + + +# ── 1. 端到端:与 PRD §5.3 示例逐项吻合 ───────────────────────────── +def test_convert_end_to_end_matches_prd_5_3(client, conv_env): + """跨两批次转换走通,四段金额与 PRD §5.3 示例**逐项吻合**。 + + 期望值一律**调生产纯函数**得出(禁手算 · 自检第 13 问),再与 PRD 示例数字对表。 + """ + r = client.post("/api/simulate/trade", json=_body(), headers=DEMO) + assert r.status_code == 200, r.text + body = r.json() + + assert body["blocked"] is False + assert body["estimated"] is True # T 日未知价法 + assert body["lot_count"] == 2 + assert body["confirm_basis"] == "natural_day_approx" + + by_days = {b["hold_days"]: b for b in body["lot_breakdown"]} + assert set(by_days) == {100, 3}, "两批次持有期应为 100 / 3 天" + + exp_out = Decimal("0") + exp_fee = Decimal("0") + for days, rate in ((100, Decimal("0.0050")), (3, Decimal("0.0150"))): + leg = by_days[days] + assert Decimal(leg["fee_rate"]) == rate, f"持有 {days} 天的费率档" + leg_amount = lot_amount(Decimal(leg["qty"]), Decimal(leg["nav"])) + leg_fee = lot_fee(leg_amount, rate) + assert Decimal(leg["fee_amount"]) == leg_fee, f"持有 {days} 天的逐批费用" + exp_out += leg_amount + exp_fee += leg_fee # 逐批舍入后求和(PRD §2.5) + + exp_conv = convert_amount(exp_out, exp_fee) + exp_diff = diff_fee(exp_conv, OUT_RATE, IN_RATE) + exp_in_amount = convert_amount(exp_conv, exp_diff) + + assert Decimal(body["out_amount"]) == exp_out + assert Decimal(body["redeem_fee"]) == exp_fee + assert Decimal(body["convert_amount"]) == exp_conv + assert Decimal(body["diff_fee"]) == exp_diff + assert Decimal(body["in_amount"]) == exp_in_amount + assert Decimal(body["in_qty"]) == in_qty(exp_in_amount, IN_NAV) + + # 与 PRD §5.3 示例数字对表(该示例由 calc_convert_demo.py 实算回填) + assert (exp_out, exp_fee, exp_conv, exp_diff, exp_in_amount) == ( + Decimal("51500.00"), + Decimal("463.50"), + Decimal("51036.50"), + Decimal("252.40"), + Decimal("50784.10"), + ) + assert Decimal(body["in_qty"]) == Decimal("53456.95") + # 转入端费率高于转出端 → 补差费应 > 0(验收 19 的非零场景) + assert exp_diff > 0 + + +# ── 2. 两条流水同组、id 前缀正确 ──────────────────────────────────── +def test_convert_writes_two_trades_in_same_group(client, conv_env): + core = conv_env["core"] + gid = client.post("/api/simulate/trade", json=_body(), headers=DEMO).json()["convert_group_id"] + assert gid.startswith("CNV-TEST-") + + rows = { + row["trade_type"]: row + for row in [ + dict(r) + for r in _all( + core, + "SELECT trade_type, trade_id, amount, convert_group_id FROM core_trade" + " WHERE customer_id = :c", + c=CUSTOMER, + ) + ] + } + assert set(rows) == {"redeem", "subscribe"}, "一次转换落两条流水(R-b)" + assert {r["convert_group_id"] for r in rows.values()} == {gid} + assert all(r["trade_id"].startswith("TRD-TEST-") for r in rows.values()) + # 转出端金额 = 原始转出额(未扣费) + assert Decimal(str(rows["redeem"]["amount"])) == Decimal("51500.00") + + +def _all(engine, sql: str, **params): + with engine.connect() as conn: + return conn.execute(text(sql), params).mappings().all() + + +# ── 3. 持仓与批次如实变动 ─────────────────────────────────────────── +def test_convert_updates_holding_and_lots(client, conv_env): + core = conv_env["core"] + client.post("/api/simulate/trade", json=_body(), headers=DEMO) + + # 转出端批次:两批 remain_qty 归零(全部转出)—— 必须限定 product_id, + # 否则会把**转入端新建批次**算进同一客户的聚合(首版断言即栽在这里) + remain = _one( + core, + "SELECT SUM(remain_qty) AS s FROM core_share_lot" + " WHERE customer_id = :c AND product_id = :p", + c=CUSTOMER, + p=PROD_OUT, + ) + assert Decimal(str(remain["s"])) == Decimal("0.0000") + + # 转出端持仓归零但**行保留**(D9/P2) + out_holding = _one( + core, + "SELECT qty FROM core_holding WHERE customer_id = :c AND product_id = :p", + c=CUSTOMER, + p=PROD_OUT, + ) + assert out_holding is not None, "转出归零保留行(不可 DELETE)" + assert Decimal(str(out_holding["qty"])) == Decimal("0.0000") + + # 转入端持仓新建且份额 = 响应 in_qty + in_holding = _one( + core, + "SELECT qty FROM core_holding WHERE customer_id = :c AND product_id = :p", + c=CUSTOMER, + p=PROD_IN, + ) + assert in_holding is not None + assert Decimal(str(in_holding["qty"])) == Decimal("53456.95") + + +# ── 4. 阶段二:明细 completed + 审计落库 ──────────────────────────── +def test_convert_detail_completed_and_audited(client, conv_env): + agent = conv_env["agent"] + gid = client.post("/api/simulate/trade", json=_body(), headers=DEMO).json()["convert_group_id"] + + detail = _one( + agent, + "SELECT status, fee_amount FROM risk_convert_detail WHERE convert_group_id = :g", + g=gid, + ) + assert detail["status"] == "completed" + assert Decimal(str(detail["fee_amount"])) == Decimal("463.50") + + # 审计以 convert_group_id 为关联主键(L-2:一次转换 = 一条主审计) + n_main = _n( + agent, + "SELECT COUNT(*) FROM audit_log WHERE customer_id = :c AND decision = 'convert_accepted'", + c=CUSTOMER, + ) + assert n_main == 1 + # 网关不重复写 trade_request(T-9:审计归 convert_service) + assert _n( + agent, + "SELECT COUNT(*) FROM audit_log WHERE customer_id = :c AND event_type = 'trade_request'", + c=CUSTOMER, + ) == 0 + + +# ── 5. 幂等:同键重试不产生第二组流水 ─────────────────────────────── +#: 首发(走 calc 纯函数)与重放(按 core_trade/core_convert_lot_detail 重建) +#: 必须**逐字节相同**的字段。这份清单是「同一响应、两条路径」的一致性契约。 +REPLAY_IDENTICAL_FIELDS = ( + "requested_qty", "actual_qty", + "out_nav", "out_amount", "redeem_fee", + "in_nav", "convert_amount", "diff_fee", "in_amount", "in_qty", + "rounding_diff", "out_subscribe_fee_rate", "in_subscribe_fee_rate", +) + + +def test_convert_idempotent_retry_returns_byte_identical_response(client, conv_env): + """带 `client_request_id` 重试 → 命中幂等、**响应与首次逐字节相同**。 + + 这是「展示位数」的回归闸门:重放值由 `DECIMAL(18,4)` 重建,若不经理发展示规格 + 收敛,同一字段会吐出 `53456.9500`(4 位)而首次是 `53456.95`(2 位)—— + 数值相等但字符串不等,前端/对账按字符串比对即误判为两笔。 + """ + core = conv_env["core"] + first = client.post( + "/api/simulate/trade", json=_body(cid="T9-IT-IDEM-1"), headers=DEMO + ).json() + second = client.post( + "/api/simulate/trade", json=_body(cid="T9-IT-IDEM-1"), headers=DEMO + ).json() + + assert second["convert_group_id"] == first["convert_group_id"] + for field in REPLAY_IDENTICAL_FIELDS: + assert second[field] == first[field], f"重放 {field} 应与首次**逐字节**相同" + # 逐批明细同样逐字段一致(qty / nav / fee_rate 的位数也是展示规格的一部分) + assert second["lot_breakdown"] == first["lot_breakdown"] + + assert _n(core, "SELECT COUNT(*) FROM core_trade WHERE customer_id = :c", c=CUSTOMER) == 2 + assert _n( + core, + "SELECT COUNT(*) FROM core_share_lot" + " WHERE customer_id = :c AND product_id = :p AND remain_qty > 0", + c=CUSTOMER, + p=PROD_OUT, + ) == 0, "重试不得再扣一次转出端份额" + assert _n( + core, + "SELECT COUNT(*) FROM core_share_lot WHERE customer_id = :c AND product_id = :p", + c=CUSTOMER, + p=PROD_IN, + ) == 1, "重试不得再建一条转入端批次" + + +# ── 5b. 展示位数规格(真实公告口径,见 convert_service._q)───────────── +def test_convert_response_field_scales(client, conv_env): + """响应的**展示位数**按字段分类收敛(真实公告口径),不随数据源漂移。 + + 真实依据:金额/份额「四舍五入保留至小数点后两位」;净值保留 4 位、 + 第 5 位四舍五入;申购费率以百分比 2 位表示(小数形式即 4 位)。 + """ + body = client.post("/api/simulate/trade", json=_body(), headers=DEMO).json() + + def scale(key: str) -> int: + return len(body[key].split(".")[1]) + + # 金额 / 份额 → 2 位 + for key in ("out_amount", "redeem_fee", "convert_amount", "diff_fee", "in_amount", + "in_qty", "requested_qty", "actual_qty"): + assert scale(key) == 2, f"{key} 应为金额/份额口径(2 位),实际 {body[key]}" + # 净值 / 费率 / 尾差 → 4 位 + for key in ("out_nav", "in_nav", "out_subscribe_fee_rate", + "in_subscribe_fee_rate", "rounding_diff"): + assert scale(key) == 4, f"{key} 应为净值/费率口径(4 位),实际 {body[key]}" + + for leg in body["lot_breakdown"]: + assert len(leg["qty"].split(".")[1]) == 2, f"逐批份额 2 位,实际 {leg['qty']}" + assert len(leg["fee_amount"].split(".")[1]) == 2, f"逐批费用 2 位,实际 {leg['fee_amount']}" + assert len(leg["nav"].split(".")[1]) == 4, f"逐批净值 4 位,实际 {leg['nav']}" + assert len(leg["fee_rate"].split(".")[1]) == 4, f"逐批费率 4 位,实际 {leg['fee_rate']}" + + # 申请份额即使**不带小数**传入,也必须补齐到 2 位(不能回显成 "50000") + assert body["requested_qty"] == "50000.00" + + +# ── 6. 跨主体拒绝(验收 14)───────────────────────────────────────── +def test_convert_cross_entity_rejected(client, conv_env): + """转出华夏/TA-CN-001 → 转入易方/TA-CN-002 → 400 `CROSS_ENTITY_NOT_SUPPORTED`。""" + r = client.post("/api/simulate/trade", json=_body(to=PROD_CROSS), headers=DEMO) + assert r.status_code == 400 + body = r.json() + assert body["error_code"] == "CROSS_ENTITY_NOT_SUPPORTED" + assert body["trace_id"] + # 校验失败不留任何流水(4xx 不落库) + assert _n(conv_env["core"], "SELECT COUNT(*) FROM core_trade WHERE customer_id = :c", c=CUSTOMER) == 0 + + +# ── 7. 未知类型仍 400(R3 的集成侧对应)───────────────────────────── +def test_unknown_trade_type_returns_400_over_http(client): + """走 HTTP 的未知类型仍是 400(`TradeRequest` 分支校验**放行**未知类型给网关兜底拒绝)。""" + r = client.post( + "/api/simulate/trade", + json={"customer_id": CUSTOMER, "trade_type": "purchase", "amount": "1000"}, + headers=DEMO, + ) + assert r.status_code == 400 + assert r.json()["error_code"] == "BAD_REQUEST" diff --git a/tests/test_integration_risk.py b/tests/test_integration_risk.py index 526e3b7..00ac819 100644 --- a/tests/test_integration_risk.py +++ b/tests/test_integration_risk.py @@ -423,18 +423,24 @@ def test_a9_cross_customer_and_unassigned_advisor_403(iclient, risk_demo_env): # ---------- 参数校验与无 trace 头兜底 ---------- -def test_convert_400_and_no_new_trade_audit(iclient, risk_demo_env): +def test_invalid_type_400_and_no_new_trade_audit(iclient, risk_demo_env): + """未知类型 → 400 且不落 `trade_request` 审计(§12 **R15** 处置后的替身)。 + + R15 原用例以 `convert` 触发(T-9 前是参数校验失败)。T-9 起 convert 是**合法** + 类型且端到端已由 `test_convert_integration.py` 覆盖,故本槽位改用仍然非法的 + `purchase` —— 保留的正是原用例真正保护的不变量:**校验失败的请求不落审计**。 + """ c, _ = iclient env = risk_demo_env before = _one(env, "SELECT COUNT(*) AS n FROM audit_log WHERE event_type = 'trade_request'") r = c.post( "/api/simulate/trade", - json=_trade("CUST-3001", "PROD-510300", "convert", 1000), + json=_trade("CUST-3001", "PROD-510300", "purchase", 1000), headers=DEMO, ) assert r.status_code == 400 and r.json()["error_code"] == "BAD_REQUEST" after = _one(env, "SELECT COUNT(*) AS n FROM audit_log WHERE event_type = 'trade_request'") - assert after["n"] == before["n"] # convert 属参数校验失败,不落审计 + assert after["n"] == before["n"] # 未知类型属参数校验失败,不落审计 def test_missing_trace_header_generates_one(iclient): diff --git a/tests/test_trade_gateway.py b/tests/test_trade_gateway.py index 71e8025..e274b14 100644 --- a/tests/test_trade_gateway.py +++ b/tests/test_trade_gateway.py @@ -20,6 +20,20 @@ from app.gateway.gateway_repository import GatewayRepository from app.gateway.trade_gateway import UnsupportedTradeType, submit_trade from app.repository.core_ro import CoreReadOnlyRepository from app.repository.risk_repository import RiskRepository +from app.service.convert.convert_service import PROCESSING +from app.service.convert.errors import ( + BelowMinQty, + CrossEntityNotSupported, + FeeRuleMissing, + IdempotencyUnavailable, + InsufficientShares, + LotConflict, + NavNotReady, + ProductNotRedeemable, + ProductNotSubscribable, + SameProduct, + TooManyLots, +) from app.service.risk import alert_service from app.service.risk.profile_l3 import AML_PENDING_TAG from app.utils.response import register_error_handlers @@ -81,6 +95,24 @@ def _req(customer="CUST-1001", product="PROD-161725", ttype="subscribe", amount= } +def _convert_req( + customer="CUST-3001", + frm="PROD-510300", + to="PROD-161725", + qty="1000", + **extra, +): + """convert 请求体(T-9 字段池):from/to/qty 三件套 + 可选幂等键。""" + return { + "customer_id": customer, + "trade_type": "convert", + "from_product_id": frm, + "to_product_id": to, + "qty": qty, + **extra, + } + + def _counts(engine, table, where="1=1"): with engine.connect() as conn: return conn.execute(text(f"SELECT COUNT(*) FROM {table} WHERE {where}")).scalar_one() @@ -89,10 +121,13 @@ def _counts(engine, table, where="1=1"): # ---------- 服务层 ---------- -def test_convert_rejected(env): +def test_unknown_trade_type_rejected(env): + """§12 **R3 拆分后**:未知类型仍 400;convert 不再拒绝(改由下方 API 层覆盖)。 + + 保留原 `test_convert_rejected` 的两条断言:不落 `core_trade`、 + 不落 `invalid_type` 审计(PRD 审计口径仅阻断/放行)。 + """ core, repo, writer, _, _ = env - with pytest.raises(UnsupportedTradeType, match="转换交易暂不支持"): - submit_trade(_req(ttype="convert"), core_ro=core, risk_repo=repo, gateway_repo=writer) with pytest.raises(UnsupportedTradeType, match="不支持的交易类型"): submit_trade(_req(ttype="purchase"), core_ro=core, risk_repo=repo, gateway_repo=writer) assert _counts(writer._engine, "core_trade") == 0 @@ -212,11 +247,139 @@ def client(env, monkeypatch): DEMO = {"X-Debug-Role": "risk_demo", "X-Debug-Actor": "STAFF-90001"} -def test_api_convert_returns_400(client): - r = client.post("/api/simulate/trade", json=_req(ttype="convert"), headers=DEMO) +# ---------- convert 请求模型与错误码映射(T-9 · 架构 §8.1/§8.3) ---------- + + +def test_api_convert_missing_leg_fields_returns_422(client): + """§12 **R4 改写**:convert 缺 from/to/qty → 422(模型分支校验)。 + + 完整「走通 → 200」路径下放到 `test_convert_integration.py`(真 MySQL)—— + 单测层造齐产品/持仓/费率/净值成本高且与集成测试重复(§12 R4 降级方案)。 + """ + r = client.post( + "/api/simulate/trade", + json={"customer_id": "CUST-3001", "trade_type": "convert", "qty": "1000"}, + headers=DEMO, + ) + assert r.status_code == 422 + assert r.json()["error_code"] == "REQUEST_VALIDATION_FAILED" + + +def test_api_convert_bad_client_request_id_returns_422(client): + """幂等键白名单与 X-Trace-Id **共用同一份正则**(S4)→ 非法字符 422。""" + r = client.post( + "/api/simulate/trade", + json=_convert_req(client_request_id="bad id!"), + headers=DEMO, + ) + assert r.status_code == 422 + + +def test_api_subscribe_missing_amount_returns_422(client): + """反向验证字段池互斥:subscribe 仅给 product_id(缺 amount)→ 422。""" + r = client.post( + "/api/simulate/trade", + json={ + "customer_id": "CUST-3001", + "trade_type": "subscribe", + "product_id": "PROD-510300", + }, + headers=DEMO, + ) + assert r.status_code == 422 + + +#: 架构 §8.3 全量映射(含验收 14 的 `CROSS_ENTITY_NOT_SUPPORTED`) +CONVERT_ERROR_MATRIX = [ + (ProductNotRedeemable(), 400, "PRODUCT_NOT_REDEEMABLE"), + (ProductNotSubscribable(), 400, "PRODUCT_NOT_SUBSCRIBABLE"), + (InsufficientShares(), 400, "INSUFFICIENT_SHARES"), + (BelowMinQty(), 400, "BELOW_MIN_QTY"), + (SameProduct(), 400, "SAME_PRODUCT"), + (CrossEntityNotSupported(), 400, "CROSS_ENTITY_NOT_SUPPORTED"), + (TooManyLots(3, 200), 400, "TOO_MANY_LOTS"), + (NavNotReady(), 503, "NAV_NOT_READY"), + (LotConflict(), 409, "LOT_CONFLICT"), + (IdempotencyUnavailable(), 503, "IDEMPOTENCY_UNAVAILABLE"), + (FeeRuleMissing(), 500, "FEE_RULE_MISSING"), +] + + +@pytest.mark.parametrize( + "exc,status,code", + CONVERT_ERROR_MATRIX, + ids=[e.error_code for e, _, _ in CONVERT_ERROR_MATRIX], +) +def test_convert_error_code_mapping(client, monkeypatch, exc, status, code): + """每条 convert 异常 → HTTP 状态 + `error_code` 逐项对齐(架构 §8.3 表)。 + + 做法:让 `convert_fund` 抛该异常,验统一错误体出口 —— convert 异常继承 + `ApiError`,经 `register_error_handlers` 自动出体,**路由层不逐个 except**。 + """ + def _boom(*_a, **_k): + raise exc + + monkeypatch.setattr(tg, "convert_fund", _boom) + r = client.post("/api/simulate/trade", json=_convert_req(), headers=DEMO) + assert r.status_code == status + body = r.json() + assert body["error_code"] == code + assert body["trace_id"] and body["request_id"] # 统一错误体四要素仍在 + + +def test_too_many_lots_body_carries_batch_count_and_max_lots(client, monkeypatch): + """架构 §8.3:`TOO_MANY_LOTS` 错误体必须带 `batch_count`/`max_lots`。 + + 本条同时锁住 `response.py` 的 `extra` 展开能力 —— 缺了它前端拿不到 + 「需跨 N 个批次、上限 200」的提示依据(执行期风险 #5 三重约束之一)。 + """ + def _boom(*_a, **_k): + raise TooManyLots(250, 200) + + monkeypatch.setattr(tg, "convert_fund", _boom) + r = client.post("/api/simulate/trade", json=_convert_req(), headers=DEMO) assert r.status_code == 400 - assert r.json()["error_code"] == "BAD_REQUEST" - assert "转换交易暂不支持" in r.json()["message"] + body = r.json() + assert body["error_code"] == "TOO_MANY_LOTS" + assert body["batch_count"] == 250 + assert body["max_lots"] == 200 + + +def test_api_convert_processing_returns_202(client, monkeypatch): + """架构 §8.3:未抢到执行权 → **202** + `{convert_group_id, status}`。""" + monkeypatch.setattr( + tg, + "convert_fund", + lambda *_a, **_k: {"status": PROCESSING, "convert_group_id": None}, + ) + r = client.post("/api/simulate/trade", json=_convert_req(), headers=DEMO) + assert r.status_code == 202 + assert r.json()["status"] == "processing" + + +def test_api_convert_accepted_returns_200(client, monkeypatch): + """convert 走通 → 200 且**路由层原样透传**(完整折算数值见真库集成测试)。""" + fake = {"blocked": False, "convert_group_id": "CNV-TEST-1", "in_qty": "1000.00"} + monkeypatch.setattr(tg, "convert_fund", lambda *_a, **_k: fake) + r = client.post("/api/simulate/trade", json=_convert_req(), headers=DEMO) + assert r.status_code == 200 + assert r.json() == fake + + +def test_convert_does_not_write_trade_request_audit(client, env, monkeypatch): + """convert **不落 `trade_request` 审计** —— 审计由 convert_service 记 `convert_request`。 + + 防的是「网关 + convert_service 双重审计」:一次转换被记成两条审计事件。 + """ + _, _, _, _, engine = env + monkeypatch.setattr( + tg, + "convert_fund", + lambda *_a, **_k: {"blocked": False, "convert_group_id": "CNV-TEST-2"}, + ) + r = client.post("/api/simulate/trade", json=_convert_req(), headers=DEMO) + assert r.status_code == 200 + assert _counts(engine, "audit_log", "event_type='trade_request'") == 0 def test_api_blocked_returns_200_with_blocked_true(client):