src/webui/errors.py
Rama main · réplica de lectura del repositorio original.
"""后端错误码映射与统一出口。
设计原则(见 ../计划方案/API错误消息国际化实现方案.md):
- 后端存量错误消息保持中文原文(契约兼容),不逐处迁移;
- 本模块维护「高频错误文案 → error_code」映射表,由 error_code_middleware
在响应出口统一补 error_code 字段,前端按 locale 映射展示;
- 未收录的错误不补码,前端回退显示 error 原文,保证任何错误都能正常显示。
"""
from __future__ import annotations
import json
import logging
from aiohttp import web
logger = logging.getLogger("trpg")
# 高频/核心错误文案 → 稳定错误码。新增错误文案时优先在此登记,
# 使前端能提供本地化文案;命名按模块前缀(game_/plugin_/character_/...)。
ERROR_CODE_MAP: dict[str, str] = {
# 游戏
"游戏不存在": "game_not_found",
"游戏不存在,请刷新页面重新开始": "game_not_found",
"游戏不存在或存档目录不存在": "game_not_found",
"未加入本局": "player_not_in_game",
"未加入本局,无法订阅": "player_not_in_game",
"未加入本局,无法提交行动": "player_not_in_game",
"未加入本局,请先通过邀请链接加入": "player_not_in_game",
"本轮正在推进剧情,请等待下一轮开始": "round_processing",
"本轮行动已修改 3 次,请等待其他玩家或 GM 推进": "action_revision_limit",
"角色已死亡,无法提交行动": "character_deceased",
"没有可回滚的上一轮": "no_previous_round",
"仅 GM 可强制推进": "gm_only",
"仅 GM 可导出游戏": "gm_only",
"仅 GM 可删除游戏": "gm_only",
"仅 GM 可删除": "gm_only",
"GM only": "gm_only",
"仅 GM 可修改 NPC 头像": "gm_only",
"该游戏未设置房间密码": "room_password_not_set",
"房间密码错误": "room_password_wrong",
"当前游戏需要密码": "room_password_required",
"需要房间密码": "room_password_required",
"房间密码至少 6 位": "room_password_too_short",
"房间密码已更改,请重新输入": "room_password_changed",
"房间密码不能只包含空白字符": "room_password_blank",
"游戏名过长(上限 40 字)": "game_name_too_long",
"世界描述过长(上限 2000 字)": "world_description_too_long",
"行动文本过长(上限 500 字)": "action_text_too_long",
"当前是房主预览模式,请先开启允许代操作": "preview_mode_forbidden",
"本局玩家入口已关闭": "player_access_closed",
"请至少保留一个角色,无法删除最后一个": "cannot_delete_last_character",
# 孤注一掷
"只能由该检定的行动者本人处理孤注一掷,主持人只能告知风险或拒绝申请": "push_forbidden",
"孤注一掷请求格式无效": "push_invalid_request",
"检定选择已变化,请刷新后重试": "push_invalid_or_stale",
# 角色
"角色不存在": "character_not_found",
"玩家不存在": "player_not_found",
"目标玩家不存在": "player_not_found",
"无权修改他人角色卡": "forbidden_edit_character",
"无权删除他人角色": "forbidden_delete_character",
"角色背景过长(上限 8000 字)": "background_too_long",
"该角色当前并未死亡": "character_not_dead",
# 插件
"插件宿主未启用": "plugin_host_disabled",
"插件内容不存在或未启用": "plugin_content_unavailable",
"该插件没有可管理的角色卡": "plugin_no_character_cards",
"卡片解析路径非法": "plugin_invalid_card_path",
"支持包不存在": "plugin_package_not_found",
"支持包已存在": "plugin_package_exists",
"请选择要导入的内容包": "plugin_content_not_selected",
"请填写内容包 ID 再导入": "plugin_content_id_required",
# 存档
"存档目录不存在": "save_dir_not_found",
"存档文件不存在": "save_file_not_found",
"存档包不能超过 50 MB": "save_package_too_large",
"缺少存档 zip 文件": "save_zip_missing",
"存档上传需要 multipart/form-data": "save_multipart_required",
"未登录,无法删除存档": "save_delete_unauthorized",
"存档缺少 GM 身份,拒绝删除": "save_missing_gm_identity",
"未指定合法 world_id": "invalid_world_id",
"非法 source_world_id": "invalid_world_id",
"未指定合法的 world_id": "invalid_world_id",
"目标世界书不存在": "lorebook_not_found",
"世界不存在": "world_not_found",
"视角无效": "invalid_viewer",
# 系统
"系统未启动": "system_not_started",
"修正值不能为 0": "delta_zero",
"请输入 GM 指令": "gm_command_empty",
"seconds 必须是整数(秒,0=禁用)": "invalid_seconds",
}
@web.middleware
async def error_code_middleware(request: web.Request, handler) -> web.StreamResponse:
"""响应出口:为 4xx/5xx JSON 错误响应补充 error_code 字段。
不解析 SSE/文件等非 JSON 响应;响应体不可读时直接放行。
"""
response = await handler(request)
if response.status < 400:
return response
if response.content_type != "application/json":
return response
try:
body = response.body
except Exception:
return response
if not body:
return response
try:
data = json.loads(body.decode("utf-8"))
except (ValueError, UnicodeDecodeError):
return response
if not isinstance(data, dict) or not data.get("error"):
return response
if data.get("error_code"):
return response
code = ERROR_CODE_MAP.get(str(data["error"]))
if not code:
return response
data["error_code"] = code
try:
response.body = json.dumps(data, ensure_ascii=False).encode("utf-8")
response.headers["Content-Length"] = str(len(response.body))
except Exception:
logger.debug("错误码注入失败: %s", response.status, exc_info=True)
return response