Files
group_xinghuo_jinrong/app/service/convert/errors.py
T

170 lines
6.1 KiB
Python
Raw Normal View History

"""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",
]