Files
gaokao-volunteer-system/admin/errors/codes.py
Hermes 2893a45d8b
Some checks failed
CI / pytest (Python 3.10) (push) Has been cancelled
CI / pytest (Python 3.11) (push) Has been cancelled
CI / pytest (Python 3.12) (push) Has been cancelled
feat(6/19): 保留期门禁 + 支付失败持久化 + 文档校准
A1 删除/匿名化 180 天保留期门禁 (P0):
- admin/errors/codes.py + BIZ_ORDER_RETENTION_NOT_EXPIRED (E02002) -> HTTP 409
- admin/config.py + retention_days / GAOKAO_RETENTION_DAYS (默认 180)
- admin/routes/orders.py + _assert_retention_expired 守卫
- data/orders/deletion_service.py + RETENTION_GUARDED_STATUSES
- admin/tests/test_order_deletion.py + _expire_retention_window + 3 测试

B1 支付失败 webhook 持久化 (P1):
- data/payments/dao.py + failed_at/failure_reason/provider_trade_no/callback_payload
- data/payments/models.py + PaymentRecord 新字段
- data/payments/service.py handle_webhook 失败分支持久化
- data/payments/tests/test_webhook.py test_handle_webhook_persists_failed_status

A2 文档校准 (P1):
- README / PRD / ROADMAP / TECH_ARCHITECTURE 顶部对齐 6/19

B2 真相源分层 (P1):
- 6/13 整改板/执行板加历史快照前缀
- 新建 6/19 整改板/执行板
- CURRENT_STATE 顶部加 6/19 增量段 (在 6/13 真相源之上叠加)

dev-verify: 1175 passed (A1+B1+A2+B2) -> 1179 passed (+ T12-C),
coverage overall=85.05% / core=100%
2026-06-20 00:27:15 +08:00

188 lines
5.8 KiB
Python

