src/webui/services/_common.py
Rama main · réplica de lectura del repositorio original.
"""WebUI services 共享的常量与工具。"""
from typing import Any
_GAME_KEY_SEP = "|"
_INVALID_GAME_KEY = ("__invalid_game_key__", "", "")
# 跨 HTTP、SSE 与 service 共用的输入边界。业务限制放在 service 层,
# routes 仅重新导入,避免不同入口各自维护一份数值。
MAX_LOREBOOK_CHARS = 5000
MAX_ACTION_CHARS = 1000
MAX_ACTIONS_PER_TURN = 3
MAX_SEED_CHARS = 2000
def professional_character_validation_error(exc: ValueError) -> tuple[str, str]:
"""Return a stable API error while keeping professional validation fail-closed."""
code = str(getattr(exc, "error_code", "") or "")
if code == "PROFESSIONAL_CHARACTER_BLUEPRINT_REQUIRED":
return code, (
"专业角色缺少可重建的规则数据,请使用当前规则的专业建卡器完成后再创建或加入。"
"旧角色卡不能仅凭展示属性安全转换。"
)
return "INVALID_PROFESSIONAL_CHARACTER", str(exc)
def _is_safe_game_key_part(s: str) -> bool:
"""校验 game_key 片段不可影响存档路径结构。"""
return "/" not in s and "\\" not in s and ".." not in s and "#" not in s and ":" not in s
def _parse_game_key(game_key: str) -> tuple[str, str, str]:
"""解析公开 game_key,并拒绝路径穿越片段。
A game key has at most three ``|`` parts. Extra parts used to be dropped,
which let ``a|b|c|anything`` alias the same game under a different key
(and slip past per-game state keyed by the raw string); they are refused.
"""
raw = str(game_key or "")
parts = raw.split(_GAME_KEY_SEP)
if len(parts) > 3:
return _INVALID_GAME_KEY
parsed = tuple(parts) if len(parts) == 3 else (raw, "", "")
if not all(_is_safe_game_key_part(part) for part in parsed):
return _INVALID_GAME_KEY
return parsed
def canonical_game_key(game_key: str) -> str:
"""The one spelling of a public game key, for keying per-game state."""
return _GAME_KEY_SEP.join(_parse_game_key(game_key))
def _is_safe_world_id(s: str) -> bool:
"""校验 world_id 不含路径遍历字符(允许中文等正常字符)。"""
return bool(s) and "/" not in s and "\\" not in s and ".." not in s
def resolve_default_scene_image(
resolver: Any,
world_id: str,
rule_id: str,
selection_key: str = "",
) -> dict[str, Any]:
"""Call the optional keyed default-image resolver without breaking old adapters.
Third-party lifecycle test adapters and older integrations still expose the
original two-argument callable. The keyed call is used by the built-in API
so each new save can select a stable member from its ruleset/world pool;
the fallback keeps those adapters source-compatible.
"""
if selection_key:
try:
return resolver(world_id, rule_id, selection_key)
except TypeError:
pass
return resolver(world_id, rule_id)
def is_game_gm(inst: Any, user_id: str, owner_authenticated: bool = False) -> bool:
"""GM 判定统一入口:该局主 GM,或已登录 owner(多人共用管理员账号都算 GM)。
所有 GM 门控必须走这里,避免散落的 ``== gm_uid`` 各自为政;
后续多 GM(co_gm_uids)也只需扩展这一个函数。
"""
if inst is None or not user_id:
return False
if user_id == getattr(inst, "gm_uid", ""):
return True
return bool(owner_authenticated)