Files
group_fqcd_jr/app/core/errors.py
T
张胜宇 ebc3fe4cbe feat(§T): 账户看板 + 场内模拟交易 9 端点(用户自助首版)
新增 §T 用户自助段(docs/05 §19 新号段 7 个 = A×40/C×7/K×4/M×4/O×3/R×4/T×9):

- T001 GET /api/v1/users/me/account/dashboard  — 账户/资金/持仓/盈亏汇总

- T002 POST /api/v1/users/me/orders  — 委托提交(首版 market 立即全额成交)

- T003 / T004 / T005  委托列表/详情/撤单

- T006 GET /api/v1/users/me/holdings  — 持仓列表(含市值/盈亏/当日盈亏)

- T007 / T008  成交记录列表/详情

- T009 GET /api/v1/users/me/cash-ledger  — 资金账本

要点(与 docs/00 §6.6 一致):

- 首版市价委托立即全额成交,不实现撮合队列/部分成交;T005 撤单首版对任何在场委托返回 ORDER_NOT_CANCELLABLE (409)

- 价格来源复用 base FundQuoteService;service 层不二次封装(满足 AGENTS 第 2 条)

- 首版风控 3 条硬性:产品可交易、客户适当性、持仓比例上限(fin_market_price 缺失或过期 → 拒绝买入)

- 数据库零修改:10 张 fin_* 表全部 docs/00 既定,本批 PR 改列类型与可空性均 0;底座实际偏差(id 无 AUTO_INCREMENT、所谓'生成列'是普通 NOT NULL)由 service _next_id / 业务派生值补偿

注册 API:9 端点均注册进 app.main;user=9001(cust)'s id 写账

权限码(tools/seed_test_rbac.py 同步登记 + CUSTOMER 全量):

  9047 account:read:self

  9048 trade:order:create

  9049 trade:order:read

  9050 trade:order:cancel

  9051 holding:read:self

  9052 trade:txn:read

错误码(app/core/errors.py + docs/05 §3.6 + tests/unit/core/test_errors.py DOCUMENTED 三方同步):

  404 ACCOUNT_NOT_FOUND / ORDER_NOT_FOUND

  409 ORDER_NOT_CANCELLABLE

  422 INSUFFICIENT_FUNDS / INSUFFICIENT_HOLDING / HOLDING_RATIO_EXCEEDED / SUITABILITY_MISMATCH / PRODUCT_NOT_TRADABLE

  503 FUND_QUOTE_UNAVAILABLE(可重试)

新增:app/api/controllers/trading.py / app/api/schemas/trading.py / app/service/trade_service.py / tools/seed_sim_account_demo.py / tests/unit/service/test_trade_service.py(unit×8) / tests/contract/test_trading_endpoint_contract.py(contract×11)

修改:app/main.py(挂载 controller) / app/core/errors.py(10 新异常类) / tools/seed_test_rbac.py / docs/05-接口文档.md(§19 T001-T009 + §3.6 9 新码) / tests/unit/core/test_errors.py(DOCUMENTED 同步)

门禁:pytest tests/unit tests/contract 1313 passed (+19 新增) / ruff all clean / 三道守卫全过
2026-09-12 15:50:37 +08:00

