170 lines
6.1 KiB
Python
170 lines
6.1 KiB
Python
"""convert 专属异常(架构 §8.3 错误码映射)。
|
||||
|
|
|
|||
|
|
全部继承 `app.utils.exceptions.ApiError` —— 复用 `app/main.py` 已有的
|
|||
|
|
`register_error_handlers`,统一输出 `{error_code, message, trace_id, request_id}` 错误体,
|
|||
|
|
**不新增异常出口、不改中间件**。
|
|||
|
|
|
|||
|
|
两点必须分清(PRD L-4 / §8.3):
|
|||
|
|
|
|||
|
|
1. **技术故障码不是业务阻断**。`NavNotReady`(503)、`LotConflict`(409)、
|
|||
|
|
`IdempotencyUnavailable`(503) 表示系统瞬时状态,恢复后重试即可成功,
|
|||
|
|
**不适用「仅 R-02 可阻断交易」的业务铁律**。
|
|||
|
|
2. **`extra` 只承载结构性附加字段**(如 `TOO_MANY_LOTS` 的 `batch_count`/`max_lots`),
|
|||
|
|
由 T-7 的 `api/simulate.py` 分支展开进错误体;本包不构造响应。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
from typing import Any
|
|||
|
|
|
|||
|
|
from app.utils.exceptions import ApiError
|
|||
|
|
|
|||
|
|
|
|||
|
|
class ConvertError(ApiError):
|
|||
|
|
"""convert 错误共同基类(便于 service 层统一 `except ConvertError` 分流)。
|
|||
|
|
|
|||
|
|
子类通过类属性声明 `status_code` / `error_code`,无需逐个重写 `__init__`。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
status_code: int = 400
|
|||
|
|
error_code: str = "BAD_REQUEST"
|
|||
|
|
default_message: str = "基金转换请求被拒绝"
|
|||
|
|
|
|||
|
|
def __init__(self, message: str | None = None, *, extra: dict[str, Any] | None = None) -> None:
|
|||
|
|
super().__init__(self.status_code, self.error_code, message or self.default_message)
|
|||
|
|
#: 附加字段(供错误体展开,如 batch_count / max_lots);不参与 message。
|
|||
|
|
self.extra: dict[str, Any] = dict(extra or {})
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ── 400:业务校验类(HTTP 400 · §8.3 前 7 条)─────────────────────────
|
|||
|
|
class ProductNotRedeemable(ConvertError):
|
|||
|
|
"""转出方不可赎回(`core_product.can_redeem = 0`)。"""
|
|||
|
|
|
|||
|
|
error_code = "PRODUCT_NOT_REDEEMABLE"
|
|||
|
|
default_message = "转出基金当前不可赎回"
|
|||
|
|
|
|||
|
|
|
|||
|
|
class ProductNotSubscribable(ConvertError):
|
|||
|
|
"""转入方不可申购(`core_product.can_subscribe = 0`)。"""
|
|||
|
|
|
|||
|
|
error_code = "PRODUCT_NOT_SUBSCRIBABLE"
|
|||
|
|
default_message = "转入基金当前不可申购"
|
|||
|
|
|
|||
|
|
|
|||
|
|
class InsufficientShares(ConvertError):
|
|||
|
|
"""可转份额不足(`Σ remain_qty < 申请份额` · PRD §12)。"""
|
|||
|
|
|
|||
|
|
error_code = "INSUFFICIENT_SHARES"
|
|||
|
|
default_message = "可转份额不足"
|
|||
|
|
|
|||
|
|
|
|||
|
|
class BelowMinQty(ConvertError):
|
|||
|
|
"""低于最低转出份额(申请份额 == 全部可转份额时豁免 `min_redeem_qty`)。"""
|
|||
|
|
|
|||
|
|
error_code = "BELOW_MIN_QTY"
|
|||
|
|
default_message = "低于该基金的最低转出份额"
|
|||
|
|
|
|||
|
|
|
|||
|
|
class SameProduct(ConvertError):
|
|||
|
|
"""转出与转入为同一产品。"""
|
|||
|
|
|
|||
|
|
error_code = "SAME_PRODUCT"
|
|||
|
|
default_message = "转出与转入基金不能为同一产品"
|
|||
|
|
|
|||
|
|
|
|||
|
|
class CrossEntityNotSupported(ConvertError):
|
|||
|
|
"""跨主体转换(不满足 同销售机构 + 同管理人 + 同 TA)。"""
|
|||
|
|
|
|||
|
|
error_code = "CROSS_ENTITY_NOT_SUPPORTED"
|
|||
|
|
default_message = "仅支持同一销售机构、同一管理人、同一注册登记机构的基金互转"
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TooManyLots(ConvertError):
|
|||
|
|
"""跨越批次数超上限(`convert_batch_max_lots`,默认 200)。
|
|||
|
|
|
|||
|
|
一期**不做自动分拆**(§1 原则 12:单笔请求 = 单事务 = 单 `convert_group_id`),
|
|||
|
|
故把实际所需批次数与上限一并回给前端,提示「请拆分多笔申请」。
|
|||
|
|
|
|||
|
|
`extra` 与 PRD「执行期风险 #5」的响应体约定一致:
|
|||
|
|
`{"error_code":"TOO_MANY_LOTS","batch_count":<实际>,"max_lots":200}`。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
error_code = "TOO_MANY_LOTS"
|
|||
|
|
default_message = "本次转换跨越的批次数超过上限,请拆分多笔申请"
|
|||
|
|
|
|||
|
|
def __init__(
|
|||
|
|
self,
|
|||
|
|
batch_count: int,
|
|||
|
|
max_lots: int,
|
|||
|
|
message: str | None = None,
|
|||
|
|
) -> None:
|
|||
|
|
super().__init__(
|
|||
|
|
message
|
|||
|
|
or f"本次转换需跨 {batch_count} 个批次,超过上限 {max_lots},请拆分多笔申请",
|
|||
|
|
extra={"batch_count": int(batch_count), "max_lots": int(max_lots)},
|
|||
|
|
)
|
|||
|
|
self.batch_count = int(batch_count)
|
|||
|
|
self.max_lots = int(max_lots)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ── 409 / 503:技术故障类(**非业务阻断** · PRD L-4)──────────────────
|
|||
|
|
class LotConflict(ConvertError):
|
|||
|
|
"""批次条件 UPDATE 的 `rowcount != 1`(并发扣减冲突)。
|
|||
|
|
|
|||
|
|
调用方按 100/200/400ms 退避重试,建议 ≤3 次(§8.3)。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
status_code = 409
|
|||
|
|
error_code = "LOT_CONFLICT"
|
|||
|
|
default_message = "份额批次并发冲突,请重试"
|
|||
|
|
|
|||
|
|
|
|||
|
|
class NavNotReady(ConvertError):
|
|||
|
|
"""该产品无任何净值记录(PRD §8.3:503 `NAV_NOT_READY`)。
|
|||
|
|
|
|||
|
|
⚠️ **`nav_stale`(净值过期)不是本异常**:净值过期只额外落一条
|
|||
|
|
`nav_stale` 审计并继续折算(PRD §2.2),只有**完全没有净值**才阻断。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
status_code = 503
|
|||
|
|
error_code = "NAV_NOT_READY"
|
|||
|
|
default_message = "该基金尚无可用净值,暂时无法转换"
|
|||
|
|
|
|||
|
|
|
|||
|
|
class IdempotencyUnavailable(ConvertError):
|
|||
|
|
"""幂等执行权获取失败且无法判定状态(如 Redis 与占位表同时不可用)。"""
|
|||
|
|
|
|||
|
|
status_code = 503
|
|||
|
|
error_code = "IDEMPOTENCY_UNAVAILABLE"
|
|||
|
|
default_message = "幂等校验服务暂不可用,请稍后重试"
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ── 500:内部兜底(§8.3 表之外 · 仅数据缺失时触发)────────────────────
|
|||
|
|
class FeeRuleMissing(ConvertError):
|
|||
|
|
"""未匹配到任何赎回费率档 —— **属数据完整性事故,不是业务分支**。
|
|||
|
|
|
|||
|
|
触发条件:`core_fee_rule` 缺少该产品的 `[0, 7)` 档(种子漏灌或产品未配档)。
|
|||
|
|
真实费用规则必含最低档,故此处**不降级为 0 费率**——静默按 0 计费会
|
|||
|
|
少收赎回费且不留痕,比直接失败危险得多。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
status_code = 500
|
|||
|
|
error_code = "FEE_RULE_MISSING"
|
|||
|
|
default_message = "未匹配到赎回费率规则(费率表数据缺失)"
|
|||
|
|
|
|||
|
|
|
|||
|
|
__all__ = [
|
|||
|
|
"ConvertError",
|
|||
|
|
"ProductNotRedeemable",
|
|||
|
|
"ProductNotSubscribable",
|
|||
|
|
"InsufficientShares",
|
|||
|
|
"BelowMinQty",
|
|||
|
|
"SameProduct",
|
|||
|
|
"CrossEntityNotSupported",
|
|||
|
|
"TooManyLots",
|
|||
|
|
"LotConflict",
|
|||
|
|
"NavNotReady",
|
|||
|
|
"IdempotencyUnavailable",
|
|||
|
|
"FeeRuleMissing",
|
|||
|
|
]
|