"""错误码常量定义 (T9.1 + T9.2 落地)。
码点结构 (固定 6 字符):
E AA BBB
| | +-- 段内顺序号 (001-999, 零填充 3 位)
| +----- 段号 (2 位)
+-------- 字面量 'E' 固定前缀
段号分配 (T9.1):
01 用户 (E01001-E01199, 199 码点)
02 业务 (E02001-E02199, 199 码点)
03 数据 (E03001-E03199, 199 码点)
04 第三方 (E04001-E04199, 199 码点)
05 系统 (E05001-E05099, 99 码点)
90-99 保留 (不分配给业务)
每段内 xx0xx-xx5xx 是子域位 (xx0 通用 / xx1 凭证 / xx2 会话 / ...).
注意:
- 字面量前缀 'E' 用于日志链路快速定位业务错误.
- 与 HTTP 状态码解耦 — 同一 HTTP 状态可对应不同业务码.
- 5xx 系统级错误严禁落到非 05 段 (防兜底掩盖).
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import FrozenSet
# ---------------- 段与子域枚举 ----------------
class ErrorSegment(str, Enum):
"""业务段号 (2 位数字字符串)."""
USER = "01"
BUSINESS = "02"
DATA = "03"
THIRD_PARTY = "04"
SYSTEM = "05"
@classmethod
def values(cls) -> FrozenSet[str]:
return frozenset(s.value for s in cls)
class ErrorSubdomain(str, Enum):
"""段内子域位 (按段独立计数).
取值对应码点第 2 位 (xx?xx 中的 ?):
0 通用
1 凭证
2 会话/速率
3 权限/状态机
4 设备/并发
5 配额/迁移
"""
GENERAL = "0"
CREDENTIAL = "1"
SESSION = "2"
PERMISSION = "3"
DEVICE = "4"
QUOTA = "5"
# ---------------- 错误码 dataclass ----------------
@dataclass(frozen=True)
class ErrorCode:
"""业务错误码.
形如 E01A01: E 前缀 + 段号 01 + 子域位 A (0-5) + 段内子码 01.
"""
segment: ErrorSegment
subdomain: ErrorSubdomain
sequence: int # 1-99 (保留首位给 1-9 用于通用, 10-99 子桶内分配)
def __post_init__(self) -> None:
if not (1 <= self.sequence <= 99):
raise ValueError(f"sequence 必须在 1-99, 得到 {self.sequence}")
def __str__(self) -> str:
# E + 段号 + 子域位 + 2 位顺序号
return f"E{self.segment.value}{self.subdomain.value}{self.sequence:02d}"
@classmethod
def of(cls, code: str) -> "ErrorCode":
"""从码点字符串反解 (用于日志/测试断言).
Raises:
ValueError: 码点格式不合法
"""
if len(code) != 6 or not code.startswith("E"):
raise ValueError(f"无效码点: {code!r} (期望 E + 5 位数字)")
try:
seg = ErrorSegment(code[1:3])
sub = ErrorSubdomain(code[3])
except ValueError as exc:
raise ValueError(f"无效码点: {code!r} ({exc})") from exc
try:
seq = int(code[4:])
except ValueError as exc:
raise ValueError(f"无效码点: {code!r} ({exc})") from exc
if not (1 <= seq <= 99):
raise ValueError(f"无效码点: {code!r} (sequence 越界)")
return cls(segment=seg, subdomain=sub, sequence=seq)
# ---------------- 内置错误码常量 ----------------
#
# 在这里集中声明项目实际使用的业务错误码, 避免散落字符串.
# 命名约定: <SEG>_<DOMAIN>_<INTENT> (全大写, 下划线)
# 新增错误码时:
# 1) 在此处声明常量
# 2) 在 registry.py 的 MESSAGES_ZH_CN 中注册中文文案
# 01 段 — 用户域
AUTH_INVALID_CREDENTIALS = ErrorCode(
segment=ErrorSegment.USER, subdomain=ErrorSubdomain.CREDENTIAL, sequence=1
) # E01101
AUTH_TOKEN_EXPIRED = ErrorCode(
segment=ErrorSegment.USER, subdomain=ErrorSubdomain.SESSION, sequence=1
) # E01201
AUTH_TOKEN_INVALID = ErrorCode(
segment=ErrorSegment.USER, subdomain=ErrorSubdomain.SESSION, sequence=2
) # E01202
AUTH_INSUFFICIENT_PERMISSION = ErrorCode(
segment=ErrorSegment.USER, subdomain=ErrorSubdomain.PERMISSION, sequence=1
) # E01301
AUTH_ACCOUNT_DISABLED = ErrorCode(
segment=ErrorSegment.USER, subdomain=ErrorSubdomain.CREDENTIAL, sequence=2
) # E01102
# 02 段 — 业务域
BIZ_ORDER_NOT_FOUND = ErrorCode(
segment=ErrorSegment.BUSINESS, subdomain=ErrorSubdomain.GENERAL, sequence=1
) # E02001
BIZ_ORDER_RETENTION_NOT_EXPIRED = ErrorCode(
segment=ErrorSegment.BUSINESS, subdomain=ErrorSubdomain.GENERAL, sequence=2
) # E02002
BIZ_ORDER_INVALID_STATUS = ErrorCode(
segment=ErrorSegment.BUSINESS, subdomain=ErrorSubdomain.PERMISSION, sequence=1
) # E02301
BIZ_RATE_LIMITED = ErrorCode(
segment=ErrorSegment.BUSINESS, subdomain=ErrorSubdomain.QUOTA, sequence=1
) # E02501
# 03 段 — 数据域
DATA_VALIDATION_FAILED = ErrorCode(
segment=ErrorSegment.DATA, subdomain=ErrorSubdomain.GENERAL, sequence=1
) # E03001
DATA_NOT_FOUND = ErrorCode(
segment=ErrorSegment.DATA, subdomain=ErrorSubdomain.GENERAL, sequence=2
) # E03002
DATA_PERSIST_FAILED = ErrorCode(
segment=ErrorSegment.DATA, subdomain=ErrorSubdomain.GENERAL, sequence=3
) # E03003
# 04 段 — 第三方域
THIRD_PARTY_UPSTREAM_ERROR = ErrorCode(
segment=ErrorSegment.THIRD_PARTY, subdomain=ErrorSubdomain.GENERAL, sequence=1
) # E04001
THIRD_PARTY_TIMEOUT = ErrorCode(
segment=ErrorSegment.THIRD_PARTY, subdomain=ErrorSubdomain.GENERAL, sequence=2
) # E04002
# 05 段 — 系统域
SYS_INTERNAL_ERROR = ErrorCode(
segment=ErrorSegment.SYSTEM, subdomain=ErrorSubdomain.GENERAL, sequence=1
) # E05001
SYS_CONFIG_MISSING = ErrorCode(
segment=ErrorSegment.SYSTEM, subdomain=ErrorSubdomain.GENERAL, sequence=2
) # E05002
SYS_RESOURCE_EXHAUSTED = ErrorCode(
segment=ErrorSegment.SYSTEM, subdomain=ErrorSubdomain.GENERAL, sequence=3
) # E05003
# 兜底码 (用于未注册码点时使用,确保任何错误都能渲染)
FALLBACK_CODE = ErrorCode(
segment=ErrorSegment.SYSTEM, subdomain=ErrorSubdomain.GENERAL, sequence=99
) # E05099