287 lines
8.6 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.
"""HTTP 错误码唯一口径。
权威来源:`docs/05-接口文档.md` §3.5(HTTP 状态码)、§3.6(核心错误码)以及各接口小节
补充的 `SSE_NOT_ACCEPTABLE`、`FEEDBACK_ALREADY_EXISTS`、`INVALID_CURSOR` 等码。
实现不得再自造码;新增场景必须先落到文档已有码上。
`retryable=None` 表示按 `status_code >= 500` 推导,与文档表中标注的个例
(如 `RESOURCE_VERSION_CONFLICT` 为 409 但可重试)用 `retryable=False/True` 显式覆盖。
"""
class AgentError(Exception):
"""基类。未分类内部错误按文档映射到 `AGENT_INTERNAL_ERROR` / 500。"""
code = "AGENT_INTERNAL_ERROR"
status_code = 500
# 文档 §3.6 的“是否可重试”列:500 标注“视情况”,基类按不可重试处理。
retryable: bool | None = False
def __init__(self, message: str) -> None:
super().__init__(message)
self.message = message
# None 表示未声明:按 status_code >= 500 推导,与文档主表一致。
declared = type(self).retryable
self.retryable = self.status_code >= 500 if declared is None else declared
@property
def is_retryable(self) -> bool:
"""固化为布尔,供错误信封(文档 §3.4)直接序列化。"""
return bool(self.retryable)
# --- 输入、鉴权与权限 -------------------------------------------------------
class ValidationAgentError(AgentError):
"""已解析请求不满足字段或业务输入约束(文档 §3.5 的 422)。"""
code = "AGENT_INPUT_INVALID"
status_code = 422
class UnauthorizedAgentError(AgentError):
"""Token 缺失或无效(文档 §3.6 `AUTHENTICATION_REQUIRED`)。"""
code = "AUTHENTICATION_REQUIRED"
status_code = 401
class ForbiddenAgentError(AgentError):
"""角色、入口或数据范围不允许(文档 §3.6 `AGENT_PERMISSION_DENIED`)。"""
code = "AGENT_PERMISSION_DENIED"
status_code = 403
class OnboardingRequiredError(ForbiddenAgentError):
"""开户前置条件未满足,沿用文档登记的权限错误码。"""
class AgentPermissionDeniedError(ForbiddenAgentError):
"""`AGENT_PERMISSION_DENIED` 的语义化别名,供新代码使用。"""
# --- 404:必须按资源语义细分,禁止使用通用 404 ------------------------------
class ResourceNotFoundError(AgentError):
"""通用 404 基类,只用于继承与 `except`,禁止直接抛出。
文档 §3.6 没有与实现无关的通用 404 码,因此这里取主表中最接近“资源被隐藏”
语义的 `SESSION_NOT_FOUND`;`tests/unit/core/test_errors.py` 用 AST 校验
所有抛错点都使用语义正确的子类,绝不会落到这个兜底值上。
"""
code = "SESSION_NOT_FOUND"
status_code = 404
class SessionNotFoundError(ResourceNotFoundError):
code = "SESSION_NOT_FOUND"
class SessionNotAccessibleError(ResourceNotFoundError):
"""当前身份不能访问会话;按文档 §4.2 资源隐藏规则同样返回 404。"""
code = "SESSION_NOT_ACCESSIBLE"
class AgentTypeNotFoundError(ValidationAgentError):
code = "AGENT_TYPE_NOT_FOUND"
status_code = 404
class RunNotFoundError(ResourceNotFoundError):
"""运行不存在或不可见;越权与不存在必须返回同一码,不泄露存在性。"""
code = "RUN_NOT_FOUND"
class GenericResourceNotFoundError(ResourceNotFoundError):
"""已对当前身份隐藏的非会话、非运行资源(消息、工单、客户、管理面资源等)。
文档 §3.6 未给这类通用 404 单独定义码;按同一资源隐藏语义复用
`SESSION_NOT_FOUND`,避免实现再造新码。
"""
code = "SESSION_NOT_FOUND"
class ReferenceNotFoundError(GenericResourceNotFoundError):
"""知识引用不存在或不可解析时的语义化别名。"""
# --- 409:幂等、版本与运行状态 ----------------------------------------------
class ConflictAgentError(AgentError):
"""通用 409 基类,只用于继承与 `except`,禁止直接抛出。
文档 §3.5 把非法状态转换归 409,主表里对应的码是 `RUN_NOT_CANCELLABLE`;
具体抛错点必须使用下面的语义子类。
"""
code = "RUN_NOT_CANCELLABLE"
status_code = 409
class IdempotencyConflictError(ConflictAgentError):
code = "IDEMPOTENCY_CONFLICT"
class ResourceVersionConflictError(ConflictAgentError):
"""`If-Match` 版本过期(文档 §5.3);文档标注可重试。"""
code = "RESOURCE_VERSION_CONFLICT"
retryable = True
class RunNotCancellableError(ConflictAgentError):
"""运行已成功、失败或进入最终提交事务(文档 §6.4)。"""
code = "RUN_NOT_CANCELLABLE"
# 注意:文档 §3.6 主表列有 `RUN_CANCELLED`,但 §6.4 把它明确定义为
# `request_idempotency` 的**状态标识**(`status='failed'` + `error_code='RUN_CANCELLED'`),
# 而不是 HTTP 响应错误码;同一运行重复取消必须幂等返回同一状态。因此这里
# **不提供** `RUN_CANCELLED` 的 HTTP 异常类,避免实现把它当成客户端错误返回。
class RunLeaseLostError(ConflictAgentError):
"""Worker 租约失效或不能覆盖运行终态。"""
code = "RUN_NOT_CANCELLABLE"
class InvalidStateError(ConflictAgentError):
"""非法状态转换且不属于运行取消/版本冲突场景(文档 §3.5 归 409)。"""
code = "RUN_NOT_CANCELLABLE"
class ResourceAlreadyExistsError(ConflictAgentError):
"""唯一性约束冲突(文档 §3.5 归 409)。"""
code = "IDEMPOTENCY_CONFLICT"
# --- 400、429、503、504 -----------------------------------------------------
class InvalidCursorError(AgentError):
code = "INVALID_CURSOR"
status_code = 400
class RateLimitedError(AgentError):
code = "RATE_LIMITED"
status_code = 429
retryable = True
class RecoverableAgentError(AgentError):
"""必需依赖不可用(文档 §3.6 `DEPENDENCY_UNAVAILABLE`,可重试)。"""
code = "DEPENDENCY_UNAVAILABLE"
status_code = 503
retryable = True
class DependencyUnavailableError(RecoverableAgentError):
code = "DEPENDENCY_UNAVAILABLE"
class UpstreamTimeoutError(RecoverableAgentError):
"""上游超过时间预算(文档 §3.6 `UPSTREAM_TIMEOUT`)。"""
code = "UPSTREAM_TIMEOUT"
status_code = 504
# --- SSE 与反馈 -------------------------------------------------------------
class SseNotAcceptableError(AgentError):
"""`Accept` 不接受 `text/event-stream`(文档 §2 运行事件接口)。"""
code = "SSE_NOT_ACCEPTABLE"
status_code = 406
class FeedbackAlreadyExistsError(ConflictAgentError):
"""同一用户对同一消息重复提交且内容不同(文档 §7.5)。"""
code = "FEEDBACK_ALREADY_EXISTS"
# ---------------------------------------------------------------------------
# 场内基金模拟交易(§T 用户自助)错误码。
# 对应基线 `docs/00` 第 6 章:买入前必须验证产品可用 + 适当性 + 持仓比例上限。
# 错误码稳定,便于前端按 code 区分。
# ---------------------------------------------------------------------------
class TradeError(AgentError):
"""场内模拟交易错误基类。"""
class ProductNotTradableError(TradeError):
"""产品不可交易(停牌/退市/未开放)。"""
code = "PRODUCT_NOT_TRADABLE"
status_code = 422
class InsufficientFundsError(TradeError):
"""可用资金不足以覆盖成交金额与费用。"""
code = "INSUFFICIENT_FUNDS"
status_code = 422
class InsufficientHoldingError(TradeError):
"""可用持仓不足(卖出时)。"""
code = "INSUFFICIENT_HOLDING"
status_code = 422
class SuitabilityMismatchError(TradeError):
"""客户适当性等级(C1-C5)与产品风险等级(R1-R5)不兼容。"""
code = "SUITABILITY_MISMATCH"
status_code = 422
class HoldingRatioExceededError(TradeError):
"""超过单一投资者持有比例上限。"""
code = "HOLDING_RATIO_EXCEEDED"
status_code = 422
class FundQuoteUnavailableError(TradeError):
"""实时行情缺失、非正数或过期。"""
code = "FUND_QUOTE_UNAVAILABLE"
status_code = 503
retryable = True
class OrderNotCancellableError(TradeError):
"""委托不可撤单(已成交/已撤单/已拒绝)。"""
code = "ORDER_NOT_CANCELLABLE"
status_code = 409
class OrderNotFoundError(TradeError):
"""委托不存在或不属于当前客户。"""
code = "ORDER_NOT_FOUND"
status_code = 404
class AccountNotFoundError(TradeError):
"""客户虚拟资金账户未开户。"""
code = "ACCOUNT_NOT_FOUND"
status_code = 404