"""Unified error codes (架构文档 §9.2, FR-SYS-02). Segments: ``4xxxx`` client error, ``5xxxx`` server error, ``6xxxx`` external dependency. Splitting 5xxxx from 6xxxx lets monitoring separate "our bug" from "vendor outage" without parsing messages. Every code carries a user-readable ``message`` and an optional ``hint`` telling the user what to do next — required by FR-SYS-02 and NFR-UX-02. """ from __future__ import annotations from dataclasses import dataclass from enum import IntEnum from typing import Any class ErrorCode(IntEnum): OK = 0 # --- 4xxxx client ----------------------------------------------------- BAD_REQUEST = 40000 VALIDATION_FAILED = 40001 IDEMPOTENCY_CONFLICT = 40002 UPLOAD_REJECTED = 40010 EVENTS_BATCH_REJECTED = 40020 UNAUTHENTICATED = 40100 TOKEN_EXPIRED = 40101 TOKEN_INVALID = 40102 REFRESH_TOKEN_INVALID = 40103 REFRESH_TOKEN_EXPIRED = 40104 SESSION_REVOKED = 40105 SMS_CODE_INVALID = 40110 SMS_CODE_EXPIRED = 40111 FORBIDDEN = 40300 QUOTA_EXHAUSTED = 40301 CONSENT_REQUIRED = 40302 MINOR_RESTRICTED = 40303 TASK_BONUS_CAP_REACHED = 40310 NOT_FOUND = 40400 TASK_BONUS_UNKNOWN = 40410 REPORT_NOT_FOUND = 40420 STATE_CONFLICT = 40900 TASK_BONUS_ALREADY_CLAIMED = 40910 REPORT_ALREADY_EXISTS = 40920 RATE_LIMITED = 42900 SMS_SEND_TOO_FREQUENT = 42910 SMS_DAILY_LIMIT = 42911 # --- 5xxxx server ----------------------------------------------------- INTERNAL_ERROR = 50000 DB_ERROR = 50001 TASK_STATE_INVALID = 50002 CONFIG_ERROR = 50003 NOT_IMPLEMENTED = 50100 # --- 6xxxx external dependency --------------------------------------- UPSTREAM_ERROR = 60000 LLM_ALL_PROVIDERS_FAILED = 60001 LLM_STRUCTURED_OUTPUT_FAILED = 60002 LLM_DAILY_BUDGET_EXCEEDED = 60003 LLM_PROMPT_CACHE_VIOLATION = 60004 MODERATION_UNAVAILABLE = 60005 LLM_SCENE_DEGRADED = 60006 SMS_PROVIDER_FAILED = 61000 @dataclass(frozen=True, slots=True) class ErrorMeta: http_status: int message: str hint: str | None = None ERROR_META: dict[ErrorCode, ErrorMeta] = { ErrorCode.OK: ErrorMeta(200, "ok"), ErrorCode.BAD_REQUEST: ErrorMeta(400, "请求参数有误", "请检查后重试"), ErrorCode.VALIDATION_FAILED: ErrorMeta(422, "请求参数校验未通过", "请按提示修改后重试"), ErrorCode.IDEMPOTENCY_CONFLICT: ErrorMeta( 409, "相同请求正在处理中", "请稍后查看任务中心,不要重复提交" ), ErrorCode.UPLOAD_REJECTED: ErrorMeta( 400, "不支持的图片类型或超出大小限制", "请上传 20MB 以内的 JPG/PNG/WebP 图片" ), ErrorCode.EVENTS_BATCH_REJECTED: ErrorMeta(400, "埋点数据格式有误"), ErrorCode.UNAUTHENTICATED: ErrorMeta(401, "请先登录", "登录后即可继续"), ErrorCode.TOKEN_EXPIRED: ErrorMeta(401, "登录状态已过期", "请重新登录"), ErrorCode.TOKEN_INVALID: ErrorMeta(401, "登录状态无效", "请重新登录"), ErrorCode.REFRESH_TOKEN_INVALID: ErrorMeta(401, "登录状态无效", "请重新登录"), ErrorCode.REFRESH_TOKEN_EXPIRED: ErrorMeta(401, "登录状态已过期", "请重新登录"), ErrorCode.SESSION_REVOKED: ErrorMeta(401, "该会话已登出", "请重新登录"), ErrorCode.SMS_CODE_INVALID: ErrorMeta(401, "验证码错误", "请检查后重试"), ErrorCode.SMS_CODE_EXPIRED: ErrorMeta(401, "验证码已过期", "请重新获取"), ErrorCode.FORBIDDEN: ErrorMeta(403, "没有访问权限"), ErrorCode.QUOTA_EXHAUSTED: ErrorMeta(403, "今日额度已用完", "明天恢复,或完成任务多换几次"), ErrorCode.CONSENT_REQUIRED: ErrorMeta(403, "需要先完成授权", "请阅读并同意后继续"), ErrorCode.MINOR_RESTRICTED: ErrorMeta(403, "该功能未向未成年人开放"), ErrorCode.TASK_BONUS_CAP_REACHED: ErrorMeta( 403, "任务奖励已达上限", "明天再来,或等额度自然恢复" ), ErrorCode.NOT_FOUND: ErrorMeta(404, "内容不存在或已删除"), ErrorCode.TASK_BONUS_UNKNOWN: ErrorMeta(404, "任务不存在或暂未开放"), ErrorCode.REPORT_NOT_FOUND: ErrorMeta(404, "报告不存在或已删除"), ErrorCode.STATE_CONFLICT: ErrorMeta(409, "当前状态无法执行该操作"), ErrorCode.TASK_BONUS_ALREADY_CLAIMED: ErrorMeta( 409, "这个周期的任务奖励已领取", "下个周期再来" ), ErrorCode.REPORT_ALREADY_EXISTS: ErrorMeta(409, "本期报告已生成过", "每 30 天可重新生成一次"), ErrorCode.RATE_LIMITED: ErrorMeta(429, "操作太频繁了", "请稍后再试"), ErrorCode.SMS_SEND_TOO_FREQUENT: ErrorMeta(429, "发送太频繁了", "请稍后再试"), ErrorCode.SMS_DAILY_LIMIT: ErrorMeta(429, "今日发送次数已达上限", "明天再试"), ErrorCode.INTERNAL_ERROR: ErrorMeta(500, "服务开小差了", "请稍后重试"), ErrorCode.DB_ERROR: ErrorMeta(500, "服务开小差了", "请稍后重试"), ErrorCode.TASK_STATE_INVALID: ErrorMeta(500, "任务状态异常", "请到任务中心重试"), ErrorCode.CONFIG_ERROR: ErrorMeta(500, "服务配置异常", "请联系客服"), ErrorCode.NOT_IMPLEMENTED: ErrorMeta(501, "该能力尚未开放"), ErrorCode.UPSTREAM_ERROR: ErrorMeta(502, "外部服务暂时不可用", "请稍后重试"), ErrorCode.LLM_ALL_PROVIDERS_FAILED: ErrorMeta( 502, "AI 服务暂时不可用", "请稍后重试,额度不会被扣除" ), ErrorCode.LLM_STRUCTURED_OUTPUT_FAILED: ErrorMeta( 502, "AI 返回结果格式异常", "请稍后重试,额度不会被扣除" ), ErrorCode.LLM_DAILY_BUDGET_EXCEEDED: ErrorMeta( 503, "AI 能力今日已达服务上限", "请明天再试,核心功能不受影响" ), ErrorCode.LLM_PROMPT_CACHE_VIOLATION: ErrorMeta(500, "服务配置异常", "请联系客服"), ErrorCode.MODERATION_UNAVAILABLE: ErrorMeta( 503, "内容安全服务不可用", "为保证安全本次请求已中止,请稍后重试" ), ErrorCode.LLM_SCENE_DEGRADED: ErrorMeta( 503, "该功能今日暂时关闭", "AI 服务今日已达服务上限,明天恢复" ), ErrorCode.SMS_PROVIDER_FAILED: ErrorMeta(502, "短信服务暂时不可用", "请稍后重试"), } class AppError(Exception): """Base application error carrying a unified error code. Never let a raw traceback reach the client (FR-SYS-02). """ def __init__( self, code: ErrorCode, *, message: str | None = None, hint: str | None = None, data: dict[str, Any] | None = None, ) -> None: meta = ERROR_META.get(code, ERROR_META[ErrorCode.INTERNAL_ERROR]) self.code = code self.message = message or meta.message self.hint = hint if hint is not None else meta.hint self.http_status = meta.http_status self.data = data or {} super().__init__(f"[{int(code)}] {self.message}") class QuotaExhaustedError(AppError): def __init__(self, *, required: int, remaining: int, reset_at: str | None = None) -> None: super().__init__( ErrorCode.QUOTA_EXHAUSTED, data={"required": required, "remaining": remaining, "reset_at": reset_at}, ) class NotFoundError(AppError): def __init__(self, message: str | None = None) -> None: super().__init__(ErrorCode.NOT_FOUND, message=message) class ForbiddenError(AppError): def __init__(self, message: str | None = None) -> None: super().__init__(ErrorCode.FORBIDDEN, message=message)