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%
250 lines
8.7 KiB
Python
250 lines
8.7 KiB
Python
"""i18n 消息注册表 (T9.2 — 用户友好提示).
|
|
|
|
每个错误码对应:
|
|
- message : 给用户看的中文短句 (≤ 30 字, 一句话讲清楚发生了什么)
|
|
- suggestion : 给用户的可执行建议 (≤ 50 字, 具体到下一步动作)
|
|
- severity : 'info' / 'warn' / 'error' (驱动前端图标与重试策略, T9.3 接入)
|
|
- retryable : True 表示前端/SDK 可安全重试 (T9.4 装饰器用)
|
|
|
|
未来扩展:
|
|
- 引入 en-US 资源包时, 把 MESSAGES 改为按 locale 索引的 dict:
|
|
MESSAGES = {"zh-CN": {...}, "en-US": {...}}
|
|
- CI 校验脚本应扫描 codes.py 的常量是否都已注册, 防止散落字符串
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Dict, Literal, Mapping
|
|
|
|
from admin.errors.codes import (
|
|
AUTH_ACCOUNT_DISABLED,
|
|
AUTH_INSUFFICIENT_PERMISSION,
|
|
AUTH_INVALID_CREDENTIALS,
|
|
AUTH_TOKEN_EXPIRED,
|
|
AUTH_TOKEN_INVALID,
|
|
BIZ_ORDER_INVALID_STATUS,
|
|
BIZ_ORDER_NOT_FOUND,
|
|
BIZ_ORDER_RETENTION_NOT_EXPIRED,
|
|
BIZ_RATE_LIMITED,
|
|
DATA_NOT_FOUND,
|
|
DATA_PERSIST_FAILED,
|
|
DATA_VALIDATION_FAILED,
|
|
FALLBACK_CODE,
|
|
SYS_CONFIG_MISSING,
|
|
SYS_INTERNAL_ERROR,
|
|
SYS_RESOURCE_EXHAUSTED,
|
|
THIRD_PARTY_TIMEOUT,
|
|
THIRD_PARTY_UPSTREAM_ERROR,
|
|
)
|
|
|
|
|
|
Severity = Literal["info", "warn", "error"]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Message:
|
|
"""用户可见的错误消息 (i18n 资源包的最小单元)."""
|
|
|
|
code: str
|
|
message: str
|
|
suggestion: str
|
|
severity: Severity
|
|
retryable: bool
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"code": self.code,
|
|
"message": self.message,
|
|
"suggestion": self.suggestion,
|
|
"severity": self.severity,
|
|
"retryable": self.retryable,
|
|
}
|
|
|
|
|
|
class MessageNotFoundError(KeyError):
|
|
"""注册表中找不到对应码点 (本应被兜底拦截,仅测试用)."""
|
|
|
|
|
|
# ---------------- zh-CN 资源包 ----------------
|
|
#
|
|
# 编写准则:
|
|
# - message: 陈述句, 不带感叹号; 第二人称避免 ('您'/'请' 可用)
|
|
# - suggestion: 祈使句开头, 给出 1-2 步可执行动作; 不承诺能解决
|
|
# - 不暴露内部实现细节 (DB 名/字段名/IP), 但允许给文件路径/链接
|
|
# - 同一段号内的码点, 文案语气保持一致
|
|
|
|
MESSAGES_ZH_CN: Mapping[str, Message] = {
|
|
# 01 段 — 用户域
|
|
str(AUTH_INVALID_CREDENTIALS): Message(
|
|
code=str(AUTH_INVALID_CREDENTIALS),
|
|
message="用户名或密码不正确",
|
|
suggestion="请检查大小写和输入法,确认无误后重新登录。忘记密码请联系管理员重置。",
|
|
severity="warn",
|
|
retryable=False,
|
|
),
|
|
str(AUTH_TOKEN_EXPIRED): Message(
|
|
code=str(AUTH_TOKEN_EXPIRED),
|
|
message="登录状态已过期",
|
|
suggestion="请重新登录后再继续操作。系统默认会话有效期 5 分钟。",
|
|
severity="warn",
|
|
retryable=False,
|
|
),
|
|
str(AUTH_TOKEN_INVALID): Message(
|
|
code=str(AUTH_TOKEN_INVALID),
|
|
message="登录凭证无效",
|
|
suggestion="请重新登录。如反复出现,请清除浏览器 Cookie 后重试。",
|
|
severity="warn",
|
|
retryable=False,
|
|
),
|
|
str(AUTH_INSUFFICIENT_PERMISSION): Message(
|
|
code=str(AUTH_INSUFFICIENT_PERMISSION),
|
|
message="当前账号无访问权限",
|
|
suggestion="请使用具备相应权限的账号登录,或联系管理员开通权限。",
|
|
severity="warn",
|
|
retryable=False,
|
|
),
|
|
str(AUTH_ACCOUNT_DISABLED): Message(
|
|
code=str(AUTH_ACCOUNT_DISABLED),
|
|
message="账号已被停用",
|
|
suggestion="请联系管理员确认账号状态,启用后再登录。",
|
|
severity="warn",
|
|
retryable=False,
|
|
),
|
|
# 02 段 — 业务域
|
|
str(BIZ_ORDER_NOT_FOUND): Message(
|
|
code=str(BIZ_ORDER_NOT_FOUND),
|
|
message="未找到该订单",
|
|
suggestion="请检查订单号是否正确,或在订单列表中通过筛选条件重新查询。",
|
|
severity="warn",
|
|
retryable=False,
|
|
),
|
|
str(BIZ_ORDER_RETENTION_NOT_EXPIRED): Message(
|
|
code=str(BIZ_ORDER_RETENTION_NOT_EXPIRED),
|
|
message="订单仍在数据保留期内",
|
|
suggestion="请等待保留期(服务完成后 180 天)结束,或走支付争议/法务豁免审批流程后再处理。",
|
|
severity="warn",
|
|
retryable=False,
|
|
),
|
|
str(BIZ_ORDER_INVALID_STATUS): Message(
|
|
code=str(BIZ_ORDER_INVALID_STATUS),
|
|
message="订单当前状态不支持该操作",
|
|
suggestion="请刷新订单详情查看最新状态,或前往订单列表查看状态流转。",
|
|
severity="warn",
|
|
retryable=False,
|
|
),
|
|
str(BIZ_RATE_LIMITED): Message(
|
|
code=str(BIZ_RATE_LIMITED),
|
|
message="请求过于频繁",
|
|
suggestion="请稍候 30 秒后再试。如持续触发,可联系管理员调整配额。",
|
|
severity="warn",
|
|
retryable=True,
|
|
),
|
|
# 03 段 — 数据域
|
|
str(DATA_VALIDATION_FAILED): Message(
|
|
code=str(DATA_VALIDATION_FAILED),
|
|
message="请求数据未通过校验",
|
|
suggestion="请检查必填字段和格式。鼠标悬停字段标签可查看填写要求。",
|
|
severity="warn",
|
|
retryable=False,
|
|
),
|
|
str(DATA_NOT_FOUND): Message(
|
|
code=str(DATA_NOT_FOUND),
|
|
message="未找到对应的数据",
|
|
suggestion="该记录可能已被删除或尚未创建,请刷新页面或返回列表确认。",
|
|
severity="warn",
|
|
retryable=False,
|
|
),
|
|
str(DATA_PERSIST_FAILED): Message(
|
|
code=str(DATA_PERSIST_FAILED),
|
|
message="数据保存失败",
|
|
suggestion="请稍后重试。如反复失败,请联系技术支持并保留错误码。",
|
|
severity="error",
|
|
retryable=True,
|
|
),
|
|
# 04 段 — 第三方域
|
|
str(THIRD_PARTY_UPSTREAM_ERROR): Message(
|
|
code=str(THIRD_PARTY_UPSTREAM_ERROR),
|
|
message="外部服务暂时不可用",
|
|
suggestion="我们正在同步上游状态,请稍后重试;如紧急可联系客服。",
|
|
severity="error",
|
|
retryable=True,
|
|
),
|
|
str(THIRD_PARTY_TIMEOUT): Message(
|
|
code=str(THIRD_PARTY_TIMEOUT),
|
|
message="外部服务响应超时",
|
|
suggestion="请稍后重试,或在网络稳定的环境下再次尝试。",
|
|
severity="error",
|
|
retryable=True,
|
|
),
|
|
# 05 段 — 系统域
|
|
str(SYS_INTERNAL_ERROR): Message(
|
|
code=str(SYS_INTERNAL_ERROR),
|
|
message="系统内部异常",
|
|
suggestion="请稍后重试。如反复出现,请联系技术支持并提供错误码。",
|
|
severity="error",
|
|
retryable=True,
|
|
),
|
|
str(SYS_CONFIG_MISSING): Message(
|
|
code=str(SYS_CONFIG_MISSING),
|
|
message="系统配置缺失",
|
|
suggestion="请联系运维人员检查服务配置,确认环境变量已正确注入。",
|
|
severity="error",
|
|
retryable=False,
|
|
),
|
|
str(SYS_RESOURCE_EXHAUSTED): Message(
|
|
code=str(SYS_RESOURCE_EXHAUSTED),
|
|
message="系统资源不足",
|
|
suggestion="当前访问量过高,请稍后再试;如紧急可联系运维扩容。",
|
|
severity="error",
|
|
retryable=True,
|
|
),
|
|
}
|
|
|
|
|
|
# 兜底文案 (任何未注册码点都返回这一份, 永远不会让用户看到空白)
|
|
_FALLBACK_MESSAGE = Message(
|
|
code=str(FALLBACK_CODE),
|
|
message="服务暂时无法处理请求",
|
|
suggestion="请稍后重试,或联系技术支持并提供错误码以便排查。",
|
|
severity="error",
|
|
retryable=True,
|
|
)
|
|
|
|
|
|
def get_message(code: str, locale: str = "zh-CN") -> Message:
|
|
"""根据业务码查找本地化消息.
|
|
|
|
行为契约:
|
|
- 已注册码点 → 返回 MESSAGES_ZH_CN 中的 Message
|
|
- 未注册但格式合法的码点 → 返回 _FALLBACK_MESSAGE (但 code 字段被替换为入参,
|
|
便于排查是哪类未注册)
|
|
- 格式非法码点 → 返回 _FALLBACK_MESSAGE (code 字段保留原值以保留上下文)
|
|
|
|
当前实现只支持 zh-CN; 后续多 locale 时改为 MESSAGES[locale].get(code, fallback).
|
|
"""
|
|
if locale != "zh-CN":
|
|
# 当前仅支持 zh-CN, 其他 locale 走兜底
|
|
return _FALLBACK_MESSAGE
|
|
msg = MESSAGES_ZH_CN.get(code)
|
|
if msg is not None:
|
|
return msg
|
|
# 未注册: 保留原 code 用于排查, 文案用兜底
|
|
return Message(
|
|
code=code,
|
|
message=_FALLBACK_MESSAGE.message,
|
|
suggestion=_FALLBACK_MESSAGE.suggestion,
|
|
severity=_FALLBACK_MESSAGE.severity,
|
|
retryable=_FALLBACK_MESSAGE.retryable,
|
|
)
|
|
|
|
|
|
def is_registered(code: str) -> bool:
|
|
"""检查码点是否已注册 (CI 校验脚本与单元测试用)."""
|
|
return code in MESSAGES_ZH_CN
|
|
|
|
|
|
def registered_codes() -> Dict[str, Message]:
|
|
"""返回当前已注册的所有码点 (测试与文档生成用)."""
|
|
return dict(MESSAGES_ZH_CN)
|