merge(neice): 内测闭环阶段1 integration 并入 dev/2.0.0
Some checks failed
contract-gates / contract-gates (push) Has been cancelled
docs-gate / docs-gate (push) Has been cancelled

WU1 注册登录(用户名+密码/无短信/网关IP限流/防枚举/outbox)
WU2 new-api per-user ¥100 额度(预置池 claim/job userToken 位/per-user 门/quota_exhausted)
WU3 dify 形态A 游戏开发节点设计 + ops 池预置脚本

全 opt-in 默认 off(AIGC_NEWAPI_QUOTA_ENABLED / wanxiang.passport.password-auth-enabled)。
阶段2 dev 真机全环 e2e LIVE 反假绿。dev 前进 36 文件与本支 57 文件零交集。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-07-07 19:34:15 -07:00
commit 210ce70160
57 changed files with 4081 additions and 131 deletions

View File

@ -169,6 +169,9 @@ def _get_collector_cls():
self._tok_in = 0
self._tok_out = 0
self._metric_emitted = False # 面二 metric 只发一次(_flush 三条降级路会调多次,防重复计数)
# WU2 §3.9:模型调用抛错(new-api 非 2xx)时按 one-api 惯例分辨的失败因;仅 quota_exhausted 才写进
# 收口 sidecar 供 driver→result_out 落成 quota_exhausted(凭据失效/其他维持 llm_error,不透传)。默认 None。
self._failure_reason = None
async def on_reply(self, agent, input_kwargs, next_handler):
# C1:框架 _agent.py:615 先 yield ReplyEndEvent 再 yield finish Msg 再收尾;collector 是最外层 on_reply,
@ -194,6 +197,18 @@ def _get_collector_cls():
# 先拿到并 publish 到 bus(异常在下一次推进时才抛),driver 立刻收到回合终结、按盘面组 failed
# summary 快速失败;异常原样重抛,不掩盖失败(框架日志照记 exception)。合成失败(极端:事件类
# 构造变化)只告警、退回慢失败路,绝不遮原异常。
# WU2 §3.9:run 崩多因 new-api 非 2xx(额度耗尽 402/403 是其一)。best-effort 按 one-api 惯例分辨
# ——仅额度耗尽写 self._failure_reason,随 _flush 落进收口 sidecar,让 driver→result_out 落成
# quota_exhausted(而非含糊 llm_error);凭据失效/其他维持 llm_error。纯旁路,任何异常绝不遮原异常。
try:
import result_out as _ro # noqa: PLC0415 —— 复用单一分辨口径(one-api 惯例,阶段2 精确 message)
_reason = _ro.classify_newapi_failure_reason(*_ro.newapi_error_status_text(e))
if _reason == "quota_exhausted":
self._failure_reason = _reason
print(f"[cheap-service] 模型调用非 2xx 按 §3.9 分辨为额度耗尽 game={self._game_id}"
f"(quota_exhausted):{type(e).__name__}: {str(e)[:160]}", flush=True)
except Exception: # noqa: BLE001 —— 分辨纯旁路,失败不影响崩溃收口与原异常上抛
pass
self._flush() # 崩溃路也先落盘(部分成本/repairs 可回收)
try:
from agentscope.event import ReplyEndEvent # noqa: PLC0415
@ -230,6 +245,10 @@ def _get_collector_cls():
"budgetSoftTripped": bool(getattr(b, "budget_soft_tripped", False)),
"traceSummary": self._tracer.summary(),
}
# WU2 §3.9:仅当模型调用非 2xx 被分辨为额度耗尽时才带 failureReason(driver 据它落 quota_exhausted);
# 正常/其他失败不带此键 → driver 不透传 → result_out 走既有 llm_error 兜底,不误标。
if self._failure_reason:
summary["failureReason"] = self._failure_reason
ev = cheap_run.game_dir(self._game_id) / "evidence"
ev.mkdir(parents=True, exist_ok=True)
(ev / "service-run-summary.json").write_text(

View File

@ -25,23 +25,46 @@ def _resolve_base() -> str:
return client.resolve_base_url()
def _resolve_key() -> str:
"""NEWAPI_KEY(_bootstrap 已从凭据档注入 env;client.get_api_key 从 env 读)。"""
def _mask_token(tok) -> str:
"""token 日志脱敏(§3.8):前后各留 4 位、中间省略;过短/空则整体隐藏。绝不整条打 new-api 凭据。"""
if not tok:
return "<none>"
s = str(tok)
return "****" if len(s) <= 8 else f"{s[:4]}{s[-4:]}"
def _resolve_key(user_token: str | None = None) -> str:
"""解析本次生成用的 new-api 调用凭据(WU2 §3.7 F)。
优先用 job 随派带来的 per-user token(user_token);缺失则回落全局 env NEWAPI_KEY
(_bootstrap 已从凭据档注入 envclient.get_api_key env )回落是系统/编排/bake-off 触发的正常预期
(它们无 game_player 成员身份,后端 dispatch 层已按身份旁路不塞 token);额度门在后端 dispatch 层按身份堵死
(member create 路无 token fail-fast),worker 层只做有则用无则回落不硬拒,以免误伤既有验证路
回落时打一条 WARN:便于发现本该带 token member create job 异常走了共享计量(§3.7)
"""
if user_token:
return user_token
from worker import client # noqa: PLC0415
return client.get_api_key()
key = client.get_api_key()
# 可追溯日志(脱敏):回落全局 key。系统/编排/bake-off 属正常;若为真实 member create job 走到这里则异常。
print(f"[cheap-driver] job 无 userToken → 回落全局 env NEWAPI_KEY({_mask_token(key)})。"
f"系统/编排/bake-off 触发为正常预期;若为真实 member create job 则异常走共享计量(§3.7)。",
flush=True)
return key
def _cheap_credential_payload() -> dict:
def _cheap_credential_payload(user_token: str | None = None) -> dict:
"""组注册 cheap M3 凭据(POST /credential/)的请求体 —— OpenAI 兼容路,base 末尾补 /v1(决策②)。
tier2(anthropic_credential,host )不同:cheap OpenAI 兼容 /v1/chat/completions,
type=openai_credentialbase_url /v1( config.build_model_openai /v1 补全同口径)
api_key = 本次生成的 new-api 凭据:优先 job.userToken(per-user 额度),缺失回落全局 env(§3.7 F)
"""
base = _resolve_base().rstrip("/")
if not base.endswith("/v1"):
base = base + "/v1"
return {"data": {"type": "openai_credential", "api_key": _resolve_key(), "base_url": base}}
return {"data": {"type": "openai_credential", "api_key": _resolve_key(user_token), "base_url": base}}
def _write_session_cfg(session_id: str, *, external_game_id: str,
@ -182,6 +205,12 @@ def _build_summary(game_id: str, brief: str, verdict, svc: dict, turn: dict, t0:
}
# 9d-trace 源维度(verdictFull/driverType/models/stage);对照 None 时各键自然降级(result_out 省略)。
summary.update(cheap_studio.build_trace_source(verdict, driver_type, attempts, stage, model))
# WU2 §3.9:Service 侧在模型调用抛错时(new-api 非 2xx)按 one-api 惯例分辨,把 failureReason 写进收口 sidecar;
# 这里原样透传进 summary,result_out._map_failure_reason 据它把额度耗尽落成 quota_exhausted(而非含糊 llm_error)。
# 仅当 Service 明确判额度耗尽(quota_exhausted)才透传,凭据失效/其他仍走 llm_error(可重试),避免误标。
svc_reason = svc.get("failureReason")
if svc_reason:
summary["failureReason"] = svc_reason
return summary
@ -229,6 +258,12 @@ async def drive_cheap_generation(job: dict, *, base_url: str | None = None, user
brief = job.get("brief") or ""
t0 = time.time()
# WU2 §3.7 F:从 §6.1 job 取 per-user token(后端 dispatchGeneric 对真实 member 装、系统/编排旁路留空);
# 空串归一为 None(缺失 → _resolve_key 回落全局 env key)。整条 job 由 worker_service 透传至此,故直接从 job 取。
user_token = (job.get("userToken") or "").strip() or None
print(f"[cheap-driver] game={game_id} 凭据来源={'per-user token' if user_token else '全局 env key(回落)'} "
f"userToken={_mask_token(user_token)}", flush=True)
# create 路参数:brief→genre 确定性关键词路由(T4 最小品类接线,取代硬编码 scaffold_template=None)。
# 命中五类(经营/剧情/TRPG/非遗/解谜)→ 选 per-genre 黄金骨架 + genre 透传丰富度评分(生产 create 路
# 与 S5 bake_off 复验口径同轨);无命中 → (None, None) 走通用 _template(与旧行为一致,路由只加不减)。
@ -257,7 +292,7 @@ async def drive_cheap_generation(job: dict, *, base_url: str | None = None, user
headers.update(otel_carrier) # traceparent(/tracestate,若有);出站 :8300 各 POST 都带
async with httpx.AsyncClient() as http:
# ① 注册 OpenAI 兼容凭据(cheap M3 走 base+/v1)。
cred = (await http.post(f"{base_url}/credential/", json=_cheap_credential_payload(),
cred = (await http.post(f"{base_url}/credential/", json=_cheap_credential_payload(user_token),
headers=headers, timeout=30.0)).json()
credential_id = cred.get("credential_id") or cred.get("id")
# #2 setup fail-fast:Service 未返 id(建凭据/agent/session 任一失败)必须立即诚实失败,不带 id=None 往下走——

View File

@ -17,14 +17,61 @@ import hashlib
import json
from pathlib import Path
# §6.1 result-out / 契约#6 output.failureReason 七值枚举(对齐后端 FailureReasonEnum,真验改正:
# §6.1 result-out / 契约#6 output.failureReason 枚举(对齐后端 FailureReasonEnum,真验改正:
# 原稿用了 content_violation/budget_exceeded/generation_failed 三个不在枚举内的值 → 真 e2e 被后端
# DifyCallbackTxService L165 拒「failureReason 不在七值内」、回调事务回滚。真七值见 FailureReasonEnum)。
# DifyCallbackTxService L165 拒「failureReason 不在枚举内」、回调事务回滚。真枚举见 FailureReasonEnum)。
# WU2 §3.9 起新增 quota_exhausted(new-api per-user 额度耗尽/池空,网关 402/403;跨契约共享枚举同批落
# aigc.yaml/dify-workflow-io.json/FailureReasonEnum 三处)。
_FAILURE_REASONS = {
"unsafe_prompt", "intent_unclear", "no_template_match", "config_invalid",
"llm_error", "timeout", "asset_gen_failed",
"llm_error", "timeout", "asset_gen_failed", "quota_exhausted",
}
def newapi_error_status_text(exc) -> tuple:
"""从一个 new-api 调用异常里尽力抽出 (http_status, 错误体文本),供 classify_newapi_failure_reason 分辨(§3.9)。
兼容 openai SDK APIStatusError(.status_code / .response.status_code / .body / .message)与被上层框架
(AgentScope)包装后的普通异常(只剩 str)抽不到 status None,文本恒为 str(exc) 兜底关键词匹配
"""
status = getattr(exc, "status_code", None)
if status is None:
status = getattr(exc, "code", None)
if status is None:
resp = getattr(exc, "response", None)
status = getattr(resp, "status_code", None) if resp is not None else None
parts = []
for attr in ("message", "body"):
v = getattr(exc, attr, None)
if v:
parts.append(str(v))
parts.append(str(exc))
return status, " ".join(parts)
def classify_newapi_failure_reason(status=None, text: str = "") -> str:
"""据 one-api/new-api 惯例把网关非 2xx 分辨成失败因(WU2 §3.9)。
返回 'quota_exhausted'(额度耗尽,干净失败不可重试) 'llm_error'(凭据失效/其他,可重试不塌成额度耗尽)
判据:HTTP 402/403 额度耗尽;401 凭据失效(维持 llm_error);状态码取不到时按错误体关键词兜底
阶段2 待验402/403 与确切错误体 message S0 只坐实了管理面未测 /v1/chat/completions 耗尽错误体,
确切匹配待 S4 真机耗尽/凭据失效测试坐实;此处先给 one-api 惯例初值(状态码优先关键词兜底)
"""
try:
s = int(status) if status is not None else None
except (TypeError, ValueError):
s = None
if s in (402, 403):
return "quota_exhausted"
if s == 401:
return "llm_error" # 凭据失效:维持可重试,不塌成 quota_exhausted(§3.9 ②)
# 状态码取不到时(被框架包装成普通异常)按 one-api 耗尽错误体惯例关键词兜底 —— 确切 message 阶段2 待精确。
t = (text or "").lower()
quota_markers = ("insufficient user quota", "insufficient quota", "余额不足", "额度不足", "quota not enough")
if any(m in t for m in quota_markers):
return "quota_exhausted"
return "llm_error"
# 源工程 2.0 profile 必填三枚举(contracts/agent-loop/source-project.schema.json:39)。
_REQUIRED_PROFILE_KEYS = ("tickModel", "inputModel", "progressModel")
@ -86,14 +133,19 @@ def _read_bundle(game_dir: Path) -> str | None:
def _map_failure_reason(summary: dict, explicit: str | None) -> str:
"""把 run-summary 的失败信号映射到契约#6 七值 failureReason 枚举。
"""把 run-summary 的失败信号映射到契约#6 failureReason 枚举。
显式入参(在七值内)优先;否则统一归 `llm_error`契约#6 catch-all(SaaGraphDispatcher 同口径:
图内部真因[九门不过 / 未收敛 / 预算熔断 / engineBundle ]不在七值内时归生成执行链异常最近桶,
真因已落 summary/日志):枚举无 budget/generation 专项值,故不强造统一 llm_error
优先级:显式入参(在枚举内)> summary.failureReason(在枚举内)> catch-all `llm_error`
summary.failureReason WU2 §3.9 的桥:Service 侧在模型调用抛错时按 one-api 惯例分辨额度耗尽,
经收口 sidecar driver._build_summary 透传到 summary,这里据它落成 quota_exhausted(而非含糊 llm_error)
其余图内部真因[九门不过 / 未收敛 / 预算熔断 / engineBundle ]不在枚举内 生成执行链异常最近桶
(SaaGraphDispatcher 同口径,真因已落 summary/日志);枚举无 budget/generation 专项值,不强造统一 llm_error
"""
if explicit and explicit in _FAILURE_REASONS:
return explicit
svc_reason = (summary or {}).get("failureReason")
if svc_reason and svc_reason in _FAILURE_REASONS:
return svc_reason
return "llm_error"

View File

@ -16,13 +16,27 @@ import worker_service as W # noqa: E402
def test_credential_payload_openai_compatible_with_v1(monkeypatch):
# 便宜档 M3 走 OpenAI 兼容路:type=openai_credential、base_url 带 /v1、api_key 非空。
monkeypatch.setattr(D, "_resolve_base", lambda: "http://100.64.0.8:3000")
monkeypatch.setattr(D, "_resolve_key", lambda: "sk-test")
# WU2 §3.7:_resolve_key 现签名 (user_token=None);缺 token 走 env 回落(此桩恒回同一 key)。
monkeypatch.setattr(D, "_resolve_key", lambda user_token=None: "sk-test")
payload = D._cheap_credential_payload()
assert payload["data"]["type"] == "openai_credential"
assert payload["data"]["base_url"].endswith("/v1")
assert payload["data"]["api_key"] == "sk-test"
def test_resolve_key_prefers_user_token_else_env_fallback(monkeypatch):
# WU2 §3.7 F:job 带 userToken 则用它(不碰 env);缺失回落全局 env key(client.get_api_key)。
import worker.client as _wc
monkeypatch.setattr(_wc, "get_api_key", lambda: "sk-env-fallback")
assert D._resolve_key("sk-user-per") == "sk-user-per" # per-user token 优先
assert D._resolve_key(None) == "sk-env-fallback" # 缺失回落 env
assert D._resolve_key("") == "sk-env-fallback" # 空串视同缺失
# 凭据体据 user_token 选 key(有则 per-user、无则回落 env)。
monkeypatch.setattr(D, "_resolve_base", lambda: "http://100.64.0.8:3000")
assert D._cheap_credential_payload("sk-user-per")["data"]["api_key"] == "sk-user-per"
assert D._cheap_credential_payload()["data"]["api_key"] == "sk-env-fallback"
def test_build_summary_shape_feeds_result_out(tmp_path, monkeypatch):
# _build_summary 组的 summary 能被 result_out.build_result_out 吃出 succeeded + trace(costRmb/repairs)。
monkeypatch.setattr(cheap_run, "game_dir", lambda gid: tmp_path / f"amgen-{gid}")
@ -136,7 +150,7 @@ def test_drive_cheap_generation_fake_sse(tmp_path, monkeypatch):
import _bootstrap
monkeypatch.setattr(_bootstrap, "ensure_api_key_env", lambda: None)
monkeypatch.setattr(D, "_cheap_credential_payload",
lambda: {"data": {"type": "openai_credential", "api_key": "sk", "base_url": "http://x/v1"}})
lambda user_token=None: {"data": {"type": "openai_credential", "api_key": "sk", "base_url": "http://x/v1"}})
class _Resp:
def __init__(self, d): self._d = d
@ -230,7 +244,7 @@ def _install_common_stubs(tmp_path, monkeypatch):
monkeypatch.setattr("cheap_verify.verify_richness", _fake_richness)
monkeypatch.setattr(_bootstrap, "ensure_api_key_env", lambda: None)
monkeypatch.setattr(D, "_cheap_credential_payload",
lambda: {"data": {"type": "openai_credential", "api_key": "sk", "base_url": "http://x/v1"}})
lambda user_token=None: {"data": {"type": "openai_credential", "api_key": "sk", "base_url": "http://x/v1"}})
# 有界轮询提速:sidecar 缺失时默认等 5s → 50ms(保留真实 bounded-poll,只缩窗口)。
_orig_rss = D._read_service_run_summary
monkeypatch.setattr(D, "_read_service_run_summary", lambda gid, wait_s=0.05: _orig_rss(gid, wait_s=wait_s))

View File

@ -105,6 +105,77 @@ def test_explicit_failure_reason_wins():
assert out["failureReason"] == "timeout"
# ── WU2 §3.9:额度耗尽干净失败 + one-api 惯例分辨 ──
def test_quota_exhausted_in_enum():
"""quota_exhausted 已进 failureReason 枚举(跨契约共享枚举同批新增,与 aigc.yaml/dify-workflow-io.json 一致)。"""
assert "quota_exhausted" in R._FAILURE_REASONS
def test_summary_failure_reason_quota_maps_through():
"""WU2 §3.9:summary.failureReason=quota_exhausted(Service 分辨→driver 透传)→ 落 failureReason=quota_exhausted。"""
gd = _make_game_dir(_tmp())
job = {"traceId": "tq", "templateId": "generic", "brief": "x"}
summary = {"finished": False, "verdict": {"pass": False, "failedGates": None},
"failureReason": "quota_exhausted"}
out = R.build_result_out(job, summary, gd)
assert out["status"] == "failed"
assert out["failureReason"] == "quota_exhausted"
def test_summary_failure_reason_ignored_when_not_in_enum():
"""summary.failureReason 非枚举值 → 不透传,回落 catch-all llm_error(防脏值污染契约)。"""
gd = _make_game_dir(_tmp())
job = {"traceId": "tq2", "templateId": "generic", "brief": "x"}
summary = {"finished": False, "verdict": {"pass": False}, "failureReason": "some_garbage"}
out = R.build_result_out(job, summary, gd)
assert out["failureReason"] == "llm_error"
def test_explicit_failure_reason_wins_over_summary():
"""显式入参优先级高于 summary.failureReason。"""
gd = _make_game_dir(_tmp())
job = {"traceId": "tq3", "templateId": "generic", "brief": "x"}
summary = {"verdict": {"pass": False}, "failureReason": "quota_exhausted"}
out = R.build_result_out(job, summary, gd, failure_reason="timeout")
assert out["failureReason"] == "timeout"
def test_classify_newapi_status_codes():
"""one-api 惯例:402/403 → quota_exhausted;401 → llm_error(凭据失效,可重试,不塌成额度耗尽)。"""
assert R.classify_newapi_failure_reason(402) == "quota_exhausted"
assert R.classify_newapi_failure_reason(403) == "quota_exhausted"
assert R.classify_newapi_failure_reason(401) == "llm_error"
assert R.classify_newapi_failure_reason(500) == "llm_error"
assert R.classify_newapi_failure_reason(None) == "llm_error"
def test_classify_newapi_keyword_fallback():
"""状态码取不到(被框架包装)时按 one-api 错误体关键词兜底分辨额度耗尽(阶段2 待精确 message)。"""
assert R.classify_newapi_failure_reason(None, "error: insufficient user quota") == "quota_exhausted"
assert R.classify_newapi_failure_reason(None, "当前分组余额不足,请充值") == "quota_exhausted"
assert R.classify_newapi_failure_reason(None, "connection reset by peer") == "llm_error"
def test_newapi_error_status_text_extraction():
"""从带 status_code/response 的异常对象抽 (status, text);抽不到 status 返 None、文本兜底 str(exc)。"""
class _Err(Exception):
status_code = 402
message = "insufficient user quota"
st, txt = R.newapi_error_status_text(_Err("boom"))
assert st == 402 and "insufficient" in txt
class _Resp:
status_code = 403
class _Wrapped(Exception):
response = _Resp()
st2, _ = R.newapi_error_status_text(_Wrapped("x"))
assert st2 == 403
st3, txt3 = R.newapi_error_status_text(ValueError("plain error"))
assert st3 is None and "plain error" in txt3
def test_traceid_fallback_to_job_id():
"""traceId 缺 → 用 job_id(贯穿键同值)。"""
gd = _make_game_dir(_tmp())

View File

@ -46,6 +46,14 @@ def log(msg: str) -> None:
print(f"[cheap-worker-service] {msg}", flush=True)
def _mask_token(tok) -> str:
"""token 日志脱敏(WU2 §3.8):前后各留 4 位、中间省略;过短/空则整体隐藏。绝不整条打 userToken。"""
if not tok:
return "<none>"
s = str(tok)
return "****" if len(s) <= 8 else f"{s[:4]}{s[-4:]}"
# ---------- §6.1 job-in 解析 ----------
def parse_job(raw: bytes) -> dict | None:
@ -163,6 +171,9 @@ def _service_run_fn(job: dict):
import cheap_service_driver
base_url = os.environ.get("CHEAP_SERVICE_URL", "http://127.0.0.1:8300")
# WU2 §3.7 F:整条 job(含 §6.1 追加的 userToken)原样透传给 driver;driver 从 job.get("userToken") 取
# per-user token 装 new-api 凭据、缺失回落全局 env key(见 cheap_service_driver._resolve_key)。
# 此处不拆字段单独传递,避免冗余管道——透传整 job 即满足「worker 取 job token」。
return asyncio.run(cheap_service_driver.drive_cheap_generation(job, base_url=base_url))
@ -541,8 +552,10 @@ def make_handler(state: WorkerState):
pass
trace_id = job.get("traceId") or job.get("job_id")
accepted, code = try_enqueue(state, job)
# WU2 §3.7/§3.8:记 userToken 是否随 job 带来(脱敏)——便于排「member create 该带 token 却没带」。
log(f"收到 job trace_id={trace_id}, templateId={job.get('templateId')}, "
f"gameId={job.get('gameId')}{'受理' if accepted else '队满拒'}({code})")
f"gameId={job.get('gameId')}, userToken={_mask_token(job.get('userToken'))} "
f"{'受理' if accepted else '队满拒'}({code})")
self._send_json(code, {"accepted": accepted, "job_id": job.get("job_id"),
"traceId": trace_id, "queued": state.queue.qsize()})

View File

@ -292,5 +292,6 @@ components:
description: >-
生成失败原因分类(结构化错误码,映射用户可读提示)。取值与契约#6 dify-workflow-io.json output.failureReason 一致。
unsafe_prompt=Prompt 不安全 / intent_unclear=意图不清 / no_template_match=无匹配模板 /
config_invalid=配置校验失败 / llm_error=LLM 异常 / timeout=超时 / asset_gen_failed=素材生成失败。
enum: [unsafe_prompt, intent_unclear, no_template_match, config_invalid, llm_error, timeout, asset_gen_failed]
config_invalid=配置校验失败 / llm_error=LLM 异常 / timeout=超时 / asset_gen_failed=素材生成失败 /
quota_exhausted=生成额度已用完WU2 new-api per-user 额度耗尽/池空,网关 402/403
enum: [unsafe_prompt, intent_unclear, no_template_match, config_invalid, llm_error, timeout, asset_gen_failed, quota_exhausted]

View File

@ -0,0 +1,52 @@
-- =============================================================================
-- 契约 #2 DB 迁移 | 主题:内测·用户名+密码注册登录WU12026-07-07 设计 §3.1/§3.6| ownerpassportsystem 内扩展)
-- 文件V31.0.0__passport_player_add_username_password.sqlFlyway只 ALTER/新增;已合入禁止修改,回滚见补偿迁移 V31.0.1
-- 依据docs/agent-specs/2026-07-07-内测-WU1-注册登录用户名密码-设计.md草案创始人内测口径拍定
-- 版本定序:执行副本 db/migration/ 实测已连续到 V30.0.0(含 V26 aigc idempotency本件取其上下一号 V31.0.0(已 ls|sort -V|tail 复核)。
-- 守门①(沿用 V11本件同时落 contracts/db-schemas/(契约源)+ huijing-server 执行副本(唯一执行 classpath
-- 不放任何单模块 -server/db/migration/(避免同版本 V31 出现在多个 classpath jar 触发 Flyway 重复校验失败)。
-- 守门②:含中文 SQLmini-desktop 执行必须 --default-character-set=utf8mb4。
-- 内容两段:
-- 1. ALTER game_player —— 加 username/password 两列、放宽 mobile 可空、加 uk_username、register_channel 值域扩 password
-- 2. CREATE game_passport_register_outbox —— 「注册成功」事件本地消息表三条注册路共用出口至少一次投递生产者§3.6)。
-- =============================================================================
-- -----------------------------------------------------------------------------
-- 段 1ALTER game_player —— 单表双身份路径共存(手机号 / 用户名 二选一§3.1
-- 关键mobile 由 NOT NULL 放宽为 NULL承载无手机号的用户名用户uk_mobile 对 NULL 行不施约束,
-- uk_username 对 NULL 行(手机号用户)不施约束,两条唯一键在同表互不干扰。
-- 存量兼容:存量行 mobile 均有值、username/password 取新列默认 NULL天然落「手机号路径」侧放宽为松约束不破坏任何存量行。
-- password 列BCrypt 密文(约 60 字符,留至 100 兼容算法前缀/未来迁移);⚠ 永不返回任何 RespVO、永不落日志。
-- -----------------------------------------------------------------------------
ALTER TABLE `game_player`
ADD COLUMN `username` VARCHAR(30) NULL COMMENT '用户名(内测用户名密码路登录主键之一,^[a-zA-Z0-9]{4,30}$;手机号用户为 NULL' AFTER `mobile`,
ADD COLUMN `password` VARCHAR(100) NULL COMMENT 'BCrypt 密文(用户名路 60 字符左右,留 100 兼容算法前缀;⚠ 永不返回、永不落日志;非用户名用户为 NULL' AFTER `username`,
MODIFY COLUMN `mobile` VARCHAR(11) NULL COMMENT '手机号登录主键之一放宽可空承载无手机号的用户名用户2026-07-07 WU1日志/响应必须脱敏)',
MODIFY COLUMN `register_channel` VARCHAR(16) NOT NULL DEFAULT 'sms' COMMENT '注册通道sms验证码 / invite邀请码旁路 / password用户名密码漏斗归因2026-07-07 WU1 扩 password',
ADD UNIQUE KEY `uk_username` (`username`, `deleted`, `tenant_id`) COMMENT '用户名唯一(含 deleted/tenant_id 适配逻辑删+多租户,同 uk_mobile 范式NULL 行不参与约束,手机号用户不受限)';
-- -----------------------------------------------------------------------------
-- 段 2CREATE game_passport_register_outbox —— 「注册成功」事件本地消息表(事务性 outbox§3.6
-- 语义注册事务内与建号原子写一行status=0 未投递);事务外投递器(@Scheduled扫未投递发 RocketMQ、
-- 发成功置 status=1 已投递。把「至少一次」锚在 DB 事务上而非 MQ 即时可达性上,杜绝「注册成功但事件从未入队」黑洞。
-- 三条注册路sms 自动注册 / invite / password共用此出口WU2 new-api 开户充值消费端据 user_id 幂等(至少一次必带重复投递)。
-- 多租户本表为机制表DO 标 @TenantIgnore不含 tenant_id 列),投递器 @Scheduled 无租户上下文可全表扫。
-- -----------------------------------------------------------------------------
CREATE TABLE `game_passport_register_outbox` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '编号',
`user_id` BIGINT NOT NULL COMMENT '新注册玩家编号game_player.idWU2 开户充值幂等键)',
`register_channel` VARCHAR(16) NOT NULL COMMENT '注册通道sms / invite / password消费端口径统一在此不区分登录方式',
`anon_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '注册时携带的客户端 anonId匿名↔登录归因缺失落空串',
`idempotent_key` VARCHAR(64) NOT NULL COMMENT '幂等键(=register:{userId}uk 保证每次注册至多一行 outbox',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '投递状态0未投递 1已投递投递器发 MQ 成功后置 1',
`delivered_time` DATETIME NULL COMMENT '投递成功时间status=1 时回填,审计)',
-- 标准审计列(与 game_player 同范式;@PermitAll 免登注册无登录态creator/updater 服务层显式兜底 "0"
`creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者(审计列)',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '逻辑删除0未删 1已删',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_idempotent_key` (`idempotent_key`, `deleted`) COMMENT '幂等键唯一(防重复写 outbox',
KEY `idx_status` (`status`) COMMENT '投递器按未投递态扫描status=0'
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '注册成功事件本地消息表(事务性 outbox至少一次投递生产者WU1 §3.6';

View File

@ -0,0 +1,24 @@
-- =============================================================================
-- 回滚补偿迁移 | 主题:撤销 V31.0.0 用户名+密码注册登录 schemaWU1
-- 文件V31.0.1__passport_player_add_username_password_rollback.sql
-- ⚠ 这是「手动补偿」脚本,**只放契约源 contracts/db-schemas/,绝不放入执行副本 db/migration/**
-- Flyway 会自动应用 db/migration/ 下的所有版本若把本回滚脚本放进去migrate 时会立刻把刚加的列/表 DROP 掉。
-- 需要回滚时由 DBA 手动执行本脚本(并按下述前置确认),执行后同步删除 db/migration/ 的 V31.0.0 与 flyway_schema_history 对应行。
-- 与 V11 对 player_user_id 放宽的回滚纪律同口径mobile 收回 NOT NULL 前必须先确认无 NULL 行。
-- =============================================================================
-- 前置确认①(收回 mobile NOT NULL 的硬前置):确认无「无手机号」的用户名用户,否则收回 NOT NULL 会失败。
-- SELECT COUNT(*) FROM `game_player` WHERE `mobile` IS NULL; -- 必须为 0否则先清理/迁移这些用户名用户
-- 前置确认②:确认 outbox 无未投递残留status=0或接受丢弃这些未投递事件。
-- SELECT COUNT(*) FROM `game_passport_register_outbox` WHERE `status` = 0 AND `deleted` = b'0';
-- 撤销段 1drop 唯一键与两列mobile 收回 NOT NULL前置确认①通过后执行
ALTER TABLE `game_player`
DROP INDEX `uk_username`,
DROP COLUMN `password`,
DROP COLUMN `username`,
MODIFY COLUMN `mobile` VARCHAR(11) NOT NULL COMMENT '手机号(登录主键;日志/响应必须脱敏,库内 MVP 明文+唯一键)';
-- register_channel 的 COMMENT 扩了 password 值域,属纯注释放宽、无需强制回退(存量数据不含该值时收回亦可,按需)。
-- 撤销段 2drop outbox 机制表
DROP TABLE IF EXISTS `game_passport_register_outbox`;

View File

@ -0,0 +1,58 @@
-- =============================================================================
-- 契约 #2 DB 迁移 | 主题内测·new-api per-user ¥100 额度池WU22026-07-07 设计 §3.5| owneraigc派发期映射查询 co-locate
-- 文件V32.0.0__create_newapi_quota_pool.sqlFlyway纯新增表已合入禁止修改回滚 = drop 表 + 摘事件消费者)
-- 依据docs/agent-specs/2026-07-07-内测-WU2-newapi额度接入-设计.md草案创始人拍定「离线预置池 + 注册 claim」
-- 版本定序:执行副本 db/migration/ 实测已连续到 V31.0.0WU1 game_player 增列 + outbox本件顺延取 V32.0.0(已 ls|sort -V|tail 复核)。
-- WU1 与 WU2 同批新增迁移——WU1 取 V31、WU2 顺延 V32避免 Flyway 撞号校验失败。
-- 守门①(沿用 V11/V31本件同时落 contracts/db-schemas/(契约源)+ huijing-server 执行副本(唯一执行 classpath
-- 不放任何单模块 -server/db/migration/(避免同版本出现在多个 classpath jar 触发 Flyway 重复校验失败)。
-- 守门②:含中文 SQLmini-desktop 执行必须 --default-character-set=utf8mb4。
-- 内容CREATE newapi_quota_pool —— 离线 ops 脚本预建 FREE 条目 + 注册 claim 绑玩家FREE→CLAIMED账本在 new-api 网关。
-- =============================================================================
-- -----------------------------------------------------------------------------
-- CREATE newapi_quota_pool —— new-api per-user 额度预置池§3.5
-- 语义ops 脚本game-runtime/tools/newapi_pool_provision.py离线预建 N 个 (new-api user + token + ¥100)
-- 按条目 INSERTstatus=FREE、claimed_* 空);注册/懒 claim 时某条被 CAS 抢占翻转 CLAIMED 并绑 game_player。
-- 账本口径唯一 = new-api 网关(权威余额=token remain_quota、权威消耗=user used_quota本表只存 grant_quota 审计快照,不在 game-cloud 记余额。
-- 列名严格对齐 ops 脚本 emit 的 INSERT(newapi_user_id, newapi_token_id, newapi_token_key, grant_quota,
-- quota_per_unit_snapshot, usd_rate_snapshot, status),去重键 uk_newapi_user(newapi_user_id) 供 ON DUPLICATE KEY UPDATE 幂等补池。
-- 审计列creator/create_time/updater/update_time/deleted与 tenant_id 均给默认值ops 脚本 raw INSERT 只给业务列,
-- 审计列/租户列走 DB 默认落值;运行时 claim/查询经 TenantUtils.executeIgnore 跨租户存取(池是系统级资源)。
-- 幂等三键(仿 game_trade_grant 的 uk_biz_no
-- · uk_newapi_user —— 预建条目按 new-api 用户去重(脚本补池锚);
-- · uk_claimed_by —— 一玩家至多一条 CLAIMEDFREE 时 claimed_by 为 NULLNULL 不参与唯一约束,多条 FREE 并存);
-- · uk_biz_no —— claim 幂等键 claim_<gamePlayerId>FREE 时为 NULL
-- 并发/重投由 status='FREE' 单条 CAS + 上述两 NULL 唯一键三重收敛成一条:同玩家不占第二条、两玩家不抢同一条。
-- newapi_token_key 是 new-api 调用凭据明文落库(内测可接受、须视为敏感):随 job 内网下发须日志脱敏,生产化应加密列(红线待办,本阶段不做)。
-- 前后兼容:纯新增表,不改任何既有表结构,无迁移风险。
-- ⚠ 再 claimS4 凭据失效路,本单不实现):将 CLAIMED 隔离为 INVALID 时必须同步清空 claimed_by_game_player_id/biz_no
-- 否则与该玩家新 CLAIMED 条目撞 uk_claimed_by/uk_biz_no。本单只落 FREE→CLAIMEDINVALID 转换延后到 S4。
-- -----------------------------------------------------------------------------
CREATE TABLE `newapi_quota_pool` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '池条目编号',
`newapi_user_id` BIGINT NOT NULL COMMENT '预建的 new-api users.id脚本灌数去重键',
`newapi_token_id` BIGINT NOT NULL COMMENT '预建的 new-api tokens.id',
`newapi_token_key` VARCHAR(64) NOT NULL COMMENT 'new-api tokens.key48 位 sk-…;⚠ 敏感,随 job 下发须脱敏,永不整条落日志)',
`grant_quota` BIGINT NOT NULL COMMENT '该条目预置的 ¥100 折算 quota审计快照权威余额以网关 token.remain_quota 为准)',
`quota_per_unit_snapshot` BIGINT NOT NULL COMMENT '折算时网关 quota_per_unit默认 500000审计追溯用',
`usd_rate_snapshot` DECIMAL(10,4) NOT NULL COMMENT '折算时 usd_exchange_rate默认 7.3,审计追溯用)',
`status` VARCHAR(16) NOT NULL DEFAULT 'FREE' COMMENT '状态FREE可claim / CLAIMED已绑玩家 / INVALID token失效隔离S4claim CAS 抢占锚',
`claimed_by_game_player_id` BIGINT NULL COMMENT 'claim 后 ↔ game_player.idFREE 时 NULLuk 允许多 NULLCLAIMED 保证一玩家至多一条)',
`claimed_at` DATETIME NULL COMMENT 'claim 时间CLAIMED 时回填)',
`biz_no` VARCHAR(64) NULL COMMENT 'claim 幂等键 claim_<gamePlayerId>FREE 时 NULL',
`retry_count` INT NOT NULL DEFAULT 0 COMMENT '预留claim/隔离重试计数',
`remark` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '备注(隔离原因等)',
-- 标准审计列DO 继承 TenantBaseDOops 脚本 raw INSERT 不给这些列,走默认落值,运行时 executeIgnore 跨租户)
`creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者(审计列)',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '逻辑删除0未删 1已删',
`tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户编号(系统级池资源,运行时 executeIgnore 跨租户存取,值仅占位)',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_newapi_user` (`newapi_user_id`) COMMENT '预建条目按 new-api 用户去重(脚本 ON DUPLICATE KEY UPDATE 补池锚)',
UNIQUE KEY `uk_claimed_by` (`claimed_by_game_player_id`) COMMENT '一玩家至多一条 CLAIMEDNULL 不参与约束,多条 FREE 并存)',
UNIQUE KEY `uk_biz_no` (`biz_no`) COMMENT 'claim 幂等键唯一NULL 不参与约束)',
KEY `idx_status` (`status`) COMMENT 'claim 抢占按 status=FREE 扫、水位按 FREE 计数'
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'new-api per-user ¥100 额度预置池(离线预建 FREE + 注册 claim 绑玩家WU2 §3.5';

View File

@ -47,7 +47,7 @@
"failureReason": {
"description": "失败原因分类status=failed 必填,映射用户可读提示)",
"type": "string",
"enum": ["unsafe_prompt", "intent_unclear", "no_template_match", "config_invalid", "llm_error", "timeout", "asset_gen_failed"]
"enum": ["unsafe_prompt", "intent_unclear", "no_template_match", "config_invalid", "llm_error", "timeout", "asset_gen_failed", "quota_exhausted"]
}
}
}

View File

@ -0,0 +1,270 @@
---
date: 2026-07-07
topic: 内测-注册登录-用户名密码
status: 草案
sot-impact: 修订 topic 鉴权与权限(新增用户名+密码鉴权路径,与短信/邀请码并存);修订 topic 数据模型game_player 增 username/password 列、mobile 放宽可空、加 uk_username。收口时把两条结论折进 后端/鉴权与权限.md 与 后端/数据模型.md本档降为留痕。
上级: docs/mvp/MVP作战清单.md
关联:
- contracts/db-schemas/V11.0.0__create_passport_player_invite.sql @ cb9f135d被扩展的 game_player 建表)
- game-cloud/huijing-module-system/.../service/passport/PassportServiceImpl.java @ cb9f135dregisterPlayer / buildLoginResp 复用锚)
- game-cloud/huijing-module-system/.../controller/app/passport/AppPassportController.java @ cb9f135d新增端点挂载点
- game-cloud/huijing-module-system/.../dal/redis/passport/PassportSmsIpCounterRedisDAO.java @ cb9f135d纯 IP 计数复用锚)
- game-cloud/huijing-module-system/.../service/user/AdminUserServiceImpl.java:572-583 @ cb9f135dBCrypt 范式)
- game-cloud/huijing-module-system/.../controller/admin/auth/vo/AuthLoginReqVO.java:24-33 @ cb9f135d用户名/密码校验范式)
- game-studio/src/api/passport.ts、src/views/login/Login.vue @ cb9f135d前端改动面
图清单: [图0 一图看懂, 图1 双身份路径共存, 图2 注册/登录时序含 IP 闸与开户 hook, 图3 步骤计划]
---
# 内测·用户名密码注册登录 功能设计
## 0 一图看懂
内测种子用户不再依赖短信:输入用户名和密码即可完成注册与登录,账号身份从"必须有手机号"松绑为"用户名或手机号二选一"。后端在既有 passport 模块里新增一对免登端点复用已经跑通的建号事务、BCrypt 编码、OAuth2 发 token 三段;防刷靠应用层按纯 IP 计数的时/日双桶实现,同时把这层闸补到此前漏保护的短信登录与邀请码注册上。
```mermaid
flowchart LR
U["种子用户"] -->|用户名+密码| REG["password-register<br/>password-login<br/>(app-api/passport)"]
REG --> IP{"同 IP 时/日<br/>计数闸"}
IP -->|超阈值| DENY["429 请求过频"]
IP -->|放行| SVC["PassportServiceImpl<br/>registerPlayer + BCrypt + buildLoginResp"]
SVC --> DB[("game_player<br/>username/password 新列<br/>mobile 放宽 NULL")]
SVC --> TOK["OAuth2 token<br/>userType=MEMBER"]
SVC -.事务内写 outbox.-> HOOK["WU2 new-api 开户充值 hook<br/>(至少一次·幂等·本档只交生产者)"]
```
**核心思想**:不新建鉴权体系,在 passport 现有骨架上加一条与短信/邀请码平权的登录路径,用一列 `username`、一列 `password` 和一处 `mobile` 放宽承载;防刷收敛到一处纯 IP 计数组件,避免每个端点各写一套。
**边界**:只做用户名密码的注册与登录(内测种子),不做找回密码、改密、手机号绑定、图形验证码、真正的边缘网关限流。
**怎么算成功**:真机上用户名密码注册返回 OAuth2 token、随后同凭据登录成功、同 IP 高频尝试被 429 拒、存量短信与邀请码两条路零回归。
## 1 意图与目标
内测阶段要把种子用户请进来试玩和创作,可当前 passport 只认手机号:登录靠短信验证码,注册靠"验证码即占有证明"的自动注册或报备前的邀请码旁路。staging 未接真实短信网关,只能靠 `sms-mock-enabled` 发一个固定码 8888 兜底登录——这是给自己人测试用的后门,把它当成种子用户的注册入口既不体面也不安全(任何人凭 8888 登录任意手机号)。创始人据此拍定内测口径:种子用户"输入用户名和密码即可"注册登录,无需短信;网关层对同 IP 的注册/登录尝试做限流防刷。
目标有三条,都要能验:
- **可注册可登录**:陌生用户提交合法用户名与密码即建号并拿到真 OAuth2 token用同一对凭据能再次登录。token 结构、userType、client 与短信路完全一致,下游鉴权、`/me`、创作守卫无需感知登录方式的差异。
- **可防刷**:同一 IP 在时窗内的注册与登录尝试次数受限,越限返回 429日志留痕可审计这层保护同时补到目前裸奔的 `sms-login``invite-register`
- **零回归**:短信验证码登录、自动注册、邀请码注册、`sms-mock` 后门四条既有路径行为不变。
## 2 边界
**做**`game_player``username` / `password` 两列并放宽 `mobile` 为可空、加 `uk_username`;新增 `password-register``password-login` 两个免登端点及其请求/响应契约用户名密码校验、BCrypt 编码与校验、防枚举口径;应用层纯 IP 计数防刷(新端点 + 回填 `sms-login` / `invite-register``wanxiang.passport.*` 段新增开关与阈值;`game-studio` 登录页新增用户名密码 Tab、`passport.ts` 端点封装、文案;`/me` 返回体补 `username` 以便前端展示非手机号身份;交付"注册成功"事件的**生产者**(注册事务内写 outbox、投递器发 MQ至少一次作为 WU2 new-api 开户充值 hook 的接缝。
**不做**:找回密码 / 改密 / 手机号与用户名互绑内测不需要留后续账号中心图形验证码或滑块与短信路一样列为公网上线前的加固项非内测门槛真正的边缘网关huijing-gateway / nginx限流作为主方案——现实是产品端走单体直连 48080网关不在链路详见 §3.3 的口径澄清与权衡);`real_name` / `id_card_no` 实名字段V11 已预留,提现前才收集);把 WU2 的 new-api 开户逻辑实现进来(本档交"注册成功"事件的生产者与至少一次接缝契约,**消费端**开户充值随 WU2即"不做"的是消费者,不是连事件发射也不做)。
## 3 方案
### 3.1 最大决策:单表双身份路径如何共存
`game_player` 当前把手机号钉成了身份主键——`mobile VARCHAR(11) NOT NULL`,唯一键 `uk_mobile(mobile, deleted, tenant_id)`,没有任何用户名或密码的概念。要让"没有手机号"的用户也能落到同一张玩家表(这样 `id` 仍是 OAuth2 token 的 userId下游一视同仁必须动三处`username`、加 `password`、把 `mobile` 从 NOT NULL 放宽为 NULL。
这里的关键是唯一键在 NULL 上的行为。MySQL 的 UNIQUE 索引允许索引列取 NULL 时存在多行——当 `mobile` 为 NULL`uk_mobile` 不再对这些行施加唯一约束,于是任意多个"无手机号"的用户名用户可以共存,而手机号非空的用户之间仍然一号一人。反过来,新加的 `uk_username(username, deleted, tenant_id)` 对手机号用户(`username` 为 NULL不设限对用户名用户严格唯一。两条路径因此在同一张表里互不干扰
```mermaid
flowchart TB
subgraph 短信/邀请码用户
A["mobile=13800000000<br/>username=NULL<br/>password=NULL"]
end
subgraph 用户名密码用户
B["mobile=NULL<br/>username=seed_alice<br/>password=BCrypt(...)"]
end
A -.受 uk_mobile 约束.-> UKM["uk_mobile 唯一<br/>(NULL 行不参与)"]
B -.受 uk_username 约束.-> UKU["uk_username 唯一<br/>(NULL 行不参与)"]
```
**契约变更DB schemacontract-first**。新增一支 Flyway 迁移,只做 ALTER不碰存量数据的值
| 变更 | 列/键 | 定义 | 说明 |
|---|---|---|---|
| 加列 | `username` | `VARCHAR(30) NULL` | 用户名,`^[a-zA-Z0-9]{4,30}$`(沿用 admin `AuthLoginReqVO` 范式);手机号用户为 NULL |
| 加列 | `password` | `VARCHAR(100) NULL` | BCrypt 密文(约 60 字符,留至 100 兼容算法前缀/未来迁移);非用户名用户为 NULL**永不返回、永不落日志** |
| 加键 | `uk_username` | `UNIQUE(username, deleted, tenant_id)` | 与 `uk_mobile` 同范式,含 deleted/tenant_id 适配逻辑删+多租户 |
| 改列 | `mobile` | `VARCHAR(11) NULL`(原 NOT NULL | 放宽可空,承载"无手机号"的用户名用户 |
| 扩语义 | `register_channel` | 值域加 `password` | 现有 `sms`/`invite` 之外新增,漏斗归因 |
**版本号与放置**。定序以执行副本 `game-cloud/huijing-server/src/main/resources/db/migration/` 为唯一依据——它是真正被 Flyway 应用的 classpath凡低于已应用版本或与已存在版本同号都会被拦。实测执行副本已连续到 `V30.0.0`(其中 `V26.0.0__aigc_task_add_idempotency_key.sql` 已占用 V26因此新迁移取其上的下一号 `V31.0.0__passport_player_add_username_password.sql`。落地前把「用 `ls .../db/migration | sort -V | tail` 复核执行副本当时最大号、若已有人加了更高号则顺延」当作**硬前置**执行,不能只当脚注——早先本档误记执行副本停在 V19、据此取 V26正好会与既有 V26 撞号导致 duplicate/checksum 校验失败、迁移根本无法应用,就是漏做这一步。
顺带订正一处 SoT 漂移:契约源 `contracts/db-schemas/` 目前只到 V25缺 V14V20 与 V26V30也就是执行副本反而**领先**契约源V11 守门①「同版本同时落源+执行副本」其实早已长期失守,方向与直觉相反)。本单 V31 必须**同时**落契约源与执行副本把守门①在本次做实;缺失的 V14V20、V26V30 回填契约源属既有欠账,另案登记补齐,不在本单强行夹带。文件不放任何单模块 `-server/db/migration/`,否则同版本出现在多个 classpath jar 会触发 Flyway 重复校验失败。含中文 SQLmini-desktop 执行须 `--default-character-set=utf8mb4`
**存量兼容与迁移**。存量 `game_player` 行全部有 `mobile``username`/`password` 为新加列取 NULL——`mobile NOT NULL → NULL` 是放宽,不破坏任何存量行;两个新列默认 NULL存量短信/邀请码用户天然落在"手机号路径"一侧。`PlayerDO` 随之补 `username`/`password` 两个字段(`password` 仅服务层内部用,不进任何 RespVO
**回滚**:写补偿迁移 `V31.0.1``DROP INDEX uk_username``DROP COLUMN username/password``mobile` 收回 NOT NULL 前必须先确认无 NULL 行(即无用户名用户),否则收回会失败——因此回滚前置一步"确认或清理无手机号用户",与 V11 对 `player_user_id` 放宽的回滚纪律同口径。
### 3.2 端点与 Service复用建号事务、BCrypt、发 token 三段
不新建鉴权服务。在 `AppPassportController` 加两个 `@PermitAll` 免登端点,在 `PassportServiceImpl` 加两个方法,把已经验证过的三段拼起来:建号走现有 `registerPlayer`(扩参承载 username/password编码校验密码走 admin 侧同款 BCrypt`AdminUserServiceImpl``passwordEncoder.encode/matches`),发 token 与组装响应直接复用 `buildLoginResp`——`userType=MEMBER``clientId=default``refresh_token` 有效期由 OAuth2 client 承载,与短信路产出的 token 一字不差。
```mermaid
sequenceDiagram
participant C as game-studio
participant K as AppPassportController
participant IP as IP 计数闸(纯 IP)
participant S as PassportServiceImpl
participant DB as game_player
participant T as OAuth2TokenService
participant H as WU2 new-api hook
Note over C,K: 注册 password-register
C->>K: {username,password,nickname?,anonId?}
K->>IP: 注册场景 时/日计数++
IP-->>K: 超阈值→429 / 放行
K->>S: passwordRegister(reqVO, clientIp)
S->>DB: selectByUsername(username)
alt 用户名已占用
S-->>C: 1-002-090-004 用户名已被占用
else 可注册
S->>DB: insert(username, BCrypt(password), mobile=NULL, channel=password)
Note over S,DB: 唯一键 uk_username 并发兜底→DuplicateKey 回查报占用
S->>T: createAccessToken(id, MEMBER, default)
S-->>C: LoginRespVO(token...)
S-)H: 事务内写 outbox→投递器发 MQ(至少一次·幂等)
end
Note over C,K: 登录 password-login
C->>K: {username,password,anonId?}
K->>IP: 登录场景 时/日计数++
IP-->>K: 超阈值→429 / 放行
K->>S: passwordLogin(reqVO, clientIp)
S->>DB: selectByUsername(username)
alt 查无 或 密码不匹配
S-->>C: 1-002-090-005 用户名或密码错误(统一)
else 匹配且未禁用
S->>T: createAccessToken(...)
S-->>C: LoginRespVO(token...)
end
```
**防枚举口径的非对称,须讲清**。短信路对手机号做"防枚举静默转登录":发码不区分是否已注册、`sms-login` 未注册即自动注册,外部无从探测某号是否存在。用户名路做不到这种静默——注册时若用户名已被别人占用,不可能悄悄并进别人的账号,必须明确报"用户名已被占用"(错误码 `PLAYER_USERNAME_ALREADY_REGISTERED`),这在语义上不可避免地暴露了"该用户名存在"。这是用户名体系的固有代价,缓解手段就是本设计的 IP 限流:让批量刷用户名探测的成本变高。登录侧则收紧——"查无此用户名"与"密码错误"一律返回同一个 `PLAYER_USERNAME_OR_PASSWORD_ERROR`,不给区分,避免用登录接口反推用户名是否存在。只统一错误码还不够:若查无用户名时立即返回、密码错时才跑 BCrypt.matches有意的高开销响应时延就把"用户名存在(慢)/不存在(快)"区分开,登录接口仍是个存在性 oracle。因此 `passwordLogin` 查无用户名时也对一个固定的假 BCrypt hash 跑一次 matches 再返回,让两个分支耗时不可区分,把时序侧信道一并堵掉。
**请求/响应 VO 契约(字段级)**
`PasswordRegisterReqVO`
| 字段 | 类型 | 校验 | 说明 |
|---|---|---|---|
| `username` | String | `@NotEmpty` `@Length(4,30)` `@Pattern(^[a-zA-Z0-9]{4,30}$)` | 用户名,登录主键之一;沿用 admin `AuthLoginReqVO` 范式 |
| `password` | String | `@NotEmpty` `@Length(6,32)` | 明文密码,服务层立即 BCrypt 编码;比 admin 的 4-16 收紧下限至 6因本端点直面公网admin 账号由运营在验证码后台内建,威胁模型不同——生产稳定优先)|
| `nickname` | String | `@Size(max=30)` 可选 | 不传则默认生成(见下) |
| `anonId` | String | `@Size(max=64)` 可选 | 客户端 anonId仅注册落 `first_anon_id` 一次,匿名↔登录归因,与短信路同口径 |
`PasswordLoginReqVO``username`(同上 Pattern`password``@NotEmpty`,登录不必重复长度校验以免给出策略线索)、`anonId?`
响应复用 `LoginRespVO``userId` / `accessToken` / `refreshToken` / `expiresTime` / `nickname` / `creatorFlag`),不新增结构。
**registerPlayer 扩参**。现签名 `registerPlayer(mobile, channel, inviteCodeId, anonId, nickname, clientIp)` 把手机号钉死为入参。改为再收 `username``encodedPassword` 两个可空参数(短信/邀请码路传 null用户名路传 mobile=null插入时按路径落对应列。默认昵称生成 `generateNickname` 现取手机号后 4 位,用户名路无手机号,改为:有 `nickname` 用之,否则用户名路取 `username` 本身、手机号路维持后 4 位。`register_channel``password`。并发兜底:现有 `catch DuplicateKeyException` 回查转登录是针对 `uk_mobile` 的;用户名路命中 `uk_username` 冲突应报 `PLAYER_USERNAME_ALREADY_REGISTERED`(不转登录——注册态不能因为撞名就把人送进别人账号),据此分支需按 channel 区分冲突语义。
**`/me``username`,并封空安全**。`PlayerMeRespVO` 现返回脱敏 `mobile`;用户名用户 `mobile` 为 NULL前端拿不到可展示身份。补 `username` 字段(明文,用户名非敏感),`mobile` 保持脱敏且允许空。这里有个当前实现会踩的坑:`getPlayerMe``PassportServiceImpl.java:300`)现在无条件 `DesensitizedUtil.mobilePhone(player.getMobile())`,而用户名用户 `mobile` 恰为 null——`/me` 契约必须显式规定 `mobile` 为 null 时跳过脱敏、直接回空串,实现处对 null 短路(不把 null 塞进脱敏函数),否则用户名用户一调 `/me` 就可能 NPE 直接回归。这是一处响应契约扩展,前端 `PlayerMeResp` 接口同步加 `username`
**密码绝不落控制台,靠机制而非配置**。`@ApiAccessLog(sanitizeKeys={"password"})` 只脱敏一条链路——它由 `ApiAccessLogFilter` 消费、写进持久化的 `system_api_access_log`。但还有第二条链路会漏:`ApiAccessLogInterceptor.preHandle``ApiAccessLogInterceptor.java:44-52`)在 `!isProd` 时用 `log.info` 把**原始 requestBody** 直接打到控制台/stdout不受 `sanitizeKeys` 约束staging 现靠调低 `logging.level` 压着。密码是可跨站复用的持久凭据,价值远高于一次性验证码,任何环境抬高日志级别或聚合 stdout 就是明文外泄——不能只靠 staging 的 logging.level 配置兜。因此对 `password-register`/`password-login` 两端点做**代码级**保证:在所有环境让这两个路径的请求体不进 `ApiAccessLogInterceptor` 的 stdout 打印(按路径跳过该 interceptor 的 body 打印,或对这两个 URI 屏蔽 body并在冒烟里加一条断言——注册/登录跑一遍后 grep 服务 stdout 必须搜不到明文密码。§5 的安全基线取证据此从「日志无明文」升级为「机制保证 + stdout 断言」,不接受仅配置压制。
**错误码1-002-090 段续接api 模块 `ErrorCodeConstants`**
| 码 | 常量 | 文案 |
|---|---|---|
| `1_002_090_004` | `PLAYER_USERNAME_ALREADY_REGISTERED` | 用户名已被占用 |
| `1_002_090_005` | `PLAYER_USERNAME_OR_PASSWORD_ERROR` | 用户名或密码错误 |
| `1_002_090_006` | `PASSWORD_AUTH_DISABLED` | 用户名密码登录通道未开启 |
禁用账号复用 `PLAYER_IS_DISABLED(1_002_090_002)`IP 越限复用全局 `TOO_MANY_REQUESTS(429)`——与短信路一致,不为限流新造码。
### 3.3 IP 限流:落应用层纯 IP 计数,兼澄清"网关层"口径
创始人原话是"网关层对同 IP 限流"。落地前须把口径对齐现实:产品端 `game-studio` 走单体直连(`.env.staging` `VITE_API_BASE=48080`huijing-gateway 当前不在产品端链路、无活体网关实例。若字面执行"网关层限流",等于要为内测先把网关拉进产品链路,属于计划外的基建改动,与"内测能用就行"的验收强度不符。
两个方案摆清:
- **方案 A推荐·应用层纯 IP 计数**。复用 `PassportSmsIpCounterRedisDAO` 已经在用的模式——Redis 自增 + 首次设 TTL 的时/日双桶,纯 IP 维度key 只含 IP 与日期,不含请求体)。在 `password-register`/`password-login` 的 Service 入层先增后判,越限抛 `TOO_MANY_REQUESTS`。今天就能跑、无新基建、能返回精确业务错误码、计数可观测。
**客户端 IP 的取法是这套防刷成不成立的命门,不能沿用发码路的 `ServletUtils.getClientIP()`**。它底层是 Hutool `JakartaServletUtil.getClientIP`,优先读 `X-Forwarded-For` 并取**最左段**——而最左段恰恰是客户端能自己写的。内测产品端浏览器单体直连 48080、链路里没有任何反代§3.3 开头的现实),意味着 XFF 完全由客户端控制:攻击者每个请求塞一个新的伪造 XFF 就落进一个新桶,纯 IP 时/日闸被平凡绕过,用户名占用探测与密码暴破随之不受限。据此定死取 IP 的口径:**内测直连场景一律采信连接层 `request.getRemoteAddr()`,不读任何 XFF**(无可信反代就不该相信可伪造的头);将来公网上线在 48080 前置一层强制改写 XFF 的可信反代后,再改为只采信反代自己 append 的**最右侧受信段**配可信代理白名单绝不采信客户端可写的最左段。Service 入层从 controller 显式接 `request.getRemoteAddr()` 传入计数器,不走 `getClientIP()`。这样限流的桶键锚在真正的连接来源上,才对得起 §1「可防刷」的命名。
- **方案 B备选/纵深·nginx `limit_req`**。在 48080 前置反代上按 `$binary_remote_addr` 限速,是字面意义的"网关/边缘层"。优点是请求还没进 JVM 就被挡;缺点是它给的是 503/自定义页而非项目的 `CommonResult` 业务错误码、防枚举语义无法在这层表达,且 staging 是否有可控的前置 nginx 尚未确认。
**推荐 A 为内测主方案B 作为公网上线时的纵深加固**真有边缘反代后叠加与图形验证码一并作上线闸门。两者不互斥A 保证业务语义与可测性B 在更外层削峰。
**一个必须避开的坑**:框架另有 `@RateLimiter` + `ClientIpRateLimiterKeyResolver` 注解式限流,但它的 key 是 `md5(方法名 + 方法入参 + IP)``ClientIpRateLimiterKeyResolver` 第 20-24 行)。用它保护 `password-register/login` 会把请求体里的 username/password 拌进 key——攻击者每次换一个用户名就落进不同的桶纯 IP 限流形同虚设。因此这两个端点**不**走该注解,一律走 §3.3 方案 A 的服务层纯 IP 计数。`send-sms-code` 现在既挂了 `@RateLimiter`key 含 mobile非纯 IP只挡同号同 IP 重放)又有服务层纯 IP 双闸;`sms-login``invite-register` 目前两样都没有,本设计把服务层纯 IP 计数补给它们。
**计数组件的复用方式**。`PassportSmsIpCounterRedisDAO` 的 key 前缀写死为 `passport_sms_ip_*`、方法名是 `incrDay/HourAndGet`,语义绑死"发码"。若三个新场景(注册/登录/邀请码)直接调它,计数会与发码混桶、语义错乱。取最小且不漂移的改法:把它泛化为按 `scene` 参数分桶——key 变 `passport_ip:{scene}:hour:{ip}:{yyyyMMddHH}` / `:day:``scene ∈ {sms, register, login, invite}`;现有发码调用回填 `scene=sms`。要说清的是这里**计数语义等价、但不是无条件"行为等价"**Redis key 字符串改了名,上线那一刻旧前缀 `passport_sms_ip_*` 的存量计数桶全部失效,等于限流窗口对已在计数的 IP 一次性重置,且原先盯旧前缀的观测/告警会断。这几件在内测都无害(桶 TTL 有界、只重置一次、观测同步改名即可),但落档要如实标为"一次性重置",不粉饰成零成本。这样每个场景独立时/日桶、阈值各自可配、单一组件承载,避免每个端点各抄一份计数代码。
**频控后端故障时的姿态,须显式决策**。现有 `incrDay/HourAndGet` 直接 `stringRedisTemplate.increment` 且**无 try/catch**Redis 抖动/超时会把异常一路抛穿——若不处理,回填后连注册/登录/`sms-login`/`invite-register` 会被频控后端的单点故障整体打挂,认证不可用。按外部交互红线定死姿态:频控计数对 Redis 异常取 **fail-open + WARN 告警**(捕获异常、记一条含 IP 与场景的告警日志、放行本次请求),理由是内测阶段认证可用性优先于把防刷做到严丝合缝,绝不让 Redis 单点静默拖垮登录代价Redis 挂时防刷短暂失效记账接受Redis 恢复后限流自动复位。这条决策写进泛化后的计数组件,不留给实现临场拍。
### 3.4 配置开关与阈值(沿用 wanxiang.passport.* 段)
新增开关一枚、阈值四对,全部进 `wanxiang.passport.*`,与既有 `invite-register-enabled` / `sms-ip-limit-*` / `sms-mock-*` 同段:
| 键 | 默认 | 说明 |
|---|---|---|
| `wanxiang.passport.password-auth-enabled` | staging `true` | 用户名密码注册+登录总开关;关闭时两端点返回 `PASSWORD_AUTH_DISABLED`(信任边界在后端服务层,不靠前端隐藏 Tab|
| `wanxiang.passport.register-ip-limit-per-hour` / `-per-day` | 推断初值 20 / 60 | 注册场景 IP 时/日上限;注册比发码宽松一档,与运营确认后调 |
| `wanxiang.passport.login-ip-limit-per-hour` / `-per-day` | 推断初值 30 / 100 | 登录场景 IP 时/日上限 |
阈值为推断初值(`sms-ip-limit` 现为 10/30标注"与运营确认后调整",不写死进代码常量。
### 3.5 前端改动面game-studio
`Login.vue` 现为 sms / invite 两 Tab加第三个"用户名密码"Tab用户名、密码两个输入框前端做与后端一致的用户名正则与密码长度预校验前端不是信任边界仅提示复用现有隐私政策勾选、`?redirect=` 回跳、`setLogin` 落持久化、`user_login` 遥测双带userId+anonId四段。登录成功路径与短信路完全一致都拿 `LoginResp`)。
`passport.ts``PasswordRegisterReq` / `PasswordLoginReq` 两个入参接口与 `passwordRegister` / `passwordLogin` 两个函数,严格贴新增的 VO 契约,走同一个 `request` 信封解包;`PlayerMeResp` 接口补 `username``locales/entry.zh|en` 加该 Tab 的字段标签、按钮、以及三个新错误码的前端文案回落(错误码文案后端已带,前端主要补 Tab 标题与占位符)。
### 3.6 使用路径与接缝WU2 new-api 开户充值 hook 的挂载点
内测里"注册成功"是一个业务事件WU2 要在此刻给新玩家在 new-api 侧开户并发放初始额度。本档只交挂载点与接缝契约,不实现:
外部调用绝不能与本地建号事务耦合——new-api 若超时或 502不该把一个已经合法的注册回滚掉也不该让用户卡在注册请求上等外部系统。但"解耦"不等于可以丢消息:如果只是 `afterCommit` 里直发 MQ事务已提交、消息却在 produce 侧发送超时/失败或应用此刻崩溃,就会出现"注册成功了、开户事件从未入队"的黑洞,重试队列/死信只能兜"已投递但消费失败",兜不到根本没发出的那批——"先能登录、额度稍后补"会在这个丢消息窗口里静默落空。按"生产稳定>开发期简单"红线,`afterCommit` 直发是 lean 路径,生产级现货是事务性 outbox本档对 WU2 的接缝按**至少一次**收紧、定死一种机制,不留给实现自选:
- **机制(定死一种·本地消息表 outbox**:注册事务**内**同库写一行 outbox 记录(`registerPlayer` 提交时与建号原子落库,载荷含 `userId``registerChannel``anonId`、幂等键);事务外由投递器扫 outbox 发 RocketMQ、发成功后标记已投递。建号成功则 outbox 记录必已落库,投递器保证最终发出——把"至少一次"锚在数据库事务上,而非 MQ 的即时可达性上。三条注册路sms 自动注册、invite、password共用这一张出口WU2 无需感知登录方式。(若后续统一走 RocketMQ 事务消息亦可,但那也是"至少一次"的一种实现,接缝语义不变。)
- **生产者归属**:写 outbox 是 **WU1 的交付**(本单只做生产者),消费端 new-api 开户充值随 WU2本单不实现消费。这样验收项"注册成功事件被发出"有对应交付物,不成孤儿。
- **幂等**:以 `userId` 为幂等键new-api 开户与充值必须可重放不重复发额(同一 userId 二次消费为 no-op——因为"至少一次"必然带来重复投递,消费侧幂等是配套硬要求。
- **超时/失败/重试**:投递器发 MQ 设超时;发失败保留 outbox 未投递态下轮重投(有限次退避)。消费侧调 new-api 设超时;消费失败进重试队列(有限次退避)。
- **补偿**:投递或消费重试耗尽落死信/告警,由运营侧补开户;注册本身已成功,不因开户失败对用户可见地失败——玩家先能登录,额度稍后补。
本设计对 WU2 的硬要求两条:一是 hook 消费的是"注册成功"事件而非"password 注册成功"事件口径统一在三条路的公共出口二是投递语义是至少一次outbox 兜底消费侧据此做幂等WU2 不得实现只在 afterCommit 直发的有损版本再自称达标。
## 4 步骤计划
代码物随执行阶段(阶段 1落地本档只定序与验收。
```mermaid
flowchart LR
S1["① 契约<br/>Flyway V31 + VO + 错误码 + PlayerDO/Mapper"] --> S2["② 服务层<br/>registerPlayer 扩参 + password 两方法 + BCrypt + IP 计数泛化"]
S2 --> S3["③ 端点+配置<br/>@PermitAll 端点 + wanxiang.passport 开关阈值 + 回填 sms/invite IP 闸"]
S3 --> S4["④ 前端<br/>Login.vue Tab + passport.ts + locales"]
S4 --> S5["⑤ 真机验收<br/>注册→登录→限流→零回归 + hook 挂点冒烟"]
```
| 阶段 | 交付物 | 验证 | 依赖 | 风险 |
|---|---|---|---|---|
| ① 契约 | V31 迁移(两处:契约源+执行副本)、`PasswordRegister/LoginReqVO``LoginRespVO` 复用、`PlayerMeRespVO` 加 username、三个错误码、`PlayerDO`+`PlayerMapper.selectByUsername` | Flyway 迁移在 dev 库应用无版本乱序/无同号;建表 DDL 校验通过 | 落地前 `ls .../db/migration \| sort -V \| tail` 硬复核执行副本最大号再定号 | mobile 放宽的存量兼容(放宽为松约束,低风险)|
| ② 服务层 | `registerPlayer` 扩参、`passwordRegister/passwordLogin`(含查无用户名跑固定假 hash 对齐耗时、BCrypt 编码校验、`PassportSmsIpCounterRedisDAO` 泛化 scene + Redis 异常 fail-open 告警、**注册事务内写 outbox注册成功事件生产者** | 服务层单测:注册占用、登录防枚举(错误码+耗时不可区分、禁用拒登、并发撞名兜底、IP 越限抛 429、Redis 异常放行且告警、outbox 记录随建号原子落库 | ① | 泛化计数组件 key 改名致存量桶一次性重置TTL 有界、内测无害,如实记账)|
| ③ 端点+配置 | 两 `@PermitAll` 端点(`@ApiAccessLog(sanitizeKeys={"password"})` **且代码级绕过 `ApiAccessLogInterceptor` 的 stdout body 打印**、Service 入层取 `request.getRemoteAddr()` 传入计数、`wanxiang.passport.*` 新键、回填 `sms-login`/`invite-register` 的服务层 IP 闸 | 端点级Knife4j/curl 打通;回填不改短信/邀请码语义;冒烟 grep stdout 无明文密码 | ② | IP 取连接层 remote-addr不采信 XFF上线接反代后须切最右受信段 |
| ④ 前端 | `Login.vue` 用户名密码 Tab、`passport.ts` 两函数+`PlayerMeResp.username`、locales | 构建通过;本地联调三 Tab 均可登录 | ③ | 与既有两 Tab 状态不串 |
| ⑤ 真机验收 | dev 单点串行 e2e | §5 判据全绿 | ①-④ | — |
## 5 验证方式
全部可真机取证或机器验,判据与 §1 目标一一对应:
- **注册返 token**:对未占用用户名 POST `password-register`,返回体含非空 `accessToken`/`refreshToken``userId`>0库内 `game_player` 出现 `username=该名, mobile=NULL, register_channel=password, password` 为 BCrypt 密文(`$2a$` 前缀、非明文)。
- **登录成功且防枚举**:用同一对凭据 POST `password-login` 返回 token密码错一位 → `1-002-090-005`;换一个不存在的用户名同样是 `1-002-090-005`(两者文案一致,不可区分);抽样比对"查无用户名"与"密码错误"两分支的响应时延处于同一量级(固定假 hash 已对齐 BCrypt 耗时),不构成存在性 oracle。
- **占用可见、登录不可见**:重复注册同用户名 → `1-002-090-004 用户名已被占用`(这是有意暴露,配合限流)。
- **IP 限流生效**:同 IP 连续注册/登录超过配置阈值 → `429 请求过于频繁`;日志出现限流 WARN 且含 IP 与计数Redis 见 `passport_ip:register:*` / `:login:*` 计数桶。
- **伪造 XFF 不能绕过**:携带每次变化的伪造 `X-Forwarded-For` 高频打注册/登录,仍按连接来源被 429证明限流锚在 `getRemoteAddr()`、不采信客户端可写的 XFF
- **回填生效**:同 IP 高频打 `sms-login` / `invite-register` 同样被 429此前无此保护
- **存量零回归**:短信验证码登录(含 `sms-mock` 8888、自动注册、邀请码注册四条路真机各跑一遍行为与改动前一致OAuth2 token、`/me`、创作守卫不受影响。
- **token 后续可用 + /me 空安全**password 路拿到的 token 调 `/me` **不 NPE**、返回该用户身份(`username` 有值、`mobile` 为 null 时回空串不报错),并能通行一次需登录态的下游调用——证明与短信路 token 等价、且脱敏对 null 已封空安全。
- **开户事件生产者冒烟**:注册成功后 outbox 表出现对应记录且被投递器发出 MQ日志/MQ 可观测验证生产者已交付WU2 实现前此处只验事件被发出,不验开户结果。
- **安全基线(机制保证,非配置压制)**`system_api_access_log` 中 password 键被脱敏、不见明文;且冒烟里对 `password-register`/`password-login` 跑一遍后 grep 服务 stdout/控制台断言搜不到明文密码(覆盖 `ApiAccessLogInterceptor` 那条链路);服务日志只见 `userId`,无密码、无 token。
## 6 风险与回滚
- **客户端 IP 取法**:限流按 §3.3 采信连接层 `request.getRemoteAddr()`、不读客户端可伪造的 XFF堵死「伪造 XFF 换桶绕过」这一最危险方向§5 专门有一条「伪造 XFF 高频打注册/登录仍被 429」取证。反方向的残余前提是——将来公网上线在 48080 前置可信反代后,`getRemoteAddr()` 会塌成反代地址、限流退化为全局闸;届时必须切到「只采信反代 append 的最右侧受信段 + 可信代理白名单」,这是上线闸门的一项,不在内测范围内提前做。
- **单账号多 IP 暴破**:本单防刷是纯 IP 单维度,没有按 `username` 的失败登录节流或锁定。配合弱口令下限6 位、无复杂度、无图形验证码)与 login 默认 100 次/日/IP攻击者用多 IP 轮转打同一个目标账号时,每个账号没有独立上限。内测阶段先记账接受该残余风险,触发处置是「一旦观测到针对单账号的高失败率,立即用 §3.3 的 scene 计数器加一个按 `username` 的失败桶做短时锁定」——组件现成scene ∈ 增加 `login_fail`),越限临时锁定该账号;账号级失败锁定与图形验证码同列为公网上线闸门。
- **密码策略偏弱**:内测取 `@Length(6,32)`、无复杂度要求、无图形验证码,靠 IP 限流兜防刷。这是"内测能用就行"的刻意取舍;公网上线前把图形验证码/滑块与密码复杂度一并补上(与 `sms-mock` 关闭、短信网关接入同属上线闸门)。
- **用户名枚举**:注册占用错误不可避免地暴露用户名存在性,限流是唯一缓解。若内测发现被刷,先下调 `register-ip-limit`,再考虑提前上图形验证码。
- **总开关误关**`password-auth-enabled=false` 会让两端点直接拒服务;它是后端信任边界的一部分,前端仅据此隐藏 Tab不能反向依赖前端。
- **回滚**:功能级回滚 = `password-auth-enabled=false`端点即时停用无需改码。schema 级回滚 = 补偿迁移 `V31.0.1``mobile` 收回 NOT NULL 前须先确认/清理无手机号用户,否则收回失败。前端回滚 = 隐藏用户名密码 Tab。三层可独立回退互不阻塞。
## 附 图清单与状态
- 图0 一图看懂§0Mermaid事实源— 现行
- 图1 单表双身份路径共存§3.1Mermaid— 现行
- 图2 注册/登录时序含 IP 闸与开户 hook§3.2Mermaid— 现行
- 图3 步骤计划§4Mermaid— 现行
SVG 门面§0 概览大图,给人 30 秒看懂)按 feature-design-doc 规约由图层 opus 子代理据本文字+Mermaid 派生尚未产出收口前补图文冲突以本文字为准。本档过双评审门Codex + OpusCodex 挂回落 Opus 单评)后交创始人批,批准后随阶段 1 落代码物。

View File

@ -0,0 +1,370 @@
---
date: 2026-07-07
topic: newapi-per-user-quota
status: 草案
sot-impact: 修订「生成引擎运行时」(§6.1 job 契约新增 userToken 位、§6.5 新增 quota_exhausted 失败因);修订「数据模型」(新增额度池表 newapi_quota_pool——离线预建 FREE 条目 + 注册 claim 绑定);关联「鉴权与权限」(不改注册路,仅消费 WU1 afterCommit 发出的「注册成功」事件从池 claim 一个条目绑定,运行时不调 new-api);修订「契约#6 dify-workflow-io.json」与「aigc.yaml FailureReason」(跨契约共享枚举同批新增 quota_exhausted须与 WU3 协调)。刻意不改「变现端到端」——本设计对接 new-api 网关额度,不在 game-cloud 自建 credits 钱包。
上级: docs/mvp/MVP作战清单.md
依赖: docs/agent-specs/2026-07-07-内测-WU1-注册登录用户名密码-设计.mdWU1 §3.6 是唯一注册接缝afterCommit 发「注册成功」领域事件/RocketMQ 消息,载荷 userId/registerChannel/anonId。本设计是该事件的消费者不在注册事务内挂点WU1 迁移取 V31本设计顺延取 V32
关联:
- game-cloud/huijing-module-system/.../passport/PassportServiceImpl.java:214 @ cb9f135dWU1 注册锚,本设计不改它,只消费其 afterCommit 事件)
- game-cloud/game-module-aigc/.../service/task/AigcTaskServiceImpl.java:202-204 @ cb9f135d编排器/bake-off「伪装在线提交」经 submitGenerate→dispatchGenerictask_source 现全落 online
- game-cloud/game-module-aigc/.../executor/AigcGenerateExecutor.java:658-681 @ cb9f135ddispatchGeneric 组 §6.1 job运行在有界消费池 consumeThreadMax=15
- game-cloud/game-module-aigc/.../executor/ExecutorLlmClient.java:124-149 @ cb9f135d
- game-cloud/game-module-aigc/game-module-aigc-api/.../enums/FailureReasonEnum.java @ cb9f135d跨契约共享枚举头注禁单边扩展
- contracts/api-schemas/aigc.yaml:296 · contracts/dify-workflow-io.json:50 @ cb9f135d两处 FailureReason enum须同批加 quota_exhausted
- game-cloud/game-module-trade/.../dataobject/GrantDO.java @ cb9f135d
- wg1/gen-worker/worker/_client.py:47-57 · wg1/gen-worker/worker/cost.py:8-15 @ cb9f135d
- cheap-worker/cheap_service_driver.py:28-44 · cheap-worker/worker_service.py:142-146 @ cb9f135d
图清单: [图0 一图看懂(SVG), 图1 六接线点数据流(mermaid), 图2 池条目 FREE→CLAIMED claim 状态机(mermaid), 图3 生成期额度扣减与失败时序(mermaid)]
---
# 内测 new-api per-user ¥100 生成额度接入 · 功能设计
## 0 一图看懂
内测阶段每个用户拿 ¥100 生成额度。这笔额度不在 game-cloud 里记一本自己的账,而是直接落在 new-api 网关上——**权威余额闸 = 用户 token 的 `tokens.remain_quota`**(预建时挂 ¥100 折算值、`unlimited_quota=false`**权威消耗 = 该 user 的 `users.used_quota`**(网关每调一次 LLM 逐笔累加);`users.quota` 同额设一份保持账户自洽,但触发耗尽 402 的那个字段是 token 的 `remain_quota`。做法分两段:**离线**由一个 ops 脚本在 mini-infra 一次性预建 N 个专属 (user + token + ¥100),把条目写进 game-cloud 的一张额度池表S0 实测坐实 new-api 的 admin 令牌无法替他人建 token、只能直连它的 postgres 才能 per-user 开户,故 provision 全部离线做、绝不进运行时);**运行时**消费 WU1 注册成功后 afterCommit 发出的事件,从池里 CAS claim 一个 FREE 条目绑给该用户,这是 game-cloud 内部一条 SQL、零 new-api 外呼。生成任务派发时把这个用户 claim 到的 token 随 job 带到 workerworker 用它去调网关,网关按这把 token 扣该用户的额度。额度花完,网关直接拒,用户看到一句「生成额度已用完」。
<svg viewBox="0 0 880 486" xmlns="http://www.w3.org/2000/svg" font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif" font-size="13">
<rect x="0" y="0" width="880" height="486" fill="#f8fafc"/>
<text x="24" y="30" font-size="17" font-weight="700" fill="#0f172a">内测额度口径:账本在 new-api——离线预置池运行时只 claim / 扣额</text>
<!-- 离线预置带 -->
<rect x="16" y="44" width="848" height="98" rx="10" fill="#f1f5f9" stroke="#94a3b8" stroke-width="1.2" stroke-dasharray="5 4"/>
<text x="28" y="62" fill="#475569" font-size="11" font-weight="600">离线预置ops 脚本 · 一次性建 N 条 · 补池=重跑)</text>
<rect x="32" y="74" width="184" height="56" rx="9" fill="#ffffff" stroke="#0f172a" stroke-width="1.4"/>
<text x="124" y="97" text-anchor="middle" fill="#0f172a">ops 预置脚本</text>
<text x="124" y="115" text-anchor="middle" fill="#64748b" font-size="10.5">建户→DB写access_token→建token→设quota</text>
<rect x="342" y="74" width="196" height="56" rx="9" fill="#eef2ff" stroke="#4338ca" stroke-width="1.5"/>
<text x="440" y="97" text-anchor="middle" fill="#312e81" font-weight="600">new-api 预建账户</text>
<text x="440" y="115" text-anchor="middle" fill="#64748b" font-size="10.5">N×(user + token + ¥100)</text>
<rect x="636" y="74" width="210" height="56" rx="9" fill="#ffffff" stroke="#0f172a" stroke-width="1.4"/>
<text x="741" y="97" text-anchor="middle" fill="#0f172a">game-cloud 额度池表 (D)</text>
<text x="741" y="115" text-anchor="middle" fill="#64748b" font-size="10.5">N 条 status=FREE</text>
<line x1="216" y1="102" x2="340" y2="102" stroke="#0f172a" stroke-width="1.4" marker-end="url(#a)"/>
<text x="278" y="95" text-anchor="middle" fill="#64748b" font-size="10">凭据A+postgres 脏活</text>
<line x1="538" y1="102" x2="634" y2="102" stroke="#0f172a" stroke-width="1.4" marker-end="url(#a)"/>
<text x="586" y="95" text-anchor="middle" fill="#64748b" font-size="10">脚本回写 FREE 条目</text>
<!-- 注册 claim 链 -->
<rect x="24" y="168" width="148" height="50" rx="9" fill="#ffffff" stroke="#0f172a" stroke-width="1.4"/>
<text x="98" y="190" text-anchor="middle" fill="#0f172a">用户注册</text>
<text x="98" y="207" text-anchor="middle" fill="#64748b" font-size="11">registerPlayer</text>
<rect x="198" y="168" width="178" height="50" rx="9" fill="#ffffff" stroke="#0f172a" stroke-width="1.4"/>
<text x="287" y="188" text-anchor="middle" fill="#0f172a">建 game_playerWU1</text>
<text x="287" y="205" text-anchor="middle" fill="#64748b" font-size="11">afterCommit 发注册成功事件</text>
<rect x="402" y="168" width="204" height="50" rx="9" fill="#ffffff" stroke="#7c3aed" stroke-width="1.6"/>
<text x="504" y="188" text-anchor="middle" fill="#7c3aed" font-weight="600">WU2 消费事件 → 从池 claim</text>
<text x="504" y="205" text-anchor="middle" fill="#64748b" font-size="11">CAS 绑定·幂等·渠道过滤</text>
<line x1="172" y1="193" x2="196" y2="193" stroke="#0f172a" stroke-width="1.4" marker-end="url(#a)"/>
<line x1="376" y1="193" x2="400" y2="193" stroke="#0f172a" stroke-width="1.4" marker-end="url(#a)"/>
<line x1="560" y1="168" x2="700" y2="132" stroke="#7c3aed" stroke-width="1.6" marker-end="url(#p)"/>
<text x="656" y="158" text-anchor="middle" fill="#7c3aed" font-size="10.5">claim FREE→CLAIMED</text>
<!-- 生成链 -->
<rect x="24" y="252" width="182" height="58" rx="9" fill="#ffffff" stroke="#0f172a" stroke-width="1.4"/>
<text x="115" y="275" text-anchor="middle" fill="#0f172a">生成任务派发</text>
<text x="115" y="293" text-anchor="middle" fill="#64748b" font-size="10.5">job 加 userToken 位(查池表 CLAIMED</text>
<rect x="250" y="252" width="164" height="58" rx="9" fill="#ffffff" stroke="#0f172a" stroke-width="1.4"/>
<text x="332" y="275" text-anchor="middle" fill="#0f172a">worker</text>
<text x="332" y="293" text-anchor="middle" fill="#64748b" font-size="10.5">用 job.userToken 调网关</text>
<rect x="636" y="246" width="210" height="100" rx="10" fill="#eef2ff" stroke="#4338ca" stroke-width="1.6"/>
<text x="741" y="270" text-anchor="middle" fill="#312e81" font-weight="700">new-api 网关(账本)</text>
<text x="649" y="293" fill="#312e81" font-size="12">tokens.remain_quota 减</text>
<text x="649" y="314" fill="#312e81" font-size="12">users.used_quota 增 = 权威消耗</text>
<text x="649" y="335" fill="#64748b" font-size="11">100.64.0.8:3000</text>
<line x1="206" y1="281" x2="248" y2="281" stroke="#0f172a" stroke-width="1.4" marker-end="url(#a)"/>
<line x1="414" y1="278" x2="634" y2="290" stroke="#0f172a" stroke-width="1.4" marker-end="url(#a)"/>
<text x="524" y="272" text-anchor="middle" fill="#64748b" font-size="10.5">该 token → 扣该用户额度</text>
<!-- 拒绝 -->
<rect x="250" y="372" width="356" height="44" rx="9" fill="#ffffff" stroke="#dc2626" stroke-width="1.5"/>
<text x="428" y="399" text-anchor="middle" fill="#dc2626">额度耗尽 → 网关 402/403 → 干净失败「生成额度已用完」</text>
<line x1="700" y1="346" x2="606" y2="392" stroke="#dc2626" stroke-width="1.4" stroke-dasharray="5 4" marker-end="url(#r)"/>
<text x="24" y="446" fill="#334155" font-size="12">核心思想new-api per-user quota 当账本provision 离线预置池,运行时只 claim 一个条目 + 生成扣额,零运行时 admin 耦合。</text>
<text x="24" y="466" fill="#334155" font-size="12">边界:不建钱包 / 不自助充值续额 / 运行时不调 admin API。新失败面=池空(懒 claim + 水位告警 + 补池)。成功=真机五验。</text>
<defs>
<marker id="a" markerWidth="9" markerHeight="9" refX="7" refY="4.5" orient="auto"><path d="M0,0 L9,4.5 L0,9 Z" fill="#0f172a"/></marker>
<marker id="p" markerWidth="9" markerHeight="9" refX="7" refY="4.5" orient="auto"><path d="M0,0 L9,4.5 L0,9 Z" fill="#7c3aed"/></marker>
<marker id="r" markerWidth="9" markerHeight="9" refX="7" refY="4.5" orient="auto"><path d="M0,0 L9,4.5 L0,9 Z" fill="#dc2626"/></marker>
</defs>
</svg>
三句话:**核心思想**——把 new-api 的 per-user quota 直接当额度账本(权威余额=token `remain_quota`、权威消耗=user `used_quota`),额度由离线 ops 脚本预建成池、注册时消费 WU1「注册成功」事件从池 CAS claim 一个条目绑玩家、生成时随 job 带该 token 扣额、耗尽网关直接拒。**边界**——只接网关额度,不在 game-cloud 建 credits 钱包、不做用户自助充值也不做运营续额闭环、**运行时不调 new-api admin API**provision 全离线)、权威 `logs.quota` 对账延后per-user 门只对真实 member 在线创作生效,编排器/bake-off/系统触发旁路走全局 key。**怎么算成功**——真机五验ops 脚本预建 N 条池new-api `users`/`tokens` 新增、每 token `remain_quota=¥100` 折算值);注册后从池 claim 一条 FREE→CLAIMED 绑该玩家;一次真 gen 后该 user `used_quota` 增;余额耗尽后 gen 被网关干净拒且回传可读拒因;池空时注册仍成功、水位告警、首次生成懒 claim 兜。
## 1 意图与目标
内测要给种子创作者发生成额度,防止有人无限刷生成把网关额度烧穿,同时给「花了多少、还剩多少」一个权威口径。创始人已拍定这条口径不是在后端自建一套 credits 钱包,而是复用 new-api 网关本就有的 per-user 额度体系:每个内测用户 ¥100消耗由网关权威计量。至于这批 per-user 账户怎么建S0 实测坐实了一条硬约束——new-api 的 admin 令牌能建用户、设 quota却**不能替他人建 token**(建 token 严格自认证、`access_token` 也不可经 API 设),任何 per-user token 都得直连它的 postgres 才能造出来。据此创始人拍定provision 不进 game-cloud 运行时,而是离线由一个 ops 脚本一次性预建一批 (user+token+¥100) 成池,运行时注册只从池里 claim 一个绑给用户——零运行时 admin 调用、零 game-cloud↔new-api-postgres 耦合。
选这条路的理由是它把「计量」这件最容易算错、最需要和真实扣费对齐的事,交给了唯一真正扣费的那一层。网关调用一次 LLM、按 token 数和模型倍率扣一次 quota`used_quota` 就是逐笔累加的真账;后端若自建钱包,就得把网关的扣费公式(含缓存折扣、模型倍率、分组倍率)在 Java 侧再实现一遍并保持同步,这正是 `wg1/gen-worker/worker/cost.py` 反复踩过的对账坑——它自己都在注释里写明「精确 `logs.quota` 仍需网关 access token 读 `/api/log/self`sk- key 无管理权」。把账本收敛到网关,这条线只需做「离线预置 token、运行时把对的 token 递到对的调用上」,不碰计量。
目标锁三条,全部可真机取证:
- 每个注册用户从预置池 claim 到一个专属 user + 专属 tokentoken `remain_quota` = ¥100 按网关口径折算的 quota 值¥100 由 ops 脚本预建时一次性设好)。
- 每次生成消耗都落在该用户自己的 token 上,网关 `used_quota` 随之增长,账目可查。
- 额度耗尽后生成被网关拒绝,后端把它翻译成用户可读的「生成额度已用完」,而不是一个含糊的「生成失败」。
## 2 边界
**做**:一个**离线 ops 预置脚本**(宿主 mini-infra用 new-api 管理凭据 + 直连其 postgres建 user / DB 写 access_token / 以该用户身份建 token / 设 quota一次性预建 N 个 (user + token + ¥100) 并把条目写进 game-cloud 池表;**game-cloud 维护一张额度池表**(预建条目 `status` FREE/CLAIMED + claim 绑定字段);**消费 WU1 afterCommit 发出的「注册成功」事件**,在 WU2 自己的事务线上从池做一次幂等、CAS 的 claim从 FREE 抢一个绑 `game_player`,不进注册事务、渠道过滤);给生成 job 的 §6.1 契约加一个 `userToken` 位并让三个 worker 入口从「读全局 env key」改成「优先读 job 上的 token」**给失败因枚举加一个 `quota_exhausted`,且按 contract-first 同批改 `aigc.yaml``dify-workflow-io.json` 两处共享枚举**让耗尽能干净回传。
**不做****不在 game-cloud 运行时调 new-api admin API、不做运行时开户**——provision 全部离线由 ops 脚本完成,运行时只对池表做 claim零 game-cloud↔new-api-postgres 运行时耦合;不在 game-cloud 建 credits/钱包表,不做扣费公式的二次实现,不做用户自助充值 / 支付 / 提现,**也不做运营续额闭环**(这些属「变现端到端」,本设计刻意不碰;每个池条目 ¥100 由 ops 脚本预建时一次性设好,运行时绝不改 quota、绝不对已 claim/已消耗条目重设额度);不做权威 `logs.quota` 对账(需管理 token 读 `/api/log/self`,明确延后到有需求时再接);**不改注册路本身**只挂事件消费者passport 不感知 WU2不改现有 bake-off / spike / 编排器批跑的行为——这些触发没有 `game_player` 成员身份dispatch 层的 per-user 门只对真实 member 生效,系统/编排触发在派发时按「无成员身份」直接旁路、回落全局 key判据见 §3.6,堵在 dispatch 层而非 worker 层)。
```mermaid
flowchart TB
subgraph IN["做(本设计闭环 · 六接线点 A-F"]
A["A 管理凭据<br/>ops 脚本用·不进运行时"]
B["B 离线 ops 预置脚本<br/>预建 N×(user+token+¥100)"]
C["C 注册 claim 消费者<br/>从池 CAS 绑定·渠道过滤"]
D["D 额度池表<br/>FREE/CLAIMED 条目"]
E["E job 加 userToken 位<br/>§6.1 契约追加字段"]
F["F worker 取 job token<br/>缺失回落全局 key"]
end
subgraph OUT["不做(越界,明确挡住)"]
X["运行时调 new-api admin API / 运行时开户"]
W["credits 钱包 / 扣费公式二次实现"]
R["用户自助充值 / 支付 / 提现 / 续额"]
L["权威 logs.quota 对账(延后)"]
S["改 spike/bake-off 全局 key 路"]
end
IN -.->|边界| OUT
```
## 3 方案
### 3.1 六个接线点如何串成一条数据流
额度供给分两段:**离线预置**ops 脚本预建池,接线点 A·B·D与**运行时**(注册 claim、生成扣额、耗尽拒接线点 C·E·F。下图是事实源逐点的字段契约在其后展开。
```mermaid
flowchart LR
subgraph OFFLINE["离线预置ops 脚本 · A·B·D"]
OPS["ops 预置脚本<br/>建户 API→DB 写 access_token<br/>→以该用户建 token API→设 quota API"]
OPS -->|凭据 A + postgres 脏活| NAP["new-api预建 N×(user+token+¥100)"]
NAP -->|脚本回写条目 FREE| POOL["额度池表 newapi_quota_pool<br/>N 条 FREE (D)"]
end
U[用户] -->|注册| REG["registerPlayerWU1<br/>注册事务提交"]
REG -->|同事务| PL["game_player 落库"]
REG -.->|afterCommit 发事件| EVT["注册成功事件/RocketMQ<br/>userId·registerChannel·anonId"]
EVT -->|WU2 消费C· 渠道过滤| CLAIM["从池 CAS claim 一个 FREE<br/>绑 game_player·写 biz_no"]
CLAIM -->|抢占 FREE→CLAIMED| POOL
CLAIM -.->|池空| ALERT["不抛·水位告警<br/>懒 claim 兜(§3.6)"]
U -->|创作生成| GEN["dispatchGeneric<br/>组 §6.1 job (E)"]
GEN -->|creatorUserId 是否为 member?| MEMB{有 game_player 行}
MEMB -->|否 系统/编排/bake-off| GLOB["旁路:回落全局 key<br/>不懒 claim"]
MEMB -->|是 真实 member| LOOK["查池表该玩家 CLAIMED 条目"]
LOOK -->|命中| TOK["job.userToken=token_key"]
LOOK -->|无 CLAIMED 条目| LZ["懒 claim 单 CAS 抢一次<br/>池空→干净失败+水位告警"]
LZ -->|抢到| TOK
TOK -->|job.userToken| WK["worker (F)"]
WK -->|用 job.userToken 调网关| NA["new-api 网关"]
NA -->|扣该 user 额度| LEDGER["token remain_quota 减<br/>user used_quota 增"]
NA -.->|余额耗尽 402/403| FAIL["quota_exhausted 干净回传"]
```
### 3.2 A · 管理凭据S0 已实测坐实
现有那把共享 `NEWAPI_KEY``sk-84GA...`,见 `docs/内网凭据与端点.md` new-api 段)是一把普通调用 token没有管理权——它能调 `/v1/chat/completions`,但建不了 user、改不了 quota。ops 预置脚本要建 user、设 quota必须用 new-api 的管理凭据。S02026-07-07真机亲验、测试用户已清理把这套管理面全部坐实不再是假设
- **root 与令牌**root 用户 = `id=1` `qingse``role=100``status=1`),持 32 字符 system access_token存 new-api postgres `users.access_token`)。
- **鉴权头**`Authorization: Bearer <access_token>` **且** `New-Api-User: 1`(缺 `New-Api-User` 头即 401。这是 New API 分支build `fd3e95ac2902-20260527`,非 one-api
- **端点(尾斜杠必须**gin `RedirectTrailingSlash`,否则 307`GET /api/user/?p=0&page_size=N` 列用户、`POST /api/user/` `{username,password,display_name}` 建用户、`PUT /api/user/` `{id,username,display_name,quota,group}` 设 quota、`GET /api/token/?p=0` 列 token`tokens` 表有 `name` 列,可按 user+name 建/查 token
- **折算**`quota_per_unit=500000``usd_exchange_rate` 用默认 `7.3`DB options 未覆盖¥100 grant_quota = `round(100/7.3×500000) ≈ 6,849,315`(回读落值确认)。
这套管理凭据只给**离线 ops 预置脚本**用,**不进 game-cloud 运行时**(运行时零 admin API 调用);令牌**存进 `docs/内网凭据与端点.md`**「new-api 管理令牌」节),不进 env-only遵守协议 §9 内网密钥进项目文档)。
**关键约束S0 坐实,正是它决定了 provision 走离线预置池)**admin 令牌**不能替他人建 token**——`POST /api/token/` 严格自认证(`New-Api-User` 必须等于令牌属主,否则 401「与登录用户不匹配」`body.user_id` 被忽略、token 落到 root 名下),且 `access_token` 不可经 `PUT /api/user/` 设(回读 empty。因此任何 per-user token 都得直连 new-api 的 postgres`infra-postgres:5432/new-api`)才能造出来,纯 HTTP API 做不到。这把「碰 postgres 的脏活」收进离线 ops 脚本,运行时便彻底不必接触 new-api 的内部 schema。
### 3.3 B · 离线 ops 预置脚本:一次性预建额度池
provision 不进 game-cloud 运行时,而是一个**离线脚本**,宿主 mini-infraSSH 直连、绕系统代理,遵守内网直连纪律),建议 **Python**(可同时用 HTTP API 与 psycopg 直连 postgres把干净活和 §3.2 那一步碰 postgres 的脏活一体承载)。脚本一次预建 N 个额度账户(内测 ≤200 规模N 取略高于当前种子用户数、留补池余量)。
**每个池条目四步建成S0 坐实的唯一可行路径)**
1. `POST /api/user/`APIroot 令牌建用户username 用确定性命名 `neice_<序号>``neice_001``neice_200`),拿 `newapi_user_id`
2. **DB 直写 new-api postgres**`infra-postgres:5432/new-api``UPDATE users SET access_token=<确定值> WHERE id=<uid>`——这一步是 S0 坐实的关键,`access_token` 无法经 API 设、又是下一步「以该用户身份建 token」的前提。
3. `POST /api/token/`API**用该用户的 access_token + `New-Api-User:<uid>`**,即以该用户身份)建 tokenname 用 `neice_<序号>``unlimited_quota=false``expired_time=不过期`,拿 48 位 `token_key`
4. `PUT /api/user/`APIroot 令牌)设 `quota=¥100 折算``grant_quota`§3.2 口径token 侧 `remain_quota` 挂同额。
**幂等(脚本可重跑补池)**:按确定性 username `neice_<序号>` 去重——重跑前先 `GET /api/user/` 列已建用户,跳过已存在的序号,只补齐缺口到目标 N**补池 = 提高目标 N 重跑脚本**。每条落一行审计日志(序号 / user_id / token_id / remain_quota / 结果),单条失败可单独重试、不影响已建条目。
**产出交接给 game-cloud**:脚本把每个建成条目的 `(newapi_user_id, newapi_token_id, newapi_token_key, grant_quota, quota_per_unit_snapshot, usd_rate_snapshot)` 写入 game-cloud 池表§3.5`status=FREE`。两种落法二选一——(a) 脚本直连 game-cloud MySQL `INSERT ... ON DUPLICATE KEY UPDATE`(按 `newapi_user_id` 去重);(b) 脚本导出 JSON、game-cloud 侧一次性导入脚本读入。内测取 (a) 最短路(脚本本就在做 DB 脏活,多一个 MySQL 连接可接受),去重键 = `newapi_user_id`
**¥100 ↔ quota 折算**:口径与 `cost.py` 同源、读网关而非硬编码。`GET /api/status`(无需鉴权,`cost.py:51-55` 已这么用)拿 `quota_per_unit`(默认 500000/`usd_exchange_rate`(默认 7.3`grant_quota = round(yuan / usd_exchange_rate × quota_per_unit)`¥100 ≈ `6,849,315`S0 实测参考值,供验收对表)。折算时把 `(quota_per_unit, usd_exchange_rate, grant_quota)` 三元快照随条目写进池表(`quota_per_unit_snapshot`/`usd_rate_snapshot`/`grant_quota`),日后网关调率不影响已发额度的审计追溯。
**外部交互处置**:每次 API 外呼连接超时 5s、读超时 10s5xx/超时重试 2 次(指数退避 0.5s→1s4xx 不重试postgres 直写用事务、失败回滚该条。脚本是离线一次性运行,不承载运行时 SLA但仍全程可追溯日志脱敏 `token`/`access_token`,见 3.8)。它把「三步建 token + 一次碰 postgres」这组无内建幂等的原子副作用一次性做完并落进池表运行时的幂等语义则完全由 §3.4 的 claim 状态机承载——两者解耦脚本负责「池里有货」claim 负责「一人分一个、不重分」。
### 3.4 C · 注册 claim消费 WU1「注册成功」事件从池 CAS 抢一个 FREE 条目绑玩家
接缝同前不变。**WU1 §3.6 对外只暴露一条接缝**:注册事务提交后(`afterCommit`)投递一个「注册成功」领域事件 / RocketMQ 消息,载荷含 `userId``registerChannel``anonId`**不在事务内提供任何挂点**且明确要求「hook 消费的是『注册成功』事件而非『password 注册成功』事件」。因此 WU2 **不能**在注册事务里 co-commit那是 WU1 用事件刻意解耦要避开的耦合),而是**作为该事件的消费者**,在 WU2 自己的线上从池 claim。宿主放 `game-module-aigc`新子包co-locate 派发期的池查询、passport 不感知 WU2经 RocketMQ 订阅注册成功 topic。
**provision 已离线完成,运行时不开户、不调 new-api**——claim 是一次纯 game-cloud 内部 DB 操作,没有任何 HTTP 外呼、没有跨 HTTP 开事务的原子性隐患,比原「运行时三步开户 + 状态机 + 三补偿腿」的方案简单一个量级。
**渠道过滤**(同前):只对真实种子渠道 claim`registerChannel ∈ {password, invite}``sms` 自动注册与 `sms-mock` 8888 后门跳过——避免一次性手机号每次首登都占掉一个 ¥100 池条目(见 §6 攻击面),这类若真去生成再由懒 claim 兜。
**claim 的原子绑定(单条 SQL CAS幂等**
- **幂等前置**:该 `game_player` 是否已 claim 由 `uk(claimed_by_game_player_id)` 保证一玩家至多一条 `CLAIMED` 条目——已有则 no-op 复用(重投事件 / 并发天然收敛)。
- **抢占 FREE 条目**`UPDATE newapi_quota_pool SET status='CLAIMED', claimed_by_game_player_id=?, claimed_at=now(), biz_no='claim_<gamePlayerId>' WHERE id=(SELECT id FROM (SELECT id FROM newapi_quota_pool WHERE status='FREE' ORDER BY id LIMIT 1) t) AND status='FREE'`(行级 CAS只有把某条 `FREE→CLAIMED` 翻转成功的那一路生效)。并发多消费者 / 重投由 `status='FREE'` 条件 + `uk(claimed_by_game_player_id)` + `uk(biz_no)` 三重收敛成一条,**任何路径都不会给同一玩家占第二个条目、也不会两玩家抢到同一条**。
- 抢到affected=1→ 该玩家已绑好 `token_key`,结束。
**注册不阻断**WU1 已保证事件发出即返回claim 与注册解耦,池抖动不拖垮注册。
**新失败模式——池空(无 `FREE` 条目)**:这是 provision 从运行时移到离线后新引入的失败面,必须显式兜。处置:
- **claim 消费者遇池空不抛异常**:池空是非瞬时故障(补池是人工 / 离线动作,不在 MQ 重试窗口内可恢复),抛异常只会把消息灌进 DLQ 空转。改为记 WARN + 触发**池水位告警**(提示 ops 补池),该玩家暂不绑定,注册照常成功。
- **持久兜底 = 首次生成懒 claim**§3.6 E该玩家真正要生成时派发线做一次单 CAS 懒 claim——池已补ops 重跑脚本)则绑上继续;仍空则当次干净失败给可读拒因(「额度账户准备中,请稍后重试」),玩家补池后重试即得。懒 claim 是一条 DB CAS不是慢外呼放在派发热路是安全的与原方案的三步慢开户根本不同见 §3.6)。
- **可选的定时补绑腿**best-effort默认不做补池后由 `@Scheduled` 扫「`password/invite` 渠道、注册已成功、但无 `CLAIMED` 池条目」的玩家批量补 claim让额度在首次生成前就绪。它需按 `game_player` 反查未绑玩家join 查询);因懒 claim 已覆盖真正需要额度的那一刻(生成时),此腿仅作体验前置增强、延后。
**池水位告警**`FREE` 条目数低于阈值(如 <20 <种子用户日增量即告警提示 ops 重跑脚本补池这是池模型的核心运维信号进观测
```mermaid
stateDiagram-v2
[*] --> FREE: ops 脚本离线预建<br/>(user+token+¥100) 写池表
FREE --> CLAIMED: 注册/懒 claim CAS 抢占<br/>绑 game_player·写 biz_no
CLAIMED --> INVALID: 运行时发现 token 失效(401)<br/>隔离·告警 ops(§3.9)
INVALID --> [*]: ops 排查/补池替换
CLAIMED --> [*]: 玩家生命周期内长期持有
FREE --> FREE: 池空→水位告警·懒 claim 兜(§3.6)
```
### 3.5 D · 额度池表:新表 newapi_quota_poolV32.0.0
池表存的是**预建条目**不再是「一玩家一映射行」——ops 脚本预建时按条目 INSERT`status=FREE``claimed_by` 空),注册时某条被 claim 才翻转成 `CLAIMED` 并绑到玩家。它带自己的生命周期FREE→CLAIMED→可选 INVALID与审计快照折算三元、幂等单号独立成表既方便脚本批量灌数、也让 claim 有清晰的抢占边界不污染玩家主表。DO 继承 `TenantBaseDO`(沿用 creator/时间/deleted/tenant_id 审计列;池是系统级资源,`tenant_id` 落系统租户即可)。
**版本号仲裁(与 WU1 并行落地必须协调)**:执行副本当前连续到 `V30.0.0`,而 WU2 依赖的 WU1`game_player` 增列 + `mobile` 放宽)已取 `V31.0.0` 并先落——WU2 骑在 WU1 的 schema 之上,故顺延取 `V32.0.0__create_newapi_quota_pool.sql`。**若两单同取 V31 会 Flyway 撞号、校验失败无法应用**。落地前把「`ls .../db/migration | sort -V | tail` 复核执行副本当时最大号、若已有更高号再顺延」当**硬前置**(与 WU1 §3.1 同口径),不能只当脚注;且同 WU1 一样**同时**落契约源 `contracts/db-schemas/` 与执行副本两处,守门①在本单做实。
字段级契约草案:
| 列 | 类型 | 语义 |
|---|---|---|
| `id` | bigint PK | 池条目主键(自增) |
| `newapi_user_id` | bigint, **uk** | 预建的 new-api `users.id`(脚本灌数去重键) |
| `newapi_token_id` | bigint | 预建的 new-api `tokens.id` |
| `newapi_token_key` | varchar(64) | `tokens.key`48 位 `sk-…`**敏感**,随 job 下发) |
| `grant_quota` | bigint | 该条目预置的 ¥100 折算 quota审计 |
| `quota_per_unit_snapshot` | bigint | 折算时网关 `quota_per_unit` |
| `usd_rate_snapshot` | decimal(10,4) | 折算时 `usd_exchange_rate` |
| `status` | varchar(16) | `FREE`(可 claim/ `CLAIMED`(已绑玩家)/ `INVALID`token 失效隔离§3.9claim CAS 抢占的锚§3.4 |
| `claimed_by_game_player_id` | bigint, null, **uk** | claim 后 ↔ `game_player.id``FREE` 时为 null唯一键允许多 null故多条 FREE 可并存,`CLAIMED` 保证一玩家至多一条) |
| `claimed_at` | datetime, null | claim 时间 |
| `biz_no` | varchar(64), null, **uk** | claim 幂等键 `claim_<gamePlayerId>``FREE` 时为 null |
| `remark` | varchar(255) | 隔离原因等 |
`newapi_token_key` 是密钥落库内测阶段明文存但须视为敏感3.8 讲传输与日志脱敏,生产化时应加密列——本阶段不做,记为红线待办)。前后兼容:纯新增表,不改任何既有表结构,无迁移风险;回滚 = drop 表 + 摘事件消费者。
**额度落点钉死(避免摇摆)**¥100 grant 由 ops 脚本预建时写 new-api 两处,但权威口径唯一——**token 的 `remain_quota` 是权威余额闸**(预建 token 时 `unlimited_quota=false`、挂 `grant_quota``users.quota` 设同额只为账户自洽、不作限额裁决;**`users.used_quota` 是权威消耗**(网关逐笔累加)。触发耗尽 402 的是 token `remain_quota→0`(网关按这把 token 扣该 user。据此三处验收对同一口径预建后查 token `remain_quota=grant_quota`§5.1)、一次 gen 后查 user `used_quota` 增量§5.3)、把 token `remain_quota` 设近 0 触发耗尽§5.4)。池表只存 `grant_quota` 审计快照,不在 game-cloud 侧记余额(余额始终以网关为准)。
### 3.6 E · job 加 userToken 位与 per-user 门的适用边界§6.1 契约变更)
`AigcGenerateExecutor.dispatchGeneric`658-681组 §6.1 job 时追加一个 `userToken` 字段。但**per-user 额度门只对「真实 member 在线创作」生效**,这是一条必须钉死的边界,否则会误伤既有验证线。
**为什么必须分身份**:编排器批跑与 bake-off 是「伪装在线提交」——经 `submitGenerate → dispatchGeneric``AigcTaskServiceImpl` 202-204 注释确认),同样流经此处;`task_source`V17当前无 ReqVO 信号、全落 `online`,据 source 辨别不了。若在此一律按 member 查池、无 `CLAIMED` 条目即懒 claim 或干净失败n=5 收敛环 / bake-off / 脚本路会被这道闸挡死;更糟的是 admin/system 触发身份属另一套 id 空间、**没有 `game_player` 行**,懒 claim 会替一个 admin/system id 占掉一个 ¥100 池条目(白白耗池,这些 id 也不是真实玩家)。
**据此的判据(今天就成立)**——门只认 `creatorUserId` 是否命中一行 `game_player`
- **无 `game_player` 行**(系统 / 编排器 / bake-off / admin 触发)→ **旁路**:不查池、不懒 claim`job.userToken` 留空worker 回落全局 key§3.7)。这条兜住越界回归。
- **命中 `game_player` 行**(真实 member 在线创作)→ 走 per-user 门:来源链 `creatorUserId``AigcTaskDO` 38 行既有字段)→ 查 `newapi_quota_pool` 取该玩家 `CLAIMED` 条目的 `newapi_token_key` → 放进 `job.userToken`。将来编排器据契约显式声明 `source=orchestrator` 后,可把 source 叠加进旁路判据、更精确。
**该玩家无 `CLAIMED` 条目(存量 null / 注册时池空未绑)时——派发线做一次单 CAS 懒 claim**。这里有个相对原「运行时开户」方案的关键收益claim 是纯 game-cloud 内部一条 `FREE→CLAIMED` 的 DB CAS**不是三步慢外呼**——原方案那条「懒开户在有界消费池(`consumeThreadMax=15`)里串 `createUser→createToken→setUserQuota` 三次分钟级外呼、首次生成高峰叠加 new-api 抖动拖垮共享池」的稳定性红线,随 provision 离线化一并消失,派发热路里放一条 DB CAS 是安全的。处置:命中 `CLAIMED` 条目取 `newapi_token_key` 放进 `job.userToken`;无 `CLAIMED` 条目则懒 claim 抢一个 `FREE`——抢到就用;**池空抢不到**就**当次干净失败**给可读拒因(`quota_exhausted`,文案「额度账户准备中,请稍后重试」),同时触发**池水位告警**提示 ops 补池玩家待补池后重试即得§3.4 的池空处置)。无论如何**绝不派一个没 token 的 create 路 job 下去**。
**契约兼容声明**(须随契约通知相关方):`userToken` 是**追加字段**,老 worker 收到多余字段不炸——三个 worker 入口解析 job 均走 `job.get(...)` 逐键取值(`worker_service.py:142-146` 即此模式),未知键被忽略,不做严格 schema 校验。所以这是一次向后兼容的加法:新后端 + 老 worker = worker 忽略 `userToken` 走全局 key行为同今天新后端 + 新 worker = 走 per-user token。无需 worker 与后端同步上线。
### 3.7 F · worker 取 job token 与回落策略
三个入口从「读全局 env `NEWAPI_KEY`」改成「优先读 `job.userToken`」:`cheap-worker/cheap_service_driver.py:28-44``_resolve_key` 现从 `client.get_api_key()` 读 env`cheap-worker/worker_service.py` 的 job 消费边界、`wg1/gen-worker/worker/_client.py:47``get_api_key` 现读 env `NEWAPI_KEY`)。
**回落策略(须给判断)**`job.userToken` 存在则用它;**缺失则回落全局 env key**。判断依据在 dispatch 层已划清§3.6)——真实 member 的 create 路由 E 保证必带 token拿不到就在后端 fail-fast 干净失败worker 见不到无 token 的 member create job而系统 / 编排器 / bake-off / spike / 脚本路本就没有 `game_player` 成员身份E 已按「无成员身份」旁路、不给它们塞 token它们缺 `userToken` 是**正常且预期**的,回落全局 key 才能让这些既有验证路继续跑。若在 worker 这层对缺失一律硬拒,会误伤这些路。所以额度口子不在 worker 层堵,而在后端 dispatch 层按身份堵死member create 路无 token 即失败、系统路旁路——worker 只做「有则用、无则回落」,并在回落时打一条 WARN便于发现本该带 token 的 member create job 异常走了共享计量)。这样既不破坏既有验证路,又不给 member create 路留静默烧共享额度的洞。
### 3.8 per-user token 在内网传输的安全面
`userToken` 是一把 new-api 调用凭据,现在要随 job 从后端流到 worker内网 HTTP dispatch。它的暴露面job 载荷若被完整打进日志、token 出现在 worker 结果回传里、dispatch 通道本身是否鉴权。处置:
- **日志脱敏**:后端与 worker 两侧都不整条打 `userToken`,仿 `DesensitizedUtil` 只留前后各 4 位;池表与 job 载荷不进 INFO 级全量日志。
- **传输面同现状**job dispatch 与 worker 回调都在内网Tailscale 100.64.x / localhost与现有全局 `NEWAPI_KEY` 已经躺在 worker env 里是同一信任边界——本变更没有把凭据带出内网,只是从「一把共享」变成「每人一把」,粒度更细、单把泄漏的爆炸半径反而更小(只影响单个用户 ¥100
- **与回调 HMAC 收口同源**回调方向worker→后端已有 `aigc.executor.callback-secret` 的 HMAC-SHA256 验签(`AdminAigcTaskController` 内网回调 3b-B。派发方向后端→worker当前是内网可达即信任`userToken` 进入载荷把「保护 dispatch 通道」的优先级抬高了一档——内测阶段维持内网可达 + 日志脱敏可接受,生产化时应给 dispatch 通道同等的服务间鉴权(记为红线待办,随 facade 回调 HMAC 收口一并规划,不在本内测闭环内实现)。
### 3.9 额度耗尽如何干净失败§6.5 失败因)
用户 token `remain_quota` 耗尽时,网关对 `/v1/chat/completions` 返非 2xxone-api 系为 402/403错误体含「用户额度不足 / insufficient user quota」类 message。今天 worker 把任何非 2xx 都归 `llm_error``ExecutorLlmClient` 134-138 的 HTTP 错分支同理),用户只会看到含糊的「生成失败」。要干净失败:
- **contract-first不可只加 Java 枚举)**`FailureReasonEnum` 的头注明写「取值与 Dify `output.failureReason` 严格一致,不可单边扩展(跨契约共享枚举)」。故 `quota_exhausted` 必须**同一改动里落三处**`FailureReasonEnum``game-module-aigc-api`)新增 `QUOTA_EXHAUSTED("quota_exhausted", "生成额度已用完")``contracts/api-schemas/aigc.yaml:296``FailureReason.enum``contracts/dify-workflow-io.json:50``output.failureReason.enum`。该枚举与 Dify 输出共享、`dify-workflow-io.json` 归 WU3 面,**须与 WU3 同批协调落**`quota_exhausted` 由 worker/网关侧产出、Dify 工作流本身不产出,但共享枚举须登记一致,否则 docs/contract 门拦)。落库列 `game_aigc_task.failure_reason``varchar(32)``quota_exhausted` 15 字符,容得下)。
- **消费方对未知值的容错须落地核实、非断言**:前端 `failureReason→文案` 映射须有默认回落(在 `game-studio` 对应映射处核未知值回落到通用「生成失败」不炸DB 列无 enum 约束天然容错;这三处逐个确认后再落,别把「不炸」当断言。
- worker 在调网关拿到非 2xx 时,**分辨两类失效**(确切 message/code 待 S4 真机耗尽 / 凭据失效测试确认——S0 只坐实了管理面,未测 `/v1/chat/completions` 的耗尽错误体):① 402/403 额度耗尽 → 回调 `failure_reason=quota_exhausted`;② 401/凭据失效token 被 admin 删/改、或 `CLAIMED` 条目存的 `token_key` 已陈旧)→ **不塌成 quota_exhausted**,触发一次 **re-claim**:把该 `CLAIMED` 池条目隔离为 `INVALID`(供 ops 排查 / 补池替换、不再派发),经 §3.4 的 CAS 给该玩家 re-claim 另一个 `FREE` 条目,并按可重试失败回传(维持 `llm_error`,不为此再摊一次跨契约枚举同步成本);池空则告警 ops。内测把池条目 token `expired_time` 由 ops 脚本设**不过期**`unlimited`/null从源头避免自伤式过期401 应属罕见。
- 后端 `handleCallback` 落终态时把该拒因透传给前端,用户看到「生成额度已用完」。
**¥100 花完后的用户体验**生成入口被挡3.10 的可选轻门或耗尽时的干净失败),用户看到明确的「额度已用完」文案。**内测不提供自助充值,也不做运营续额闭环**:每个池条目 ¥100 由 ops 脚本预建时一次性设好,运行时绝不改 quota**无运行时 `setUserQuota`**),更**严禁对已 claim/已消耗条目重设额度**(绝对赋值会把已花额度刷回满额=免费续杯)。若未来确需续额,那是对 token `remain_quota`**delta-aware 增量**(读现值再加)、或引入独立 grant 流水表承载多笔——池表 `uk(claimed_by_game_player_id)` 一人一条、`biz_no` 一单,本就不为多笔续额设计。续额属「变现端到端」范畴,不在本内测闭环。
### 3.10 可选加项:入队前轻余额门
`AigcTaskServiceImpl.enqueueWithControlPlane`335 起,现有降级门①/配额并发门②/背压门③;`submitGenerate``retryTask` 共走)可加一个**可选**的轻余额预检:在既有门旁查该玩家 claim 到(`CLAIMED` 条目)的 token 的 `remain_quota`,若低于一次生成的估算成本(如 ¥0.15 折算 quota则提前拒新增错误码「额度不足」省掉一次注定被网关拒的空跑。**同 §3.6 的身份边界**:此预检也只对真实 member`creatorUserId` 命中 `game_player`)生效,编排器/bake-off 经此共享入队门时须旁路(无成员身份即跳过预检),否则同样会误伤批跑。
这门是 best-effort 加项,不是闭环必需:查网关失败(超时/网关抖)**不阻断**——直接放行让网关在真扣费点做权威判定(网关才是额度的最终裁决者,预检只是体验优化)。默认可先不做,留作接线点跑通后的体验增强。
## 4 步骤计划
代码物随阶段一落地,本设计只定架构级 HOW。
| 阶段 | 交付物 | 验证 | 依赖 | 风险 |
|---|---|---|---|---|
| S0 实测管理面A**已完成** | `docs/内网凭据与端点.md` 管理令牌节 + 管理面三件事实已坐实root=id1 `qingse` + 鉴权头 `Bearer access_token`+`New-Api-User:1` + 端点尾斜杠 + 折算 500000/7.3/¥100≈6,849,315**关键约束坐实**admin 不能替他人建 token、`access_token` 不可经 API 设 → per-user token 必须碰 postgres | 真机手工建户/建token(以该用户身份)/setQuota 各成功一次、测试用户已清理 0 残留S0 findings 已亲验) | mini-infra new-api 可达 | 无——已实测锁定,本档 3.2/3.3/3.9 假设项已消解 |
| S1 ops 预置脚本 + 池表B·D | Python 预置脚本(建户 API → DB 写 `access_token` → 以该用户身份建 token API → 设 quota API`neice_<序号>` 幂等补池;产出写 game-cloud 池表)+ **`V32.0.0`** 池表迁移(契约源+执行副本两处)+ DO/Mapper | staging 跑脚本预建 N 条new-api `users`/`tokens` 新增 N 行、每 token `remain_quota`=¥100 折算、`unlimited=false`;池表 N 条 `FREE``token_key`/快照落齐;脚本重跑不重建(按序号去重) | S0 事实 | postgres 直写耦合 new-api schema——限**离线脚本**承担、不进运行时;`ls\|sort -V\|tail` 硬复核版本号 |
| S2 事件消费 claim + 池空处置C | **RocketMQ 消费 WU1 注册成功事件**(渠道过滤)+ 单 CAS `FREE→CLAIMED` claim`uk_claimed_by`/`uk_biz_no` 幂等)+ 池空不抛/水位告警 + 懒 claim 挂点§3.6 兜) | 注册后池一条 `FREE→CLAIMED``claimed_by`=该 `game_player.id`;重投事件不二次 claimuk+CAS 拦);把池 FREE 清零后注册仍成功、水位告警触发、补池后首次生成懒 claim 绑上 | S1 池 + **WU1 事件接缝已落** | 池空是新失败面(懒 claim + 告警兜);幂等靠 uk + 单 CAS |
| S3 job 加位 + 身份门 + worker 取 tokenE·F | dispatchGeneric 追加 `userToken` + **member/系统身份分流**(无 game_player 行旁路回落)+ **单 CAS 懒 claim**(池空当次干净失败、不阻塞派发线)+ 三 worker 入口改读 job token + 回落 | 新后端+老 worker 回归不炸member create 路用 per-user token编排器/bake-off/n=5 收敛环仍能跑(旁路全局 key 不被误挡);无 CLAIMED 条目时懒 claim 绑上或干净失败、派发池不阻塞 | S2 池 | 契约兼容——追加字段、worker `.get` 忽略未知;懒 claim 是 DB CAS非慢外呼派发热路安全 |
| S4 耗尽干净失败§6.5contract-first | **同批**改三处枚举(`FailureReasonEnum` + `aigc.yaml:296` + `dify-workflow-io.json:50`,与 WU3 协调)+ worker 分辨 402/403 额度耗尽 vs 401 凭据失效(后者 re-claim 池条目)+ 前端拒因回落核实 | 真机把 token `remain_quota` 设近 0 → gen → 后端落 `quota_exhausted`docs/contract 门绿;前端未知值不炸 | S3 | 网关额度错/凭据错的确切 message——S4 真机耗尽 / 凭据失效测试确认S0 只坐实管理面、未测耗尽错误体);跨契约共享枚举须与 WU3 同批 |
| S5可选 | 入队前轻余额门member-only | 余额不足入队即拒;查网关失败不阻断;编排器旁路不被误挡 | S3 | best-effort不做也闭环 |
## 5 验证方式
验收门全部可真机取证内网直连绕系统代理SSH mini-infra 走真 IP
口径钉死(对齐 §3.5**权威余额 = token `remain_quota`、权威消耗 = user `used_quota`**,验收对同一口径。
1. **预置池**ops 脚本跑一次,`SELECT count(*) FROM users WHERE username LIKE 'neice_%'``tokens` 各新增 N 行、每 token `remain_quota` = ¥100 折算值(`unlimited_quota=false`,默认约 6,849,315game-cloud `SELECT count(*) FROM newapi_quota_pool WHERE status='FREE'` = N`token_key`/`grant_quota`/快照三元落齐。
2. **注册 claim**:注册一个内测用户(走 WU1 password 路)后,池里恰有一条 `FREE→CLAIMED``claimed_by_game_player_id`=该 `game_player.id``biz_no='claim_<id>'``claimed_at` 非空;池 `FREE` 计数 -1。
3. **生成扣减**:记录一次真 gen 前后该 claim 到的 user `SELECT used_quota FROM users WHERE id=<newapi_user_id>``Δused_quota > 0`;且该 token `remain_quota` 相应减少(权威余额闸)。
4. **耗尽干净拒**`PUT` / SQL 把该 token `remain_quota` 设近 0 → 触发一次 gen → 观察网关返 402/403 且后端任务终态 `failure_reason=quota_exhausted`(而非 `llm_error`),前端拿到可读拒因。
5. **claim 幂等**:对同一 `game_player_id` 重放注册成功事件(或手工重投消费),池不新增第二条 `CLAIMED`、该玩家仍绑同一条(`uk_claimed_by` + 单 CAS 拦住);并发重投时验只有一条路径把某条 `FREE→CLAIMED`
6. **池空降级**:把池 `FREE` 清零后注册一个用户注册WU1成功可登录、该玩家无 `CLAIMED` 条目、水位告警触发ops 补池(重跑脚本)后对该玩家首次生成触发懒 claim 绑上(或定时补绑腿)。
7. **越界不误伤**:驱动一次 bake-off / n=5 收敛环批跑(系统/编排触发,无 `game_player` 成员身份),验其 job 不被 per-user 门挡、`userToken` 留空、worker 回落全局 key 正常出图(证 §3.6 身份分流成立)。
8. **存量 null 懒 claim**:对一个无 `CLAIMED` 条目的存量 member 触发生成,验单 CAS 懒 claim 就地绑上(池有货)或干净失败给可读拒因(池空),派发线不被阻塞。
可观测信号:**池水位(`FREE` 计数)与 claim 成功/池空计数**、池条目 `status` 流转(`FREE→CLAIMED``CLAIMED→INVALID` 隔离)、任务终态 `failure_reason` 分布(`quota_exhausted``llm_error` 可区分、worker→网关外呼日志脱敏 token运行时不存在 admin 外呼日志,佐证零运行时 admin 调用)。
## 6 风险与回滚
- **provision 已离线,运行时零 new-api admin 耦合**(本方案相对 on-demand 的关键收益):管理凭据与 postgres 直写只活在离线 ops 脚本里game-cloud 运行时零 admin API 调用、零 new-api-postgres 连接。原「运行时开户外呼进 DB 事务 / 三步慢外呼拖垮派发池 / CAS+PROVISIONING 串行三补偿腿」那一整套原子性与稳定性风险,随 provision 离线化一并消失。
- **新失败面:池空**provision 离线化换来的代价是池会耗尽。三层兜——注册时池空**不抛异常**(记 WARN + 水位告警、不绑、注册照常成功)、首次生成**单 CAS 懒 claim**池已补则绑上、仍空则当次干净失败给可读拒因、ops **重跑脚本补池**(按 `neice_<序号>` 幂等去重、提高目标 N。**池水位告警(`FREE` <阈值是核心运维信号**进观测缺了它池会静默见底所有新用户拿不到额度
- **claim 幂等**:一玩家至多一条 `CLAIMED``uk_claimed_by`+ `biz_no` 唯一键 + 单条 `FREE→CLAIMED` CAS重投 / 并发收敛成一条——不会给同一玩家占第二个条目、也不会两玩家抢到同一条。这比原方案简单claim 是纯 DB 操作没有「HTTP 副作用无法回滚」的问题。
- **越界不误伤既有验证线**per-user 门只对真实 member`creatorUserId` 命中 `game_player`)生效;编排器批跑/bake-off/n=5 收敛环「伪装在线提交」经 `submitGenerate→dispatchGeneric``AigcTaskServiceImpl` 202-204同样流经派发点无成员身份即旁路回落全局 key、绝不懒 claim否则替一个 admin/system id 白占一个 ¥100 池条目)。堵点在 dispatch 层按身份分流,不在 worker 层。
- **账本口径唯一**:权威余额 = token `remain_quota`、权威消耗 = user `used_quota``users.quota` 只作账户自洽不作限额裁决。¥100 由 ops 脚本预建时一次性设好,**运行时绝不改 quota无运行时 `setUserQuota`**,更严禁对已 claim/已消耗条目重设额度(绝对赋值会刷回满额=免费续杯);不做运营续额闭环,续额若需为 delta-aware 增量或独立 grant 流水表,属变现端到端、不在本闭环。
- **契约一致性contract-first会被门拦**`quota_exhausted` 是跨契约共享枚举(`FailureReasonEnum` 头注禁单边扩展),必须同批落 Java 枚举 + `aigc.yaml:296` + `dify-workflow-io.json:50` 三处、与 WU3 协调前端未知值容错须逐处核实非断言。worker 须分辨 402/403 额度耗尽(→`quota_exhausted`)与 401 凭据失效(→隔离该条目 `INVALID` + re-claim 另一 `FREE` + 可重试失败),池条目 token `expired_time` 由 ops 脚本设不过期避免自伤。
- **凭据面**`newapi_token_key` 明文落池表、随 job 内网传输内测可接受但须日志脱敏claim 按 `registerChannel` 过滤——`sms-mock` 8888 后门与一次性手机号自动注册不 claim避免白占 ¥100 池条目。**管理令牌system access token是皇冠明珠**(泄漏可建任意用户/设任意额度/读全部 channel key爆炸半径远大于任何 per-user token现只被离线 ops 脚本使用,只存 `docs/内网凭据与端点.md`、最小权限、可轮换、标注泄漏应急,单列一条红线;**new-api postgres 直写凭据同属敏感**ops 脚本用,泄漏可乱改 new-api 账本),一并列红线。生产化的列加密与 dispatch 通道服务间鉴权随 facade HMAC 收口规划,不在本闭环。
- **脏活耦合 new-api schema**postgres 直写 `users.access_token` 耦合 new-api 内部 schema升级可能改列名——但仅限**离线 ops 脚本**承担New API 升级时只需改脚本一处、不波及 game-cloud 运行时。这正是选池模型(相对让 game-cloud 加 new-api postgres JDBC 的 on-demand 混合方案)的关键收益。
- **版本号并行仲裁**WU1 与 WU2 同批新增迁移——WU1 取 V31、WU2 顺延 **V32**,落地前 `ls .../db/migration | sort -V | tail` 硬复核,避免 Flyway 撞号。
- **对 WU1 的依赖面**WU2 骑在 WU1 的 `mobile` 放宽可空之上;`mobile` 可空的跨模块回归trade/aigc/遥测等假定非空的消费方、脱敏空安全)与防枚举验证码闸门**归 WU1 验收**(主线 WU 归属WU2 不重复挑仅登记此依赖WU1 未证前 WU2 的 member 身份查询不假定 `mobile` 非空。
- **回滚**:本设计全为加法(离线 ops 脚本 / 新池表 / 事件消费者 claim / job 追加字段 / 新枚举值),回滚 = 摘事件消费者 + worker 恢复读全局 key + drop `newapi_quota_pool` + 撤 `userToken` 字段 + 回撤三处枚举ops 脚本是离线工具,不用则不跑、删除即可;老全局 `NEWAPI_KEY` 路径始终保留,回滚即退回今天的共享计量行为,零数据损伤。
## 附 图清单与状态
| 图 | 媒介 | 位置 | 状态 |
|---|---|---|---|
| 图0 一图看懂 | inline SVG | §0 | 随文house style浅底/实线现行/虚线待办/红线拒绝路) |
| 图1 六接线点数据流 | mermaid | §3.1 | 随文(事实源) |
| 图2 池条目 FREE→CLAIMED claim 状态机 | mermaid | §3.4 | 随文(事实源) |
| 图3 边界 in/out | mermaid | §2 | 随文(事实源) |
> 关联 commit`cb9f135d`。收口时按 frontmatter `sot-impact` 把可存留结论折进「生成引擎运行时」(§6.1/§6.5) 与「数据模型」两个 canonical SoT并把 `quota_exhausted` 同步进 `contracts/api-schemas/aigc.yaml``contracts/dify-workflow-io.json` 两处共享枚举(与 WU3 协调本设计档降为留痕。S0 已实测坐实(管理端点/鉴权头/折算/「admin 不能替他人建 token」均已验证本档 3.2/3.3 的原「待实测」管理面假设项已消解provision 据此定为离线预置池 + 注册 claim§3.9 的耗尽 / 凭据失效错误码 S0 未覆盖,仍待 S4 真机确认。

View File

@ -0,0 +1,246 @@
---
date: 2026-07-07
topic: 内测-dify游戏开发节点-形态A
status: 草案
sot-impact: 无 canonical 修订——本设计不改 §6.1 job/result-out 契约、不改生产生成 runtime、不改契约#6(dify-workflow-io.json,反而澄清其历史身份)。仅两处收口回写:① 实施落地后回写《内网凭据与端点》(canonical)补 dify(nginx :18080/:18443、plugin_daemon :15003)与便宜档 cheap-worker(:9501)、cheap Service(:8300)的 dev 端点及 dify 专用 new-api token;② 若该内测通道日后转常设入口,再回写《生成引擎运行时》(agentic运行时架构图说,canonical)补一条「dify workflow 旁路 ingress」注记。本档阶段只申报影响面,不动 canonical。
上级: docs/mvp/MVP作战清单.md
关联: |
cheap-worker/worker_service.py(:34 DEFAULT_PORT=9501、:192-204 try_enqueue 按 job_id/traceId 在 _seen 去重、:514-547 /generate 入口、:82-105 HMAC 签名、:229-256 落盘与回调 status=succeeded/failed、:570 main 硬编码 host=0.0.0.0 无 --host) ·
cheap-worker/cheap_service_driver.py(:29-32 NEWAPI_KEY 从进程 env 取、非 per-job token、:200-276 驱动 cheap Service /chat @127.0.0.1:8300) ·
cheap-worker/cheap_budget.py(:18-37 单局 ¥10 软停/¥15 硬地板、rmb_hard_limit/soft_budget override 被 pop、调用方不可改) ·
cheap-worker/cheap_run.py(:42-44 game_dir=games/amgen-<id>/、:220-234 scaffold 先克隆 _template/src 7 文件) ·
game-cloud/.../executor/AigcGenerateExecutor.java(:658-673 §6.1 job 组装) ·
game-cloud/.../controller/admin/task/AdminAigcTaskController.java(:113-169 /dify/callback-internal) ·
game-cloud/.../service/executor/CallbackSignatureVerifier.java(:63-99 enabled/verify) ·
game-cloud/.../service/callback/DifyCallbackTxService.java(:131-135 selectByTraceId→AIGC_TASK_NOT_EXISTS) ·
contracts/dify-workflow-io.json(契约#6,历史方向,本档不改) ·
commit cb9f135d
图清单: [图1 拓扑总览, 图2 回流落点三选项决策, 图3 触发-生成-回流时序, 图4 做与不做边界]
---
# 内测 · dify 游戏开发节点直连 agentscope 生成线 —— 形态 A 落地设计
## 0 一图看懂
内测要交付的能力只有一句话:在 mini-infra 上那套已经部署好的 dify 里搭一条最小 workflow其中一个「游戏开发节点」一个 HTTP Request 节点)把用户的一句话创意组成后端生成线早已定义的 §6.1 job直接 POST 给便宜档的 cheap-worker `:9501/generate`,由现成的 agentscope 生成线跑出一款小游戏。后端近零改动——不新增接口、不改生成主线、不碰契约。
```mermaid
flowchart LR
subgraph MI["mini-infra 100.64.0.8"]
D["dify 1.15.0<br/>nginx :18080/:18443"]
DN["游戏开发节点<br/>(HTTP Request)"]
NA["new-api :3000<br/>(LLM 网关)"]
D --> DN
end
subgraph GEN["便宜档生成栈 (dev,如 mini-desktop)"]
W["cheap-worker :9501<br/>/generate 有界队列"]
S["cheap Service :8300<br/>/chat agentscope 生成"]
DIR[("game_dir 落盘<br/>src/ 多文件源工程")]
W --> S
S --> DIR
end
DN -->|"§6.1 job POST<br/>(Tailscale)"| W
S -->|"OpenAI 兼容调模型"| NA
W -.->|"回流三选项<br/>见 图2"| OUT{{"产物落点"}}
DIR --> OUT
```
**核心思想**dify 只是又一个「组 §6.1 job 的调用方」,替换掉后端 `AigcGenerateExecutor` 的角色job 契约与 worker 生成核心一字不改复用现货把「agentscope 能被 dify workflow 驱动」这件事跑通。
**边界**:内测形态 A 是一条**独立生成通道**——产物落在 worker 的 `game_dir` 与 dify 自己手里,**不进 game-cloud 的发布/feed****不扣任何用户的 new-api 个人额度**。要让 dify 产物进 feed 属于形态 B需要一个能建 task 记录的服务间免登录触发/回调面),工作量与攻击面都上一个台阶,内测不做。
**怎么算成功**:从 dify`:18080`)触发一次 workflow → cheap-worker `:9501` 收到 job 并回 202 → agentscope 真跑一局 → worker 收口日志报 `status=succeeded``game_dir` 落定产物。注意成功判据是那行收口日志,**不是**「`src/` 非空」——scaffold 会在真生成前先把 `_template/src`7 个文件)克隆进 `game_dir`,一次 scaffold 成功但生成失败的 run 同样「src/ 非空」,单看它区分不出成功与失败(详见 §5。这一条端到端链路走通即「内测前支持 dify workflow」成立。
> §0 的门面 SVG 概览图随收口由图层子代理据本节文字与图1 派生补上;当前草案以 Mermaid 为事实源。
## 1 意图与目标
内测要对内证明一件事:平台的游戏生成能力不只能被自家后端的执行器驱动,也能被一个通用的 workflow 编排器dify当作一个节点挂进去调用。这关系到后续「B 端定制」与「把生成能力对外暴露成可编排的一步」这条叙事——如果 agentscope 生成线只能被 game-cloud 内部那条异步队列唤起,它就还只是一个封闭子系统;一旦它能被 dify 这类外部编排面以标准 HTTP 调起,它才具备「作为一个可复用生成算子被别的流程组合」的形态。
之所以现在能几乎零成本做成,是因为生成线早就把「投递面」设计成了纯 HTTP + 一份稳定的 job 契约。后端的 `AigcGenerateExecutor` 组一份 §6.1 job`job_id`/`tier`/`kind`/`brief`/`model`/`budget`/`gameId`/`templateId`/`renderCtx`/`idempotency_key`/`deadline_ms`/`callback`/`traceId`POST 给 cheap-worker 的 `/generate`worker 入队后返 202、在后台线程跑 agentscope、完成后按 `callback.target` 把结果 HMAC 签名发回。这套投递面对调用方是谁并不关心——它只认 job 的形状。dify 的一个 HTTP Request 节点完全可以充当这个「组 job 的人」。
目标就落在这条最短路径上:
- 在 dify 里建一条最小 workflow含一个把 §6.1 job 打给 cheap-worker `:9501/generate` 的 HTTP「游戏开发节点」
- 把 dify 的输入(用户一句话 brief映射进 job 字段,鉴权与超时按内网现实配好;
- 明确 dify 生成的游戏走哪条回流、落在哪里,并把「不进 feed、不扣个人额度」这两条边界钉死避免和后端生产链路、和 WU2 的计费门互相污染;
- 交付一次可真机取证的端到端 demo 作为验收。
不做的是任何后端生成主线的改动,以及契约 #6 里「后端主动调 Dify」那个历史方向。
## 2 边界
```mermaid
flowchart TB
subgraph IN["做 (in scope)"]
I1["dify 最小 workflow + 一个 HTTP 游戏开发节点"]
I2["dify 输入 → §6.1 job 字段映射(复用现有契约,不改)"]
I3["回流落点决策 + 边界钉死(不进 feed)"]
I4["额度接缝:dify 专用 token,不扣个人额度"]
I5["安全:内网 + HMAC 两端非空约束(若用回调)"]
I6["一次真机取证 demo 作验收"]
end
subgraph OUT["不做 (out of scope)"]
O1["契约#6「后端主动调 Dify」方向"]
O2["改后端生成主线 / §6.1 job 契约 / 生产 runtime"]
O3["dify 产物进 game-cloud 发布/feed(=形态B)"]
O4["把 dify 产物计入创作者账户/个人额度"]
O5["dify workflow 内自建安全/意图/质量多节点(契约#6 旧六节点形态)"]
end
```
在范围内的是 dify 侧的 workflow 与节点、字段映射、回流与额度的边界判断、以及安全与验收。这些改动几乎全部落在 dify 里(一份 workflow 定义和运维配置里端点、token、密钥后端代码近零改。
明确排除四类。其一,契约 #6`contracts/dify-workflow-io.json`描述的是「Java 壳主动调 Dify workflow、Dify 内跑六个节点再把结果回给 Java 壳」这个反方向,工作量最大、且依赖一个从未部署过的 Dify 生成能力内测不碰。其二后端生成主线、§6.1 job 契约、生产 runtime 一律不动——形态 A 的全部价值恰恰建立在「什么都不改就能被驱动」上,一旦要改后端就说明选错了形态。其三,把 dify 产物送进 game-cloud 的发布与 feed这需要一个能在后端建 task 记录的服务间触发面,属于形态 B见 §3.3 的论证。其四,把 dify 的试验性生成消耗计入任何真实创作者的额度账,见 §3.4。
## 3 方案
### 3.1 拓扑dify 顶替执行器的「组 job」角色
现行生产链路里,一次便宜档生成是这样流动的:用户在 studio 提交创意 → 后端建 `game_aigc_task` 记录、进异步队列 → `AigcGenerateExecutor` 认领任务、组 §6.1 job → 投给 cheap-worker `:9501/generate` → worker 入队返 202 → 后台线程调 cheap Service `:8300/chat` 跑 agentscope → 产物落 `game_dir`、并把 result-out 按 `callback.target` HMAC 回发 `/dify/callback-internal` → 后端三表事务落包、进发布与 feed。
形态 A 只替换这条链的**最前一段**:把「后端建 task + 执行器组 job」换成「dify 的 HTTP 节点组 job」。从 worker 往后入队、agentscope 生成、落盘一字不动。差别只在两处而这两处正是本设计要解决的真问题dify 组的 job 没有对应的后端 task 记录决定了回流落点§3.3dify 走的不是用户的计费门决定了额度接缝§3.4)。
一个容易忽略但已由读码坐实的事实cheap-worker 的生成核心(默认 `run_fn` = 驱动 cheap Service `/chat`)真正消费的 job 字段只有 `brief``gameId``cheap_service_driver.py:226,229``renderCtx`/`template_schema`/`banned_list`/`findings` 在便宜档生成路径里**并不被读取**(后端组它们是为 SAA 图路与未来引擎线预留)。这把 dify 节点要造的 job 大幅简化——dify 不必去复刻后端 `promptResourceLoader` 那套模板 schema 装配,只要 `brief` 有内容、`templateId``generic``gameId`/`traceId` 给一个 dify 自造的唯一值,生成就能跑。其余 §6.1 字段按契约给合理默认即可worker 不会因为缺它们而失败。
### 3.2 游戏开发节点:一个 HTTP Request 节点组 §6.1 job
「游戏开发节点」就是 dify workflow 里的一个 HTTP Request 节点。它的三要素——目标、请求体、鉴权与超时——如下。
**目标 URL**`http://<便宜档生成栈内网 IP>:9501/generate`。dify 跑在 mini-infra`100.64.0.8`cheap-worker 在 dev 阶段通常跑在 mini-desktop`100.64.0.7`)或与生成栈同机;两机同在 Tailscale 网内dify 的 HTTP 节点经 Tailscale 直连 worker `:9501`。**内网直连必须绕过系统代理**(本项目反复踩的坑:系统代理 fake-ip 会把 `100.64.x` 劫走dify 的 HTTP 节点若经其 sandbox 出网,需确认 sandbox 的 `NO_PROXY` 覆盖目标 IP否则连不上。
**请求体**:一份 §6.1 job。字段映射见下表这是复用既有契约不是新增契约
| §6.1 job 字段 | 形态 A 取值 | 来源 / 说明 |
|---|---|---|
| `brief` | dify workflow 输入的用户一句话创意 | dify「开始」节点的输入变量映射进来worker 生成核心真正消费 |
| `gameId` | dify 自造唯一串,建议 `dify-<workflow_run_id>` | worker 用作 `game_dir` 目录名与 OTLP conversation.id形态 A 下无后端含义 |
| `traceId` | 与 `job_id` 同值,建议 `dify-<uuid>` | worker 以 `traceId`(缺则 `job_id`)为幂等/追踪键;**必须加 `dify-` 前缀**做命名空间隔离,见 §3.5 |
| `job_id` | 同 `traceId` | 贯穿 job↔result 的键 |
| `templateId` | `"generic"` | worker `result_out.py` 缺省即 `generic`,显式给以稳形 |
| `tier` | `"L1"` | 便宜档裸路,与执行器同值 |
| `kind` | `"generate"` | 单局生成 |
| `model` | dify 侧配的便宜档模型名(如 `MiniMax-M3`) | worker 侧 cheap Service 实际按自身配置取 key/model此字段透传建议与生成栈配置一致 |
| `budget` | `{"maxYuan":0.15,"maxLlmCalls":8}` | 契约字段,透传即可;**它不承载封顶**——单局真实成本上界由服务端 `cheap_budget` 固定的 ¥10 软停 / ¥15 硬地板决定(`build_cheap_breaker``rmb_hard_limit`/`soft_budget` 的调用方 override 直接 pop 掉job 里写 0.15 是死字段,别据它产生「单局最多 ¥0.15」的错觉,见 §3.4 |
| `renderCtx` | `{"template_schema":"","banned_list":"","findings":""}` 或整体省略 | **便宜档生成路径不消费**;给空壳或不给都不影响生成 |
| `idempotency_key` | 同 `traceId` | 契约字段,透传 |
| `deadline_ms` | 任务级墙钟(如 `600000`) | 与 watchdog 协同;给一个合理上限即可 |
| `callback` | 见 §3.3 决策 | 形态 A 默认**不设**(a-min);若走 a-dify 则指 dify 侧捕获入口 |
**鉴权与超时**worker 的 `/generate` 当前对入站不做鉴权(信任边界靠内网不可外达,见 §3.5)。超时要分清两段——`/generate` 是**异步投递面**worker 入队后**立即返 202**`{accepted,job_id,traceId,queued}`),真正的 agentscope 生成在后台线程跑、可能几十秒到几分钟。所以 dify HTTP 节点的超时设成**握手级**(约 10 秒足够,只等 202/503**绝不能**把它设成等生成完成的长超时——生成结果不会从这个 HTTP 响应回来,它经回调或落盘回流。队满时 worker 返 503dify 节点据此快速失败。
### 3.3 回流落点dify 生成的游戏走哪条回流(核心难点)
这是形态 A 唯一的真设计难点。worker 生成完会按 job 里的 `callback.target` 把 result-out HMAC 签名发出去。生产链路里这个 target 指向后端的 `/dify/callback-internal`,后端在那里做三表事务落包、进发布与 feed。但形态 A 下 job 是 dify 自造的,`traceId` 是 dify 编的,**后端根本没有一条 `game_aigc_task` 记录与之对应**。
这不是猜测,是读码坐实的硬事实:`/dify/callback-internal` 复用的唯一写入路径 `DifyCallbackTxService.handleCallbackTx` 第一步就是 `aigcTaskMapper.selectByTraceId(reqVO.getTraceId())`,查不到任务直接抛 `AIGC_TASK_NOT_EXISTS`(错误码 `1-101-000-000`)、零写入拒绝(`DifyCallbackTxService.java:131-135`)。也就是说,即便 dify 把 `callback.target` 指向后端回调口、HMAC 也签对了,后端也会因为「没有这条 task」而拒绝产物根本进不了发布与 feed 流程——更不用说落包还依赖 `creatorUserId` 等前置,这些在形态 A 下压根不存在。
于是回流落点只有三个可能,各自的结局清晰可判:
```mermaid
flowchart TB
W["worker 生成完<br/>product 已落 game_dir"] --> Q{"callback.target?"}
Q -->|"省略 (a-min · 推荐)"| A["worker 跳过回调<br/>产物留在 game_dir 磁盘<br/>= 落点"]
Q -->|"指向 dify 捕获入口 (a-dify)"| B["第二条 dify workflow<br/>接住 result-out<br/>= 产物回 dify 自己消费"]
Q -->|"指向后端 /dify/callback-internal (a-backend)"| C["handleCallbackTx<br/>selectByTraceId 未命中<br/>→ AIGC_TASK_NOT_EXISTS 拒<br/>= 不进 feed,仅能验 HMAC 接线"]
style A fill:#dcfce7,stroke:#16a34a
style C fill:#fee2e2,stroke:#dc2626
```
**推荐内测默认 = (a-min):省略 `callback.target`,产物落在 worker 的 `game_dir`。** 依据同样是读码坐实的worker 的 `process_job` 里,生成核心 `run_fn(job)` 返回 `(summary, game_dir)`——产物**无论是否有回调,都已经写进 `game_dir` 这个磁盘目录**`worker_service.py:229`);而当 `callback.target` 缺省时worker 明确「job 无 callback.target跳过回调」`worker_service.py:251-256`)。所以省略回调不会丢产物,只是不往外发。这条路零额外部件、零后端触碰,落点就是 worker 输出目录 `games/amgen-<gameId>/` 里那份 `src/` 源工程最适合内测证明「agentscope 能被 dify 驱动」这唯一命题。但「落点可见」不等于「生成成功」——真成功判据是 worker 收口那行 `status=succeeded`§5 有专门取证点),别把「`ls src/` 非空」当成功证据scaffold 会先克隆 `_template`)。
**可选更完整形态 = (a-dify)`callback.target` 指向 dify 侧的一个捕获入口**dify 里再建一条以 HTTP 触发的小 workflow专门接住 result-out 存进 dify 的变量/日志)。这才字面兑现了「产物回 dify 自己消费」。它比 a-min 多一处 dify 接线,但仍然零后端改动。若创始人希望「闭环留在 dify 内」,走这条;否则 a-min 更省。走 a-dify 时因为要用回调HMAC 密钥两端非空的约束就必须落实§3.5)。
**明确否决 = (a-backend):把 `callback.target` 指向后端 `/dify/callback-internal`。** 结局已如上——`selectByTraceId` 未命中、`1-101-000-000` 拒、产物不落包、不进 feed。它唯一的用处是烟测「HMAC 接线是否打通」(看后端返 401 还是走到 `AIGC_TASK_NOT_EXISTS` 的业务码能区分「签名对不对」与「task 在不在」),但它**不是一条回流路径**,不能拿它当 demo 的落点。
**要真进 feed 怎么办(形态 B内测不做**:得补一个「服务间免登录、且能建 task 记录」的触发面——要么让 dify 先调一个后端的「代 dify 建 task」接口拿到一条真 `game_aigc_task`(含 `creatorUserId` 等前置)再触发生成,要么把回调面扩成「无 task 时按 dify 来源新建 task 再落包」。两条都在打开新的服务间信任面(免登录建记录 = 攻击面),且要处理创作者归属、租户、审核等一连串前置,工作量与风险都显著上升,接近形态 B。内测的价值命题不需要它故钉死在范围外。
**边界(写死)**:形态 A 的 dify 产物**不进 game-cloud 的发布与 feed****不产生 `game_aigc_task` 记录****不产生创作者归属**。它是一条自证「生成线可被 dify 驱动」的独立通道,产物消费者是 dify 与运维本人。
### 3.4 额度接缝:不扣个人额度,用 dify 专用 token
形态 A 绕过了后端的任务表与计费门,也就绕过了 WU2 那套「new-api 按用户扣额度」的入口。这既是它的便宜之处,也是必须显式管住的一条缝:**dify 路的模型消耗默认不计入任何用户的个人额度账**。
worker 往后的生成实际调的是 new-api`100.64.0.8:3000`)来跑 LLM这笔钱一定会花。问题只是记在谁头上。形态 A 下没有「用户」这个主体,所以:
- **隔离靠的是「给 dify 起一套专属的 cheap-worker/cheap Service 实例、其进程 `NEWAPI_KEY` 环境变量 = dify 专用 token」不是靠 job 自带 token。** 这一条必须写死因为读码坐实driver 的 key 从进程级 env 取(`cheap_service_driver._resolve_key()``client.get_api_key()``NEWAPI_KEY`job 里根本没有任何 token/key 位。所以**切勿试图从 dify 节点透传 token**(做不到),也别以为「同一个共享 worker 能按 token 自动区分 dify 与真实用户」(它只有一把进程 env key认不了 job 级来源。dev 内测本就是「mini-desktop 单一 dev 生成栈」,那台栈本就 dify 专用,所以天然满足;但机制要讲清,否则会误导实现者去 job 里加 token。
- **与 WU2 的硬冲突要点破**WU2 拟给 job 补 `userToken` 位、让 worker 按用户取 key 做 per-user 计费。这与「dify 专用进程 env key」在**同一栈上**是互斥的——同一个进程只有一把 env `NEWAPI_KEY`dify 路和真实 create 路一旦共栈就会共用同一把 key隔离失效。所以二选一并写死要么为 dify 起**独立**的 cheap Service 实例(自带专用 env key且不承接 create 路),要么等 WU2 的 `job.userToken` 落地后由 worker 按 job 取 token届时 dify 路给一个专用 userToken。内测按前者dev 单一 dify 栈天然独立),生产转常设入口时再对齐 WU2。
- **成本上界要按真实值讲,别被 job.budget 的 0.15 骗**:单局真实成本上界 = 服务端固定的 ¥15 硬地板(软停 ¥10、硬地板 ¥15`cheap_budget` fail-closedjob.budget 不封顶,见 §3.2**不是 ¥0.15**——差了约 100 倍。所以 dify 路真正的额度兜底只有一条:给这个 dify 专用 new-api token 配一个 new-api 侧的用量配额上限。按 ¥15/局的上界估,若给该 token 配 ¥150 日额,理论最坏也就 ~10 局/日会触顶保护,内测反复触发跑不爆真实用户账、也跑不穿这个封顶。
这个 dify 专用 token 属于凭据实施落地时写进《内网凭据与端点》canonical不进 env var、不散在聊天里。
### 3.5 安全:内网信任边界 + HMAC 两端非空
形态 A 新增/复用的面有两处受信要讲清。
**dify → worker 的入站面(`/generate`**worker 当前对 `/generate` 不做鉴权,信任边界完全靠「内网不可外达」。这里有一个必须说清的现状:**worker 进程无法只绑内网地址**——`main()` 硬编码 `start_server(host="0.0.0.0")``worker_service.py:570`)、且不提供 `--host` 参数,实际绑所有网卡。所以「别把 `:9501` 暴露到公网网卡」不是一个进程自带的旋钮,真正的防护只能落在**主机层**:让 `:9501` 所在机器没有公网可达面Tailscale-only / NAT 之后 / 主机防火墙只放行 `100.64.x``:9501`)。这与后端 `/dify/callback-internal` 早期 3b-A「仅内网可达」是同一套兜底逻辑。在 mini-desktop 这类 NAT 后的开发工作站上内测够用,但要认清风险面:一个零鉴权、会真花 new-api 钱的生成入口若绑到 `0.0.0.0` 又暴露在可达网段,就是一个成本型 DoS 面(局域网内任何机器都能反复触发烧 token所以「内网不可外达」目前是一条**必须由主机网络隔离兑现的假设**、不是进程自带属性。不为形态 A 单独给 worker 加鉴权(加了也是重复造 §3.3 已论证不该碰的服务间信任面);若确要进程级绑定控制,需给 `worker_service``--host`,那超出本档「后端零改」边界,列为 follow-up。验收侧建议实测一条从非 Tailscale 来源打 `:9501` 应不可达。
**worker → 回流的 HMAC仅当用回调即 a-dify / a-backend 烟测)**worker 用 `CALLBACK_SECRET` 对回调**原始 body 字节**算 HMAC-SHA256、置头 `X-Callback-Signature``worker_service.py:82-105`);接收端以同密钥重算、常数时间比对。这里有一条必须守死的红线,和 facade 收口口径一致:**HMAC 密钥必须两端非空**。读码坐实的隐患是——`CallbackSignatureVerifier.enabled()` 判定「密钥非空即启用」,而 `verify()` 在**未启用(密钥空串)时直接返回 true 放行**`CallbackSignatureVerifier.java:63-78`worker 侧同理,`--callback-secret` 空则**不签**。两端只要有一端空,验签就整体关闭,回退成「裸 @PermitAll 仅靠内网」——签名这层等于没有。所以走 a-dify或用 a-backend 烟测接线worker 的 `--callback-secret` 与接收端的密钥**都必须配成同一个非空值**且实施时把「两端非空」作为一条可机器验的启动断言worker 启动日志会打「已启用/未启用」,接收端同样)。走 a-min无回调时这条不适用因为根本没有回调面。
还有一条密钥代价要提醒a-backend 烟测的接收端是**生产后端**,它验签用的是生产 `aigc.executor.callback-secret`。若为了这个烟测把生产密钥复制进 dify 生成栈的 worker就扩大了该密钥的存放/泄漏面——一旦 dify 栈被攻陷,攻击者能用它对任意 result-out 签名投递。所以要做 HMAC 接线自证,优先用 a-difydify 侧捕获入口自有一套隔离密钥、不碰生产),或用一个临时/隔离密钥做接线验证,别为一次烟测把生产 callback-secret 搬进生成栈。
**命名空间隔离(防串号)**dify 自造的 `traceId` 一律加 `dify-` 前缀。这不是洁癖——万一 dify 的 traceId 和后端某条真 `game_aigc_task` 的 traceId 撞上,且回调又误指了后端口,`selectByTraceId` 就可能命中一条真任务、把 dify 的产物错误地驱动进某个真实用户的发布流程。前缀 + 不指后端口,双保险堵掉这个串号面。
### 3.6 命名血缘澄清:现行依赖里没有 Dify
后端源码里有 `DifyCallbackServiceImpl``DifyCallbackTxService``/dify/callback-internal` 这一串带「Dify」字样的名字极易让人误以为 Dify 是现行生成依赖。读码坐实的事实相反:这两个类的源码注记(`DifyCallbackServiceImpl.java:40``DifyCallbackTxService.java:53`)都明写——「真实 Dify/OpenGame 已降级远期、从未部署;本类是现行 M-b agent 闭环写链的内外层入口真在用类名『Dify』仅历史勿据类名推断 Dify 为现行依赖」。现行后端生成线是 agentscope这些「Dify」只是历史命名的化石。
契约 #6`dify-workflow-io.json`同理它描述的是一个「Java 壳 → Dify 六节点 workflow → Java 壳」的旧方向 I/O从未落地。本设计的形态 A **不使用**这份契约的形状——形态 A 走的是 §6.1 job/result-out 这份真在用的契约dify 只是它的一个新调用方。读本档的人要清楚:这次是**第一次真的把一个 dify 实例接进来**,接法却刻意避开了那个历史的「后端调 Dify」方向反而让 dify 站到「调用生成线」的位置上。名字的巧合不代表血缘的延续。
### 3.7 外部交互的失败、超时、重试、幂等、补偿
形态 A 牵动 dify、cheap-worker、cheap Service、new-api 四个外部/异步面,逐一交代它们的异常处置。
**dify HTTP 节点 → worker `/generate`**:超时设握手级(约 10s只等 202/503。worker 不可达 → dify 节点连接失败 → workflow 快速失败(不是静默卡住)。队满 → worker 返 503 → dify 节点据非 2xx 失败。重试与幂等这里要把方向讲对读码坐实worker 的 `try_enqueue` **按 `job_id`(缺则 `traceId`)在进程级 `_seen` 集去重**——同键重投直接返 `(True,202)`、**不重复入队、不重复生成**`worker_service.py:192-204`,函数 docstring 写死「同 job_id 重投→(True,202) 幂等不重入」)。因为 §3.2 强制 dify 令 `job_id`=`traceId`=`dify-<uuid>` 同值,所以**dify 侧对同一个 job 配 HTTP 重试是安全的**——重试复用同一 `job_id`,会被内置去重折叠成单次生成,不会重复烧 token。真正的 gotcha 是反向的两点,要在 demo 记录里标注:其一,`_seen` 是进程级、**永不淘汰、worker 重启即清空**;其二,正因去重,一次**真正的重新生成必须换一个新的 `job_id`/`traceId`**,否则被静默去重(照返 202、但不产新产物——所以实现者别为省事让每次重试新造 uuid那才会造出「重复生成」也别以为「同 job_id 再 POST 一次能拿到一份新产物」。
**worker → cheap Service `/chat``:8300`**这段是既有生成路径worker 内部已有处置——生成异常会走兜底发 `failed` result-out`worker_service.py` 的异常兜底路径),避免任务挂在 RUNNING。cheap Service 不可达或生成失败 → worker 产 `failed` 结果a-min 下体现为 game_dir 里没有成功产物 + worker 日志报失败)。这段无需形态 A 额外设计,沿用现货。
**worker → new-api`:3000`**:由 cheap Service 内部承载超时与失败key 失效/额度耗尽 → 生成失败 → failed result-out。形态 A 的额度接缝§3.4)已把这条的账隔离到 dify 专用 token。
**worker → 回流(仅 a-dify/a-backend**`post_callback` 是发一次、失败记日志不重试fire-once。a-min 无此面。a-dify 下若 dify 捕获入口不可达,产物仍在 game_dir落盘先于回调只是 dify 侧没接住——降级为 a-min 的可见性,不丢产物。
**失败可见性a-min 的固有盲区,必须显式声明)**a-min推荐默认、无回调dify 的 HTTP 节点在 worker 入队那一刻就拿到 202 返回并把 workflow 判为成功,之后 agentscope 的真实结果**完全不回流给 dify**。所以一旦 new-api 额度耗尽 / key 失效 / cheap Service 生成失败worker 只会打一行 `status=failed` 的收口日志、产物为空,而 **dify 工作流对此彻底不可见、没有任何用户可读的拒因**。换句话说 **a-min 下「dify 节点返 202」= 已受理,绝不等于「生成成功」**;失败只能靠人去 `ls game_dir` / `tail` worker 日志发现。这是 a-min 换来的极简所付的代价,内测取证时人肉盯日志可接受。若要让失败在触发点可见(比如把 WU2 的 `quota_exhausted` 这类干净拒因透传给 dify必须走 a-dify——把回调指向 dify 侧捕获入口,让 result-out 的 `status`/拒因回到 dify 能读的地方;届时可把 a-dify 提为 demo 取证的必选,而非仅可选。
**补偿语义**:形态 A 不引入跨系统事务,产物落盘是幂等的(同 game_dir 覆盖写。唯一需要人管的补偿是「dify 被误触发多次」时清理多余 game_dir属运维动作非自动补偿。
## 4 步骤计划
形态 A 的落地几乎全在 dify 侧配置与一次运维接线,代码物随阶段 1实施本档只到设计。分三步。
**第一步 · 生成栈就位并可被内网直连**。在 dev 生成机(如 mini-desktop拉起 **dify 专属的** cheap-worker`:9501`)与 cheap Service`:8300`,其进程 `NEWAPI_KEY`=dify 专用 token与真实 create 路隔离,见 §3.4),并确认该机无公网可达面(`:9501` 硬编码绑 `0.0.0.0`、无 `--host`,只能靠主机 Tailscale-only/NAT/防火墙收口,见 §3.5)。交付物 = 两个进程在跑、`:9501/generate``:8300/chat` 仅 Tailscale 内网可达。验证 = 从 dify 所在的 mini-infra 上 `curl --noproxy` 打一个最小 job 到 `:9501` 拿到 202并实测非 Tailscale 来源打 `:9501` 不可达。依赖 = 生成栈现货、new-api 专用 token。风险 = 系统代理劫持内网 IP`NO_PROXY`/`--noproxy` 绕过)。
**第二步 · dify 建最小 workflow + 游戏开发节点**。在 dify`:18080`)里建一条 workflow一个开始节点收 `brief` 输入 → 一个 HTTP Request「游戏开发节点」按 §3.2 映射组 §6.1 job、POST 到 `:9501/generate`(超时握手级、`traceId``dify-` 前缀、`callback` 按 §3.3 决策默认省略)。交付物 = 一条可运行的 dify workflow其定义 DSL 导出留痕(建议存 `spikes/` 或本档 assets供可复现。验证 = 在 dify 里手动跑一次,节点拿到 202。依赖 = 第一步就位。风险 = dify sandbox 出网未绕代理(同第一步)。
**第三步 · 端到端取证 + 边界与凭据回写**。跑通 dify → worker → agentscope → game_dir 全链取证§5。收口把 dify/worker/Service 端点与 dify 专用 token 回写《内网凭据与端点》,把 dify workflow DSL 留痕。交付物 = 一份真机取证记录 + 凭据回写。验证 = §5 验收门全绿。依赖 = 前两步。风险 = 无回调时忘了看 game_dir落点提醒
## 5 验证方式
内测最小可行判据只有一条链:**从 dify workflow 触发一次 → cheap-worker 跑 agentscope → 拿到生成产物**。它可真机取证,取证点逐条可机器验或肉眼可查:
1. **触发**:在 dify`http://100.64.0.8:18080`)运行 workflow输入一句 brief。dify 侧显示游戏开发节点返回 202、body 含 `{"accepted":true,"job_id":"dify-...","traceId":"dify-...","queued":N}`。**注意 202 只证明「已受理」,不证明「生成成功」**——它甚至可能是同 `job_id` 重投被去重返的 202此时 `queued` 不增、无新生成§3.7);真成功证据在第 4 步。所以每次要真生成必须换新 `job_id`/`traceId`
2. **收单**cheap-worker 日志出现 `收到 job trace_id=dify-..., templateId=generic, gameId=dify-... → 受理(202)``worker_service.py:544` 那行。这是「dify 的 job 确实到了 worker」的直接证据。
3. **真跑 agentscope**worker 日志显示后台线程驱动 cheap Service 生成(`:8300` 各 POST生成过程有 LLM 调用痕迹cheap Service 侧日志对应产出。
4. **生成成功(不是「产物可见」,别踩假绿)**:成功判据用 worker 的真状态信号,**不看 `src/` 是否非空**——因为 scaffold 会在真生成前先把 `_template/src`(实测 7 个文件:`assets/core/game-logic/game/host-config/main/render.js`)克隆进 `game_dir`,一次 scaffold 成功但生成失败的 run 同样「`src/` 非空、多文件」单看它区分不出成败。可用的成功证据有三任一即可①grep worker 收口日志到 `status=succeeded``process_job``worker_service.py:254/256` 那两行本就打 `status={succeeded|failed}`);②核 `_wg1-gen/<gameId>/evidence/verdict.json` 九门 pass③核 `game_dir/src/game-logic.js` 已偏离 `_template` 基线agent 真改过游戏本体)。产物落点目录是 **`game-runtime/games/amgen-<gameId>/src/`**`cheap_run.game_dir``games/amgen-<id>/`dify 令 `gameId=dify-<run_id>` 时实际目录是 `amgen-dify-<run_id>/``gameId` 已含 `dify-` 前缀,别按字面去找 `dify-<gameId>`。a-dify 下,额外在 dify 捕获入口看到 result-out 被接住、其 `status` 可读。
5. **边界反证(可选但推荐)**:确认后端**没有**新增 `game_aigc_task` 记录、feed 里**没有**这款 dify 产物——反证「不进 feed」这条边界成立。若走了 a-backend 烟测,确认后端返 `1-101-000-000`task 不存在)而非 401反证「HMAC 接线通、但产物如设计般被拒于 feed 外」。
这条链任一环断了都能定位到具体环节202 没拿到 = dify→worker 网络/代理;收单没日志 = worker 没跑/端口错;`status=failed` 或产物只剩 `_template` = cheap Service/new-api 失败——但要认清a-min 下这套定位**全靠运维在生成栈这侧看 worker 日志/ls 目录dify 那侧对失败是盲的**§3.7 失败可见性)。走通即「内测前支持 dify workflow」成立。
## 6 风险与回滚
**回滚**:形态 A 是纯增量、后端零改动,回滚只需在 dify 里停用/删除那条 workflow生成栈照常服务生产链路无任何后端状态需要还原。这是形态 A 相对形态 B 最大的安全优势。
**主要风险**:其一,内网代理劫持——`100.64.x` 被系统代理 fake-ip 劫走导致连不上,处置是 dify sandbox 与运维 curl 一律 `NO_PROXY`/`--noproxy`。其二回调面若误配a-backend 或 a-dify 密钥单端空)——处置是钉死「不指后端口 + HMAC 两端非空 + traceId 带 `dify-` 前缀」,并把两端非空做成启动断言。其三,入站面无鉴权 + `:9501` 硬编码绑 `0.0.0.0`——处置是靠主机层网络隔离Tailscale-only/NAT/防火墙)兜住,验收实测非 Tailscale 来源打 `:9501` 不可达§3.5。其四dify 反复触发烧 new-api 专用 token——量级要按真实值算单局成本上界是服务端固定的 ¥15不是 job.budget 的 0.15),唯一真实兜底是给该专用 token 配 new-api 侧用量配额(如 ¥150/日 ≈ 最坏 10 局触顶);注意同一次 POST 的 HTTP 重试会被 worker 按 `job_id` 去重折叠、不会重复烧钱,真正的多次生成只发生在「每次换新 id」时。其五读者误把「Dify」类名当现行依赖——处置见 §3.6,本档已把命名血缘讲清。
**已知限制**worker 的去重是**进程级 `_seen`、永不淘汰、重启即清空**——同 `job_id`/`traceId` 重投会被静默去重(返 202 但不产新产物),一次真正的重新生成必须换新 id§3.7)。这不是「会重复生成」的限制,恰恰相反是「同 id 只产一次」,实现 dify 重试时据此复用同一 `job_id` 即可,无需在形态 A 里对生成主线动任何去重逻辑。
## 附 图清单与状态
| 图 | 位置 | 角色 | 状态 |
|---|---|---|---|
| 图1 拓扑总览 | §0 | Mermaid 事实源 | 随文本 |
| 图2 回流落点三选项决策 | §3.3 | Mermaid 事实源 | 随文本 |
| 图3 触发-生成-回流(隐含于图1+§3.7 文字) | §3.7 | 文字为主 | 随文本 |
| 图4 做与不做边界 | §2 | Mermaid 事实源 | 随文本 |
| §0 门面 SVG 概览 | §0 | 派生视觉(给人看) | 待收口图层子代理据图1 派生 |
> 图文冲突以文字为准SVG 收口时由图层子代理据 §0/图1 派生house style 照 architecture-diagram-atlas。本档收口需过 Codex + Opus 双评审Codex 不可用回落 Opus 单评),并按 frontmatter `sot-impact` 在实施落地后回写《内网凭据与端点》。

View File

@ -21,7 +21,10 @@ public enum FailureReasonEnum {
CONFIG_INVALID("config_invalid", "配置校验失败"),
LLM_ERROR("llm_error", "LLM 异常"),
TIMEOUT("timeout", "超时"),
ASSET_GEN_FAILED("asset_gen_failed", "素材生成失败");
ASSET_GEN_FAILED("asset_gen_failed", "素材生成失败"),
// WU2 §3.9new-api per-user 额度耗尽网关 402/403或派发期池空懒 claim 未果干净失败给可读拒因
// 区别于含糊的 llm_error跨契约共享枚举同批已加 aigc.yaml FailureReason.enum dify-workflow-io.json output.failureReason.enum
QUOTA_EXHAUSTED("quota_exhausted", "生成额度已用完");
/** 失败原因码(落库 varchar对齐契约枚举值 */
private final String reason;

View File

@ -0,0 +1,62 @@
package com.wanxiang.huijing.game.module.aigc.dal.dataobject.quota;
import com.wanxiang.huijing.framework.tenant.core.db.TenantBaseDO;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* new-api per-user 额度池条目 DO对应表 newapi_quota_poolWU2 §3.5
*
* 池表存的是预建条目而非一玩家一映射行离线 ops 脚本
* game-runtime/tools/newapi_pool_provision.py预建 N (new-api user + token + ¥100) 按条目 INSERT
* status=FREEclaimed_* 注册/ claim 时某条被 CAS 抢占翻转 CLAIMED 并绑 game_player
*
* 继承 {@link TenantBaseDO}自带 creator/create_time/updater/update_time/deleted + tenant_id 审计/租户列
* 池是系统级资源运行时 claim/查询经 {@code TenantUtils.executeIgnore} 跨租户存取tenant_id 值仅占位
* ops 脚本 raw INSERT 只给业务列审计/租户列走 DB 默认落值
*
* 账本口径唯一 = new-api 网关权威余额=token remain_quota权威消耗=user used_quota DO
* {@link #grantQuota} 只是审计快照game-cloud 侧不记余额
*
* 幂等三键仿 {@code GrantDO} uk_biz_nouk_newapi_user脚本去重/ uk_claimed_by一玩家至多一条 CLAIMED/
* uk_biz_noclaim 幂等键 claim_&lt;gamePlayerId&gt;并发/重投由 status='FREE' 单条 CAS + NULL 唯一键三重收敛
*
* @author 绘境AI
*/
@TableName("newapi_quota_pool")
@Data
@EqualsAndHashCode(callSuper = true)
public class NewapiQuotaPoolDO extends TenantBaseDO {
/** 池条目主键 */
private Long id;
/** 预建的 new-api users.id脚本灌数去重键 uk_newapi_user */
private Long newapiUserId;
/** 预建的 new-api tokens.id */
private Long newapiTokenId;
/** new-api tokens.key48 位 sk-…;⚠ 敏感,随 job 下发须脱敏,永不整条落日志) */
private String newapiTokenKey;
/** 该条目预置的 ¥100 折算 quota审计快照权威余额以网关 token.remain_quota 为准) */
private Long grantQuota;
/** 折算时网关 quota_per_unit默认 500000审计追溯用 */
private Long quotaPerUnitSnapshot;
/** 折算时 usd_exchange_rate默认 7.3,审计追溯用) */
private BigDecimal usdRateSnapshot;
/** 状态FREE/CLAIMED/INVALID{@link com.wanxiang.huijing.game.module.aigc.enums.NewapiQuotaStatusEnum}claim CAS 抢占锚 */
private String status;
/** claim 后 ↔ game_player.idFREE 时 NULLuk_claimed_by 允许多 NULL */
private Long claimedByGamePlayerId;
/** claim 时间CLAIMED 时回填) */
private LocalDateTime claimedAt;
/** claim 幂等键 claim_&lt;gamePlayerId&gt;FREE 时 NULL */
private String bizNo;
/** 预留claim/隔离重试计数 */
private Integer retryCount;
/** 备注(隔离原因等) */
private String remark;
}

View File

@ -0,0 +1,69 @@
package com.wanxiang.huijing.game.module.aigc.dal.mysql.quota;
import com.wanxiang.huijing.game.module.aigc.dal.dataobject.quota.NewapiQuotaPoolDO;
import com.wanxiang.huijing.game.module.aigc.enums.NewapiQuotaStatusEnum;
import com.wanxiang.huijing.framework.mybatis.core.mapper.BaseMapperX;
import com.wanxiang.huijing.framework.mybatis.core.query.LambdaQueryWrapperX;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
import java.time.LocalDateTime;
/**
* new-api 额度池 MapperWU2 §3.5
*
* <p><b>跨租户存取铁律</b>池是系统级资源注册消费/派发线均无租户上下文调用方{@code NewapiQuotaServiceImpl}
* 必须用 {@code TenantUtils.executeIgnore(...)} 包裹本 Mapper 的每次调用否则多租户插件会按空租户上下文过滤致查不到条目
*
* <p><b>claim 幂等靠 CAS + 唯一键</b>{@link #claimOneFree} 是单条 {@code FREECLAIMED} 行级 CAS并发/重投由
* {@code status='FREE'} 条件 + uk_claimed_by + uk_biz_no 三重收敛任何路径不给同一玩家占第二条不让两玩家抢同一条
*
* @author 绘境AI
*/
@Mapper
public interface NewapiQuotaPoolMapper extends BaseMapperX<NewapiQuotaPoolDO> {
/**
* 按已绑玩家取其 CLAIMED 条目 claim 前查 / 派发取 tokenuk_claimed_by 保证至多一条
*
* @param gamePlayerId game_player.id
* @return CLAIMED 条目未绑返回 null
*/
default NewapiQuotaPoolDO selectClaimedByPlayer(Long gamePlayerId) {
return selectOne(new LambdaQueryWrapperX<NewapiQuotaPoolDO>()
.eq(NewapiQuotaPoolDO::getClaimedByGamePlayerId, gamePlayerId)
.eq(NewapiQuotaPoolDO::getStatus, NewapiQuotaStatusEnum.CLAIMED.getStatus()));
}
/**
* 池水位FREE 条目计数低于阈值触发补池告警§3.4
*
* @return 当前 FREE 条目数
*/
default Long countFree() {
return selectCount(new LambdaQueryWrapperX<NewapiQuotaPoolDO>()
.eq(NewapiQuotaPoolDO::getStatus, NewapiQuotaStatusEnum.FREE.getStatus()));
}
/**
* 原子 CAS抢一个 FREE 条目翻转 CLAIMED 并绑玩家§3.4
*
* <p>双层派生表 {@code (SELECT id FROM (SELECT id ... LIMIT 1) t)} MySQL不能 UPDATE 又在 FROM 子查询引用同表
* 的标准物化绕法{@code AND status='FREE'} 再做一次行级 CAS 兜底并发返回1=抢到0=池空子查询 NULL 无命中
* 显式带 {@code deleted=0}raw @Update 不经多租户/逻辑删自动改写调用方 executeIgnore 已免租户过滤
*
* @param gamePlayerId 绑定的 game_player.id
* @param bizNo 幂等键 claim_&lt;gamePlayerId&gt;
* @param claimedAt claim 时间
* @return 影响行数1=抢到0=池空无 FREE 条目
*/
@Update("UPDATE newapi_quota_pool "
+ "SET status='CLAIMED', claimed_by_game_player_id=#{gamePlayerId}, claimed_at=#{claimedAt}, biz_no=#{bizNo} "
+ "WHERE id = (SELECT id FROM (SELECT id FROM newapi_quota_pool WHERE status='FREE' AND deleted=0 ORDER BY id LIMIT 1) t) "
+ "AND status='FREE' AND deleted=0")
int claimOneFree(@Param("gamePlayerId") Long gamePlayerId,
@Param("bizNo") String bizNo,
@Param("claimedAt") LocalDateTime claimedAt);
}

View File

@ -0,0 +1,29 @@
package com.wanxiang.huijing.game.module.aigc.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* new-api 额度池条目状态枚举WU2 §3.4/§3.5 状态机
*
* FREE 离线 ops 脚本预建的可 claim 条目CLAIMED 已被注册/ claim CAS 抢占绑玩家
* INVALID token 失效隔离S4 claim 本单只落 FREECLAIMED不产出 INVALID
* 落库列 newapi_quota_pool.statusvarchar(16)CAS 抢占以 status='FREE' 为锚
*
* @author 绘境AI
*/
@Getter
@AllArgsConstructor
public enum NewapiQuotaStatusEnum {
/** 可 claim离线预建claimed_* 空) */
FREE("FREE"),
/** 已绑玩家claim CAS 抢占后翻转) */
CLAIMED("CLAIMED"),
/** token 失效隔离S4不再派发本单不产出 */
INVALID("INVALID");
/** 落库值(对齐 DDL status 列取值) */
private final String status;
}

View File

@ -10,6 +10,7 @@ import com.wanxiang.huijing.game.module.aigc.service.executor.GameConfigSchemaVa
import com.wanxiang.huijing.game.module.aigc.service.executor.PromptResourceLoader;
import com.wanxiang.huijing.game.module.aigc.service.executor.WorkerClassifyClient;
import com.wanxiang.huijing.game.module.aigc.mq.GenTaskProducer;
import com.wanxiang.huijing.game.module.aigc.service.quota.NewapiQuotaService;
import com.wanxiang.huijing.game.module.aigc.saa.SaaGraphDispatcher;
import com.wanxiang.huijing.game.module.aigc.service.executor.GenerationDispatcher;
import com.wanxiang.huijing.game.module.aigc.service.executor.WorkerDispatchClient;
@ -178,7 +179,8 @@ public class AigcExecutorConfiguration {
WorkerDispatchClient workerDispatchClient,
SaaGraphDispatcher saaGraphDispatcher,
ObjectProvider<SourceProjectApi> sourceProjectApiProvider,
ObjectProvider<GenTaskProducer> genTaskProducerProvider) {
ObjectProvider<GenTaskProducer> genTaskProducerProvider,
ObjectProvider<NewapiQuotaService> newapiQuotaServiceProvider) {
// A11 M1软取 SourceProjectApistudio @Primary 就地解析modify 路据 baseVersionId 反查 base 源注入 §6.1 job sourceProject
// 供便宜档 HTTP worker DB 被动消费 SaaGraphDispatcher 同一 seam缺席单模块装配/studio 未在 classpath modify 取源旁路不注入 sourceProject 不阻断装配
SourceProjectApi sourceProjectApi = sourceProjectApiProvider.getIfAvailable();
@ -186,11 +188,15 @@ public class AigcExecutorConfiguration {
// stale queued重发信号交消费者重走认领+派发不在 tick 直派投递可靠性补偿 MQ 保持唯一投递路径与并发 cap 无关
// 缺席防御分支 null 兜底 tick 退回直接认领+派发韧性降级不让任务卡死
GenTaskProducer genTaskProducer = genTaskProducerProvider.getIfAvailable();
// WU2 §3.6软取 NewapiQuotaService@Service aigc 模块常在席传入使 dispatchGeneric §6.1 job 时对真实 member
// per-user 额度门取其 claim 到的 token 放进 userToken 其主开关 aigc.newapi-quota.enabled 默认 false 时对所有创作者返 null
// opt-in 才生效缺席防御分支/单模块装配 null 不接线worker 全局 key现行行为字节零变
NewapiQuotaService newapiQuotaService = newapiQuotaServiceProvider.getIfAvailable();
// SAA 迁移 #2传入 http worker + saa 进程内派发器由执行器构造按 aigc.executor.dispatcher 选用
// 默认 http=现行行为不变saa=opt-in 进程内形态
return new AigcGenerateExecutor(properties, aigcTaskMapper, difyCallbackService,
promptResourceLoader, schemaValidator, llmClient, workerDispatchClient, saaGraphDispatcher,
sourceProjectApi, Clock.systemDefaultZone(), genTaskProducer);
sourceProjectApi, Clock.systemDefaultZone(), genTaskProducer, newapiQuotaService);
}
}

View File

@ -0,0 +1,65 @@
package com.wanxiang.huijing.game.module.aigc.mq;
import com.wanxiang.huijing.game.module.aigc.mq.message.PassportRegisterEvent;
import com.wanxiang.huijing.game.module.aigc.service.quota.NewapiQuotaService;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
/**
* new-api 额度注册 claim 消费者WU2 §3.4消费 WU1注册成功事件从预置池 claim 一个 FREE 绑玩家
*
* <p><b>接缝</b>订阅 WU1 outbox 投递器的 topic {@code passport-register}生产侧常量
* {@code PassportRegisterOutboxDeliverer.TOPIC}跨模块不依赖 system-server故此处以字面量对齐 wire 契约
* 消息载荷是 JSON反序列化进本模块镜像体 {@link PassportRegisterEvent}字段与生产侧 {@code PassportRegisterMessage} 同名
*
* <p><b>装配开关</b> {@code aigc.newapi-quota.enabled=true} 装配关闭默认时不起消费者不连 NameServer
* WU2 整体未启用时零副作用 {@code NewapiQuotaServiceImpl} 主开关同源
*
* <p><b>幂等 + 不外抛</b>claim 幂等由 service uk + CAS 保证重投/并发收敛成一条池空/异常已在 service 内吞
* 池空是离线补池才能恢复 MQ 重试窗口内可恢复抛异常只灌 DLQ 空转此处防御性兜底 catch正常返回=ack不无限重投
* 消费模型 = 默认 CLUSTERING每条消息集群内单实例消费
*
* @author 绘境AI
*/
@Slf4j
@Component
@ConditionalOnProperty(prefix = "aigc.newapi-quota", name = "enabled", havingValue = "true")
@RocketMQMessageListener(
topic = "passport-register", // = PassportRegisterOutboxDeliverer.TOPICwire 契约跨模块不引 -server
consumerGroup = "newapi_quota_claim_consumer"
)
public class NewapiQuotaClaimConsumer implements RocketMQListener<PassportRegisterEvent> {
/** 额度池服务claim 落地:渠道过滤 + CAS 抢占 + 池空处置)。 */
private final NewapiQuotaService quotaService;
public NewapiQuotaClaimConsumer(NewapiQuotaService quotaService) {
this.quotaService = quotaService;
}
/**
* 消费一条注册成功事件 service 做渠道过滤 + 幂等 CAS claim
*
* @param event 注册成功事件镜像体userId=game_player.id / registerChannel 归因
*/
@Override
public void onMessage(PassportRegisterEvent event) {
if (event == null || event.getUserId() == null) {
// 毒消息体空/ userId重投无益丢弃不重投只告警留痕
log.error("[newapi-quota-mq] 注册事件体空或缺 userId丢弃不重投 event={}", event);
return;
}
try {
quotaService.claimForRegister(event.getUserId(), event.getRegisterChannel());
log.info("[newapi-quota-mq] 消费注册成功事件已处置 userId={}, channel={}", event.getUserId(), event.getRegisterChannel());
} catch (Exception e) {
// 防御性兜底service 内已吞池空/常规失败此处只兜意外异常正常返回=ack不让 MQ 无限重投风暴
log.error("[newapi-quota-mq] 消费注册事件异常(吞掉不重投,待补池后懒 claim 兜userId={}, channel={}",
event.getUserId(), event.getRegisterChannel(), e);
}
}
}

View File

@ -0,0 +1,29 @@
package com.wanxiang.huijing.game.module.aigc.mq.message;
import lombok.Data;
import java.io.Serializable;
/**
* 注册成功事件消费侧镜像体WU2 §3.4
*
* <p>WU1 的生产侧消息类 {@code PassportRegisterMessage} 落在 huijing-module-system-server-server -api
* 跨模块不依赖对方 -server守门故本模块按同名字段建一个消费侧镜像 DTORocketMQ JSON 传输载荷
* 消费端 {@code RocketMQListener<PassportRegisterEvent>} 按字段名反序列化即得与生产侧解耦topic 是唯一 wire 契约
* 字段须与生产侧 {@code PassportRegisterMessage} 逐一同名userId/registerChannel/anonId/idempotentKey
*
* @author 绘境AI
*/
@Data
public class PassportRegisterEvent implements Serializable {
/** 新注册玩家编号game_player.idclaim 幂等键) */
private Long userId;
/** 注册通道sms / invite / password渠道过滤据此 */
private String registerChannel;
/** 注册时携带的客户端 anonId本消费不使用保真镜像 */
private String anonId;
/** 幂等键(=register:{userId} */
private String idempotentKey;
}

View File

@ -8,6 +8,8 @@ import com.wanxiang.huijing.game.module.aigc.mq.GenTaskProducer;
import com.wanxiang.huijing.game.module.aigc.service.callback.DifyCallbackService;
import com.wanxiang.huijing.game.module.studio.api.SourceProjectApi;
import com.wanxiang.huijing.game.module.studio.dto.SourceProjectFetchRespDTO;
import com.wanxiang.huijing.game.module.aigc.service.quota.NewapiQuotaService;
import com.wanxiang.huijing.game.module.aigc.service.quota.QuotaPoolExhaustedException;
import com.wanxiang.huijing.framework.common.enums.UserTypeEnum;
import com.wanxiang.huijing.framework.common.exception.ServiceException;
import com.wanxiang.huijing.framework.common.pojo.CommonResult;
@ -124,6 +126,13 @@ public class AigcGenerateExecutor {
* 扫描-认领-派发行为避免任务卡死韧性降级非主路径生产经 AigcExecutorConfiguration 注入非空
*/
private final GenTaskProducer genTaskProducer;
/**
* new-api per-user 额度门 seamWU2 §3.6<b>可为 null</b>真实 member 在线创作派发时取其 claim 到的 token 放进
* §6.1 job {@code userToken} 系统/编排/bake-off game_player 旁路留空worker 回落全局 key
* <p><b>null 旧构造/单测态</b>不注入 userToken = 现行行为worker 全局 key生产经 AigcExecutorConfiguration
* 软注入非空其内部主开关 {@code aigc.newapi-quota.enabled}默认 false关时对所有创作者返 null故即便在席也零行为变更opt-in 生效
*/
private final NewapiQuotaService newapiQuotaService;
/** 自禁用连续 tick 计数(自检恢复即清零;告警节流判据) */
private int selfCheckFailTicks = 0;
@ -200,6 +209,24 @@ public class AigcGenerateExecutor {
GameConfigSchemaValidator schemaValidator, ExecutorLlmClient llmClient,
WorkerDispatchClient workerDispatchClient, GenerationDispatcher saaDispatcher,
SourceProjectApi sourceProjectApi, Clock clock, GenTaskProducer genTaskProducer) {
// 委托 12 参构造newapiQuotaService null per-user 额度门不接线现行行为worker 全局 key现行单测沿用此签名
this(properties, aigcTaskMapper, difyCallbackService, promptResourceLoader, schemaValidator,
llmClient, workerDispatchClient, saaDispatcher, sourceProjectApi, clock, genTaskProducer, null);
}
/**
* 12 参构造WU2 §3.6 收口 AigcExecutorConfiguration @Bean 调用 11 参基础上接 new-api per-user 额度门
* {@link NewapiQuotaService}真实 member 在线创作派发时取其 claim 到的 token 放进 §6.1 job {@code userToken}
* 系统/编排 game_player 旁路留空worker 回落全局 key null旧构造/单测态=不接线现行行为字节零变
*
* @param newapiQuotaService per-user 额度门 seam null=不接线其主开关 aigc.newapi-quota.enabled 默认 false 时对所有创作者返 nullopt-in 生效
*/
public AigcGenerateExecutor(AigcExecutorProperties properties, AigcTaskMapper aigcTaskMapper,
DifyCallbackService difyCallbackService, PromptResourceLoader promptResourceLoader,
GameConfigSchemaValidator schemaValidator, ExecutorLlmClient llmClient,
WorkerDispatchClient workerDispatchClient, GenerationDispatcher saaDispatcher,
SourceProjectApi sourceProjectApi, Clock clock, GenTaskProducer genTaskProducer,
NewapiQuotaService newapiQuotaService) {
// §5.2 阈值关系铁律stale×60 > budget + 273装配期校验误配置宁可启动失败也不带病收割在飞任务
properties.validateThresholds();
this.properties = properties;
@ -218,12 +245,15 @@ public class AigcGenerateExecutor {
this.clock = clock;
// C3gen 队列生产者兜底 tick 重发信号用非空=MQ 主触发 + 兜底重发信号投递可靠性补偿null=兜底退回直派单测/降级态
this.genTaskProducer = genTaskProducer;
// WU2 §3.6per-user 额度门 null=不接线现行行为在席时其主开关关默认仍旁路opt-in 才生效
this.newapiQuotaService = newapiQuotaService;
// 启动三行自检日志§12- 冒烟观察面执行器已启用 / prompt 资源版本 / key 配置态严禁打印密钥本身
log.info("[executor-selfcheck] aigc 生成执行器已启用poll={}ms, batch={}, budget={}s, stale={}min, maxAge={}h, templates={}, sourceSeam={}, 触发={}",
log.info("[executor-selfcheck] aigc 生成执行器已启用poll={}ms, batch={}, budget={}s, stale={}min, maxAge={}h, templates={}, sourceSeam={}, 触发={}, perUser额度门={}",
properties.getPollIntervalMs(), properties.getScanBatchSize(), properties.getTaskBudgetSeconds(),
properties.getStaleRunningMinutes(), properties.getMaxTaskAgeHours(), properties.getSupportedTemplates(),
sourceProjectApi != null ? "在席" : "缺席(modify取源旁路)",
genTaskProducer != null ? "MQ主触发+tick兜底重发信号" : "tick直派(无MQ生产者/单测态)");
genTaskProducer != null ? "MQ主触发+tick兜底重发信号" : "tick直派(无MQ生产者/单测态)",
newapiQuotaService != null ? "在席(opt-in)" : "缺席(worker全局key)");
// HJ-MC-TPL-EXEC-001 §6.4多模板装载后无单一版本 templateId 逐模板列出版本就绪时
log.info("[executor-selfcheck] prompt 资源版本 {}", promptResourceLoader.isReady()
? promptResourceLoader.describePromptVersions() : ("未就绪:" + promptResourceLoader.getNotReadyReason()));
@ -680,6 +710,29 @@ public class AigcGenerateExecutor {
// 便宜档 HTTP workerPython DB只被动消费 job["sourceProject"]键名同 SAA K_SOURCE_PROJECT反查失败/缺源则不放该键best-effort 非阻断
putModifyFieldsIntoJob(job, task);
// WU2 §3.6per-user new-api 额度门 §6.1 job userToken
// 身份边界只对真实 member 在线创作生效命中一行 game_player 取其 claim 到的 token_key 放进 job
// 系统/编排器/bake-off game_player 旁路留空worker 回落全局 key§3.7不误伤既有验证路
// newapiQuotaService null旧构造/单测态或主开关关 resolveUserTokenForDispatch null不注入 = 现行行为
if (newapiQuotaService != null) {
try {
String userToken = newapiQuotaService.resolveUserTokenForDispatch(task.getCreatorUserId());
if (StringUtils.hasText(userToken)) {
job.put("userToken", userToken); // §6.1 追加字段向后兼容worker .get 忽略未知键 worker 收到不炸
// 传输安全§3.8token new-api 调用凭据仅留前后 4 位入日志绝不整条打印
log.info("[executor-dispatch] per-user 额度 token 已随 job 下发脱敏taskId={}, traceId={}, creatorUserId={}, tokenMasked={}",
task.getId(), task.getTraceId(), task.getCreatorUserId(), maskToken(userToken));
}
// userToken 为空 = 系统/编排旁路正常且预期不注入不打扰日志
} catch (QuotaPoolExhaustedException e) {
// 真实 member 需要 token 但池空 claim 抢不到 当次干净失败给可读拒因§3.6 池空处置
// 绝不派一个没 token create job 下去静默烧共享额度水位告警已在 service 内触发
log.warn("[executor-dispatch] per-user 额度池空/懒 claim 未果,按 quota_exhausted 干净失败 taskId={}, traceId={}, creatorUserId={}",
task.getId(), task.getTraceId(), task.getCreatorUserId());
return callbackFailed(task, FailureReasonEnum.QUOTA_EXHAUSTED);
}
}
boolean dispatched = generationDispatcher.dispatch(job);
if (dispatched) {
// 投递成功任务留 RUNNING worker 回调终态由回调驱动不在此写tick 汇总以派发计数 succeeded/failed
@ -694,6 +747,19 @@ public class AigcGenerateExecutor {
return callbackFailed(task, FailureReasonEnum.LLM_ERROR);
}
/**
* per-user token 日志脱敏WU2 §3.8只留前后各 4 中间打码绝不整条打印 new-api 调用凭据
*
* @param token 原始 token_key可能为空
* @return 脱敏串 sk-A****Z9zK/过短返回固定占位
*/
private static String maskToken(String token) {
if (token == null || token.length() <= 8) {
return "****";
}
return token.substring(0, 4) + "****" + token.substring(token.length() - 4);
}
/**
* modify/extend 四件套契约 C3接进 §6.1 job modify 使 {@link com.wanxiang.huijing.game.module.aigc.saa.SaaGraphDispatcher}
* {@code buildInputs} 能读到 modifyMode/modifyPatch/baseVersionId 并据此分流到 SAA modify

View File

@ -0,0 +1,49 @@
package com.wanxiang.huijing.game.module.aigc.service.quota;
/**
* new-api per-user 额度池服务WU2 §3.4 注册 claim + §3.6 派发期 per-user
*
* <p>账本在 new-api 网关本服务只做两件纯 game-cloud 内部 DB 操作 new-api 外呼
* <ol>
* <li><b>注册 claim</b>{@link #claimForRegister}消费 WU1注册成功事件从预置池 CAS 抢一个 FREE 绑玩家</li>
* <li><b>派发取 token</b>{@link #resolveUserTokenForDispatch}真实 member 在线创作时取其 claim 到的 token job 下发</li>
* </ol>
*
* <p><b>主开关</b>{@code aigc.newapi-quota.enabled}默认 false关时两个入口全旁路现行行为worker 回落全局 key
* 开时注册 claim 消费者装配 + per-user 门生效这样 WU2 可整体 opt-in池未预置前不误伤既有生成/批跑验证路
*
* @author 绘境AI
*/
public interface NewapiQuotaService {
/**
* 注册 claim§3.4消费注册成功事件对种子渠道从池 CAS 抢一个 FREE 绑该玩家幂等
*
* <p>渠道过滤只对 {@code password/invite} claim{@code sms} 自动注册与 {@code sms-mock} 后门跳过
* 避免一次性手机号每次首登占掉一个 ¥100 池条目这类若真去生成由懒 claim
* <p>幂等uk_claimed_by + uk_biz_no + 单条 CAS 三重收敛重投/并发不给同一玩家占第二条
* <p>池空不抛异常补池是离线人工动作 MQ 重试窗口内可恢复抛异常只灌 DLQ 空转 WARN + 触发水位告警
* 该玩家暂不绑首次生成由懒 claim §3.6
*
* @param gamePlayerId 新注册玩家编号game_player.id
* @param registerChannel 注册通道sms/invite/password
*/
void claimForRegister(Long gamePlayerId, String registerChannel);
/**
* 派发期解析 per-user token§3.6per-user 门只对真实 member 在线创作生效
*
* <ul>
* <li>主开关关 / creatorUserId null旁路worker 回落全局 key</li>
* <li><b> game_player </b>系统/编排/bake-off/admin 触发 null旁路不查池不懒 claim</li>
* <li><b>命中 game_player </b>真实 member 取其 CLAIMED 条目的 token_key CLAIMED 则单 CAS claim 抢一个 FREE
* 抢到返 token_key池空抢不到 {@link QuotaPoolExhaustedException}当次干净失败给可读拒因 + 水位告警</li>
* </ul>
*
* @param creatorUserId 任务创作者{@code AigcTaskDO.creatorUserId}
* @return per-user token_keymember 命中/ claim 成功null旁路系统触发或主开关关
* @throws QuotaPoolExhaustedException member 需要 token 但池空 claim 抢不到
*/
String resolveUserTokenForDispatch(Long creatorUserId);
}

View File

@ -0,0 +1,181 @@
package com.wanxiang.huijing.game.module.aigc.service.quota;
import com.wanxiang.huijing.game.module.aigc.dal.dataobject.quota.NewapiQuotaPoolDO;
import com.wanxiang.huijing.game.module.aigc.dal.mysql.quota.NewapiQuotaPoolMapper;
import com.wanxiang.huijing.framework.tenant.core.util.TenantUtils;
import com.wanxiang.huijing.module.system.api.passport.PlayerApi;
import com.wanxiang.huijing.module.system.api.passport.dto.PlayerRespDTO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import jakarta.annotation.Resource;
import java.time.LocalDateTime;
/**
* new-api per-user 额度池服务实现WU2 §3.4/§3.6
*
* <p>claim 与派发均是纯 game-cloud 内部 DB 操作 new-api 外呼池是系统级资源故每次 Mapper 调用都经
* {@link TenantUtils#executeIgnore} 跨租户注册消费/派发线均无租户上下文主开关 {@code aigc.newapi-quota.enabled}
* 默认 false关时两入口全旁路 = 现行行为不误伤既有生成/批跑验证路
*
* @author 绘境AI
*/
@Slf4j
@Service
public class NewapiQuotaServiceImpl implements NewapiQuotaService {
/** 种子渠道:只对这两类 claim一次性手机号/后门跳过,避免白占 ¥100 池条目§3.4)。 */
private static final String CHANNEL_PASSWORD = "password";
private static final String CHANNEL_INVITE = "invite";
/**
* 主开关默认 false关时 claim/派发全旁路 {@code NewapiQuotaClaimConsumer} @ConditionalOnProperty
* 开则消费者装配 + per-user 门生效
*/
@Value("${aigc.newapi-quota.enabled:false}")
private boolean enabled;
/** 池水位告警阈值FREE 计数低于此值即 WARN 提示 ops 补池§3.4,默认 20。 */
@Value("${aigc.newapi-quota.water-level-threshold:20}")
private long waterLevelThreshold;
@Resource
private NewapiQuotaPoolMapper poolMapper;
/**
* 玩家身份 seam跨模块只依赖 system -api creatorUserId 是否命中一行 game_player
* 单体内由 system PlayerApiImpl(@Primary) 就地解析getPlayer 对无此行返 data=null不抛
*/
@Resource
private PlayerApi playerApi;
// ================== §3.4 注册 claim ==================
@Override
public void claimForRegister(Long gamePlayerId, String registerChannel) {
if (!enabled) {
// 主开关关WU2 未启用注册 claim 全旁路现行行为玩家生成走全局 key
return;
}
if (gamePlayerId == null) {
log.warn("[newapi-quota] 注册 claim 收到空 gamePlayerId跳过 channel={}", registerChannel);
return;
}
// 渠道过滤只对种子渠道 claimsms 自动注册 / sms-mock 后门跳过避免白占池条目真去生成由懒 claim
if (!CHANNEL_PASSWORD.equals(registerChannel) && !CHANNEL_INVITE.equals(registerChannel)) {
log.info("[newapi-quota] 注册渠道非种子渠道,跳过 claim gamePlayerId={}, channel={}", gamePlayerId, registerChannel);
return;
}
// 池是系统级资源消费线无租户上下文 executeIgnore 跨租户存取block =void Runnable 重载丢弃返回值
TenantUtils.executeIgnore(() -> {
tryClaimCore(gamePlayerId, "register");
});
}
// ================== §3.6 派发期 per-user ==================
@Override
public String resolveUserTokenForDispatch(Long creatorUserId) {
if (!enabled || creatorUserId == null) {
// 主开关关 / 无创作者 旁路worker 回落全局 key现行行为
return null;
}
// 身份门只对真实 member命中一行 game_player生效getPlayer 对无此行返 null系统/编排/bake-off/admin 触发
PlayerRespDTO player = playerApi.getPlayer(creatorUserId).getCheckedData();
if (player == null) {
// game_player 旁路不查池不懒 claimuserToken 留空worker 回落全局 key§3.6 越界不误伤
log.debug("[newapi-quota] 无 game_player 行(系统/编排触发per-user 门旁路 creatorUserId={}", creatorUserId);
return null;
}
// 真实 member取其 CLAIMED token无则懒 claim全程 executeIgnore 跨租户派发线无租户上下文
// executeIgnore(Callable) 会把内部抛的任何异常包成 RuntimeException丢失类型故池空信号用返回 null表达
// executeIgnore 外再抛 QuotaPoolExhaustedException保证 dispatchGeneric 能按类型精确捕获
String tokenKey = TenantUtils.executeIgnore(() -> {
NewapiQuotaPoolDO claimed = poolMapper.selectClaimedByPlayer(creatorUserId);
if (claimed != null && StringUtils.hasText(claimed.getNewapiTokenKey())) {
return claimed.getNewapiTokenKey();
}
// CLAIMED 条目存量 null / 注册时池空未绑 派发线单 CAS claim DB CAS非慢外呼派发热路安全
boolean claimedNow = tryClaimCore(creatorUserId, "lazy-dispatch");
if (claimedNow) {
NewapiQuotaPoolDO after = poolMapper.selectClaimedByPlayer(creatorUserId);
if (after != null && StringUtils.hasText(after.getNewapiTokenKey())) {
return after.getNewapiTokenKey();
}
}
// member 路返回 null 专表池空抢不到外部据此抛耗尽水位告警已在 tryClaimCore 内触发
return null;
});
if (tokenKey == null) {
// 池空抢不到 当次干净失败给可读拒因quota_exhausted绝不派无 token member create job 下去静默烧共享额度
throw new QuotaPoolExhaustedException(creatorUserId);
}
return tokenKey;
}
// ================== 内部幂等 CAS claim调用方须已 executeIgnore 包裹 ==================
/**
* 幂等 CAS claim 核心假定调用方已 {@code executeIgnore} 包裹
* 已绑复用 / 抢一个 FREE / 并发 dup 收敛 / 池空不抛
*
* @param gamePlayerId game_player.id
* @param scene 场景register / lazy-dispatch日志用
* @return true=该玩家已有 CLAIMED 条目既有复用 / 本次抢到 / 并发已被抢占同玩家false=池空未绑
*/
private boolean tryClaimCore(Long gamePlayerId, String scene) {
// 幂等前置已绑则 no-op 复用重投事件 / 并发天然收敛
NewapiQuotaPoolDO existing = poolMapper.selectClaimedByPlayer(gamePlayerId);
if (existing != null) {
log.info("[newapi-quota] 玩家已绑池条目claim no-op 复用 scene={}, gamePlayerId={}, poolId={}",
scene, gamePlayerId, existing.getId());
return true;
}
String bizNo = "claim_" + gamePlayerId;
try {
int affected = poolMapper.claimOneFree(gamePlayerId, bizNo, LocalDateTime.now());
if (affected == 1) {
log.info("[newapi-quota] claim 成功 FREE→CLAIMED scene={}, gamePlayerId={}, bizNo={}", scene, gamePlayerId, bizNo);
alertIfLowWater(scene);
return true;
}
// affected=0子查询无 FREE 命中 = 池空非并发并发同玩家走下方 DuplicateKeyException 分支
log.warn("[newapi-quota] 池空(无 FREE 条目),本次未绑 scene={}, gamePlayerId={}", scene, gamePlayerId);
triggerWaterLevelAlert(0L, scene);
return false;
} catch (DuplicateKeyException e) {
// 并发同玩家 claim两路各抢一条不同 FREE第二路写 claimed_by/biz_no uk_claimed_by/uk_biz_no
// 幂等收敛为一条该玩家已被并发路绑好视为成功复用不抛
log.info("[newapi-quota] 并发同玩家 claim 收敛uk 拦截),幂等复用 scene={}, gamePlayerId={}", scene, gamePlayerId);
return true;
}
}
/**
* claim 成功后查水位低于阈值即告警§3.4 池水位是核心运维信号缺了池会静默见底
*/
private void alertIfLowWater(String scene) {
try {
Long free = poolMapper.countFree();
long freeVal = free == null ? 0L : free;
if (freeVal < waterLevelThreshold) {
triggerWaterLevelAlert(freeVal, scene);
}
} catch (Exception e) {
// 水位查询失败不阻断 claimbest-effort 观测信号错误路径留痕
log.warn("[newapi-quota] 池水位查询失败(不阻断 claimscene={}", scene, e);
}
}
/**
* 触发池水位告警内测阶段以带唯一标记的 WARN 落日志供 ops/观测线捕获对接真实告警通道属观测线 follow-up
*/
private void triggerWaterLevelAlert(long freeCount, String scene) {
log.warn("[newapi-quota-alert] 池水位低,请 ops 重跑预置脚本补池 free={}, threshold={}, scene={}",
freeCount, waterLevelThreshold, scene);
}
}

View File

@ -0,0 +1,26 @@
package com.wanxiang.huijing.game.module.aigc.service.quota;
/**
* 额度池空信号异常WU2 §3.6真实 member 在派发热路懒 claim 时池已见底抢不到 FREE 条目
*
* {@link NewapiQuotaService#resolveUserTokenForDispatch} 抛出{@code AigcGenerateExecutor.dispatchGeneric} 捕获
* 翻成 {@code FailureReasonEnum.QUOTA_EXHAUSTED} 当次干净失败文案额度账户准备中请稍后重试
* 而不是派一个没 token create job 下去静默烧共享额度运行时异常不强制调用方 trydispatch 显式捕获处置
*
* @author 绘境AI
*/
public class QuotaPoolExhaustedException extends RuntimeException {
/** 触发耗尽的创作者game_player.id便于日志/告警定位 */
private final Long creatorUserId;
public QuotaPoolExhaustedException(Long creatorUserId) {
super("newapi 额度池空,玩家懒 claim 抢不到 FREE 条目 creatorUserId=" + creatorUserId);
this.creatorUserId = creatorUserId;
}
public Long getCreatorUserId() {
return creatorUserId;
}
}

View File

@ -0,0 +1,26 @@
package com.wanxiang.huijing.game.module.aigc.enums;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* {@link FailureReasonEnum} WU2 §3.9 新增值断言把守 {@code quota_exhausted} 跨契约共享枚举存在且取值稳定
*
* <p>该值须与 contracts/api-schemas/aigc.yaml FailureReason.enumcontracts/dify-workflow-io.json
* output.failureReason.enum 三处一致contract-firstdocs 门对账落库列 game_aigc_task.failure_reason(varchar(32)) 容得下 15 字符
*
* @author 绘境AI
*/
class FailureReasonEnumQuotaTest {
/** quota_exhausted 存在且码值/文案与契约一致(跨契约共享枚举,禁单边漂移)。 */
@Test
void testQuotaExhaustedPresent() {
assertEquals("quota_exhausted", FailureReasonEnum.QUOTA_EXHAUSTED.getReason());
assertEquals("生成额度已用完", FailureReasonEnum.QUOTA_EXHAUSTED.getMessage());
// 落库列 varchar(32) 容量兜底15 字符
assertEquals(15, FailureReasonEnum.QUOTA_EXHAUSTED.getReason().length());
}
}

View File

@ -0,0 +1,69 @@
package com.wanxiang.huijing.game.module.aigc.mq;
import com.wanxiang.huijing.game.module.aigc.mq.message.PassportRegisterEvent;
import com.wanxiang.huijing.game.module.aigc.service.quota.NewapiQuotaService;
import com.wanxiang.huijing.framework.test.core.ut.BaseMockitoUnitTest;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
/**
* {@link NewapiQuotaClaimConsumer} 单元测试 Mockito把守委托 service 做渠道过滤+claim不外抛契约
*
* <p>覆盖正常事件透传 userId+channel service毒消息体空/ userId丢弃不委托不抛service 异常被兜底吞不无限重投
*
* @author 绘境AI
*/
class NewapiQuotaClaimConsumerTest extends BaseMockitoUnitTest {
@Mock
private NewapiQuotaService quotaService;
@InjectMocks
private NewapiQuotaClaimConsumer consumer;
private PassportRegisterEvent event(Long userId, String channel) {
PassportRegisterEvent e = new PassportRegisterEvent();
e.setUserId(userId);
e.setRegisterChannel(channel);
e.setIdempotentKey(userId == null ? null : "register:" + userId);
return e;
}
/** 正常事件:透传 userId + channel 给 service渠道过滤在 service 内做)。 */
@Test
void testOnMessage_delegatesToService() {
consumer.onMessage(event(5L, "password"));
verify(quotaService).claimForRegister(eq(5L), eq("password"));
}
/** 毒消息event 为 null → 不委托、不抛。 */
@Test
void testOnMessage_nullEvent_discardNoThrow() {
assertDoesNotThrow(() -> consumer.onMessage(null));
verify(quotaService, never()).claimForRegister(any(), anyString());
}
/** 毒消息userId 为空 → 不委托、不抛。 */
@Test
void testOnMessage_nullUserId_discardNoThrow() {
assertDoesNotThrow(() -> consumer.onMessage(event(null, "password")));
verify(quotaService, never()).claimForRegister(any(), anyString());
}
/** service 异常:兜底吞掉不外抛(正常返回=ack不让 MQ 无限重投)。 */
@Test
void testOnMessage_serviceThrows_swallowedNoThrow() {
doThrow(new RuntimeException("db down")).when(quotaService).claimForRegister(eq(5L), eq("password"));
assertDoesNotThrow(() -> consumer.onMessage(event(5L, "password")));
}
}

View File

@ -0,0 +1,194 @@
package com.wanxiang.huijing.game.module.aigc.service.quota;
import com.wanxiang.huijing.game.module.aigc.dal.dataobject.quota.NewapiQuotaPoolDO;
import com.wanxiang.huijing.game.module.aigc.dal.mysql.quota.NewapiQuotaPoolMapper;
import com.wanxiang.huijing.framework.common.pojo.CommonResult;
import com.wanxiang.huijing.framework.test.core.ut.BaseMockitoUnitTest;
import com.wanxiang.huijing.module.system.api.passport.PlayerApi;
import com.wanxiang.huijing.module.system.api.passport.dto.PlayerRespDTO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
/**
* {@link NewapiQuotaServiceImpl} 单元测试 Mockito不触真 DB把守 WU2 §3.4 注册 claim §3.6 派发 per-user 门契约
*
* <p>覆盖claim CAS 幂等既有复用不二次占池 / 并发 dup 收敛渠道过滤sms 跳过池空不抛主开关旁路
* per-user 门身份分流member token / 系统旁路 userToken claim fail-fast池空抛 QuotaPoolExhaustedException
*
* @author 绘境AI
*/
class NewapiQuotaServiceImplTest extends BaseMockitoUnitTest {
@InjectMocks
private NewapiQuotaServiceImpl service;
@Mock
private NewapiQuotaPoolMapper poolMapper;
@Mock
private PlayerApi playerApi;
@BeforeEach
void setUp() {
// @Value 字段 Mockito 不注入反射置默认主开关开水位阈值 20
ReflectionTestUtils.setField(service, "enabled", true);
ReflectionTestUtils.setField(service, "waterLevelThreshold", 20L);
}
private PlayerRespDTO member(long userId) {
PlayerRespDTO dto = new PlayerRespDTO();
dto.setUserId(userId);
dto.setStatus(0);
dto.setCreatorFlag(1);
return dto;
}
private NewapiQuotaPoolDO claimedEntry(long playerId, String tokenKey) {
NewapiQuotaPoolDO entry = new NewapiQuotaPoolDO();
entry.setId(9001L);
entry.setClaimedByGamePlayerId(playerId);
entry.setNewapiTokenKey(tokenKey);
entry.setStatus("CLAIMED");
return entry;
}
// ===================== §3.4 注册 claim =====================
/** password 渠道 + 无既有条目 → 执行一次 CAS 抢占bizNo=claim_<id>。 */
@Test
void testClaimForRegister_password_casOnce() {
when(poolMapper.selectClaimedByPlayer(5L)).thenReturn(null);
when(poolMapper.claimOneFree(eq(5L), eq("claim_5"), any(LocalDateTime.class))).thenReturn(1);
service.claimForRegister(5L, "password");
verify(poolMapper).claimOneFree(eq(5L), eq("claim_5"), any(LocalDateTime.class));
}
/** 幂等:该玩家已有 CLAIMED 条目 → no-op 复用,不再 CAS重投不二次占池。 */
@Test
void testClaimForRegister_idempotent_existingClaimed_noCas() {
when(poolMapper.selectClaimedByPlayer(5L)).thenReturn(claimedEntry(5L, "sk-EXIST"));
service.claimForRegister(5L, "invite");
verify(poolMapper, never()).claimOneFree(anyLong(), anyString(), any(LocalDateTime.class));
}
/** 渠道过滤sms 自动注册跳过 claim池 mapper 全程不碰。 */
@Test
void testClaimForRegister_smsChannel_skip() {
service.claimForRegister(5L, "sms");
verifyNoInteractions(poolMapper);
}
/** 渠道过滤sms-mock 后门跳过。 */
@Test
void testClaimForRegister_smsMockChannel_skip() {
service.claimForRegister(5L, "sms-mock");
verifyNoInteractions(poolMapper);
}
/** 池空CAS 返 0 → 不抛异常、不绑(记 WARN + 水位告警,注册照常成功)。 */
@Test
void testClaimForRegister_poolEmpty_noThrow() {
when(poolMapper.selectClaimedByPlayer(5L)).thenReturn(null);
when(poolMapper.claimOneFree(eq(5L), eq("claim_5"), any(LocalDateTime.class))).thenReturn(0);
assertDoesNotThrow(() -> service.claimForRegister(5L, "password"));
}
/** 并发同玩家 claimCAS 撞 uk 抛 DuplicateKeyException → 幂等收敛,不外抛。 */
@Test
void testClaimForRegister_concurrentDuplicate_idempotentNoThrow() {
when(poolMapper.selectClaimedByPlayer(5L)).thenReturn(null);
when(poolMapper.claimOneFree(eq(5L), eq("claim_5"), any(LocalDateTime.class)))
.thenThrow(new DuplicateKeyException("uk_claimed_by 冲突"));
assertDoesNotThrow(() -> service.claimForRegister(5L, "password"));
}
/** 主开关关:注册 claim 全旁路playerApi/poolMapper 均不碰(现行行为)。 */
@Test
void testClaimForRegister_disabled_bypass() {
ReflectionTestUtils.setField(service, "enabled", false);
service.claimForRegister(5L, "password");
verifyNoInteractions(poolMapper, playerApi);
}
// ===================== §3.6 派发 per-user =====================
/** 主开关关:派发解析旁路返 null不查身份、不查池。 */
@Test
void testResolve_disabled_null() {
ReflectionTestUtils.setField(service, "enabled", false);
assertNull(service.resolveUserTokenForDispatch(5L));
verifyNoInteractions(playerApi, poolMapper);
}
/** 系统/编排触发(无 game_player 行getPlayer 返 null → 旁路返 null不查池不懒 claim。 */
@Test
void testResolve_nonMember_bypassNull() {
when(playerApi.getPlayer(999L)).thenReturn(CommonResult.success((PlayerRespDTO) null));
assertNull(service.resolveUserTokenForDispatch(999L));
verifyNoInteractions(poolMapper);
}
/** 真实 member 且已有 CLAIMED 条目 → 取其 token_key不懒 claim。 */
@Test
void testResolve_memberWithClaimed_returnsToken() {
when(playerApi.getPlayer(5L)).thenReturn(CommonResult.success(member(5L)));
when(poolMapper.selectClaimedByPlayer(5L)).thenReturn(claimedEntry(5L, "sk-TOKEN-5"));
assertEquals("sk-TOKEN-5", service.resolveUserTokenForDispatch(5L));
verify(poolMapper, never()).claimOneFree(anyLong(), anyString(), any(LocalDateTime.class));
}
/** 真实 member 无 CLAIMED → 懒 claim 抢到 → 取 token_key。 */
@Test
void testResolve_memberNoClaimed_lazyClaimSuccess_returnsToken() {
when(playerApi.getPlayer(5L)).thenReturn(CommonResult.success(member(5L)));
// 首查无 CLAIMED tryClaimCore 内的幂等前置也走此桩CAS 抢到后再查得已绑条目
when(poolMapper.selectClaimedByPlayer(5L))
.thenReturn(null)
.thenReturn(null)
.thenReturn(claimedEntry(5L, "sk-LAZY-5"));
when(poolMapper.claimOneFree(eq(5L), eq("claim_5"), any(LocalDateTime.class))).thenReturn(1);
assertEquals("sk-LAZY-5", service.resolveUserTokenForDispatch(5L));
verify(poolMapper).claimOneFree(eq(5L), eq("claim_5"), any(LocalDateTime.class));
}
/** 真实 member 无 CLAIMED 且池空 → 懒 claim fail-fast 抛 QuotaPoolExhaustedException当次干净失败。 */
@Test
void testResolve_memberNoClaimed_poolEmpty_throwsExhausted() {
when(playerApi.getPlayer(5L)).thenReturn(CommonResult.success(member(5L)));
when(poolMapper.selectClaimedByPlayer(5L)).thenReturn(null);
when(poolMapper.claimOneFree(eq(5L), eq("claim_5"), any(LocalDateTime.class))).thenReturn(0);
assertThrows(QuotaPoolExhaustedException.class, () -> service.resolveUserTokenForDispatch(5L));
}
}

View File

@ -33,6 +33,18 @@ public class ApiAccessLogInterceptor implements HandlerInterceptor {
private static final String ATTRIBUTE_STOP_WATCH = "ApiAccessLogInterceptor.StopWatch";
/**
* 敏感 URI 后缀白名单2026-07-07 WU1 §3.2命中的请求体不打印到 stdout/控制台
* <p>
* 背景 Interceptor 在非 prod 环境用 log.info **原始 requestBody** 直接打到控制台不受 {@code @ApiAccessLog(sanitizeKeys)}
* 约束那条只脱敏持久化日志 system_api_access_log密码是可跨站复用的持久凭据任何环境抬高日志级别或聚合 stdout
* 即明文外泄不能只靠 staging logging.level 故对这两个 passport 密码端点做**代码级**保证不读不打印其 body
*/
private static final String[] SENSITIVE_BODY_URI_SUFFIXES = {
"/passport/password-register",
"/passport/password-login"
};
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
// 记录 HandlerMethod提供给 ApiAccessLogFilter 使用
@ -44,8 +56,12 @@ public class ApiAccessLogInterceptor implements HandlerInterceptor {
// 打印 request 日志
if (!SpringUtils.isProd()) {
Map<String, String> queryString = ServletUtils.getParamMap(request);
String requestBody = ServletUtils.getBody(request);
if (CollUtil.isEmpty(queryString) && StrUtil.isEmpty(requestBody)) {
// WU1 §3.2敏感 URI密码端点不读不打印请求体代码级杜绝明文密码进 stdout不受 sanitizeKeys 约束的第二条链路
boolean sensitiveBody = isSensitiveBodyUri(request.getRequestURI());
String requestBody = sensitiveBody ? null : ServletUtils.getBody(request);
if (sensitiveBody) {
log.info("[preHandle][开始请求 URL({}) 参数(已屏蔽敏感请求体)]", request.getRequestURI());
} else if (CollUtil.isEmpty(queryString) && StrUtil.isEmpty(requestBody)) {
log.info("[preHandle][开始请求 URL({}) 无参数]", request.getRequestURI());
} else {
log.info("[preHandle][开始请求 URL({}) 参数({})]", request.getRequestURI(),
@ -72,6 +88,21 @@ public class ApiAccessLogInterceptor implements HandlerInterceptor {
}
}
/**
* 判断请求 URI 是否属于敏感请求体白名单命中则不打印 body {@link #SENSITIVE_BODY_URI_SUFFIXES}
*/
private boolean isSensitiveBodyUri(String uri) {
if (StrUtil.isEmpty(uri)) {
return false;
}
for (String suffix : SENSITIVE_BODY_URI_SUFFIXES) {
if (uri.endsWith(suffix)) {
return true;
}
}
return false;
}
/**
* 打印 Controller 方法路径
*/

View File

@ -180,6 +180,10 @@ public interface ErrorCodeConstants {
ErrorCode PLAYER_NOT_EXISTS = new ErrorCode(1_002_090_001, "玩家不存在");
ErrorCode PLAYER_IS_DISABLED = new ErrorCode(1_002_090_002, "该账号已被禁用");
ErrorCode PLAYER_CREATE_NOT_IN_WHITELIST = new ErrorCode(1_002_090_003, "创作功能限内测白名单,当前账号暂无创作权限"); // 拍板4creator_flag 白名单校验未过PlayerApi#validateCreator
// ========== 内测用户名密码注册登录2026-07-07 WU1 §3.2==========
ErrorCode PLAYER_USERNAME_ALREADY_REGISTERED = new ErrorCode(1_002_090_004, "用户名已被占用"); // password-register 用户名撞 uk_username注册态明确报占用不静默转登录
ErrorCode PLAYER_USERNAME_OR_PASSWORD_ERROR = new ErrorCode(1_002_090_005, "用户名或密码错误"); // password-login 防枚举查无用户名与密码错误统一此码配合固定假 hash 对齐耗时堵时序侧信道
ErrorCode PASSWORD_AUTH_DISABLED = new ErrorCode(1_002_090_006, "用户名密码登录通道未开启"); // wanxiang.passport.password-auth-enabled=false 时两端点拒服务信任边界=后端服务层
// ========== passport 邀请码 1-002-091-000 ==========
ErrorCode INVITE_CODE_NOT_EXISTS = new ErrorCode(1_002_091_000, "邀请码无效"); // 码不存在与已停用/已过期/已耗尽区分便于运营定位

View File

@ -97,6 +97,13 @@
<artifactId>huijing-spring-boot-starter-mq</artifactId>
</dependency>
<!-- RocketMQ2026-07-07 WU1 §3.6passport 注册成功事件 outbox 投递器 RocketMQTemplate
starter-mq 以 optional=true 声明本 starter不传递故本模块须显式直依赖版本由 huijing 依赖管理统一控制。 -->
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-spring-boot-starter</artifactId>
</dependency>
<!-- 服务保障相关passport 发码端点 @RateLimiterIP 维度限频R6 §7.2)需此 starter2026-06-10 鉴权件取消注释 -->
<dependency>
<groupId>com.wanxiang</groupId>

View File

@ -8,6 +8,8 @@ import com.wanxiang.huijing.framework.ratelimiter.core.keyresolver.impl.ClientIp
import com.wanxiang.huijing.framework.security.core.util.SecurityFrameworkUtils;
import com.wanxiang.huijing.module.system.controller.app.passport.vo.InviteRegisterReqVO;
import com.wanxiang.huijing.module.system.controller.app.passport.vo.LoginRespVO;
import com.wanxiang.huijing.module.system.controller.app.passport.vo.PasswordLoginReqVO;
import com.wanxiang.huijing.module.system.controller.app.passport.vo.PasswordRegisterReqVO;
import com.wanxiang.huijing.module.system.controller.app.passport.vo.PlayerMeRespVO;
import com.wanxiang.huijing.module.system.controller.app.passport.vo.SendSmsCodeReqVO;
import com.wanxiang.huijing.module.system.controller.app.passport.vo.SmsLoginReqVO;
@ -16,6 +18,7 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.annotation.security.PermitAll;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import java.util.concurrent.TimeUnit;
import org.springframework.validation.annotation.Validated;
@ -80,6 +83,29 @@ public class AppPassportController {
return success(passportService.inviteRegister(reqVO, ServletUtils.getClientIP()));
}
@PostMapping("/password-register")
@PermitAll // 免登内测种子用户用户名密码注册受总开关 wanxiang.passport.password-auth-enabled 管控
// WU1 §3.2 安全实证脱敏持久化访问日志的 password 禁明文落 system_api_access_log
// 控制台 stdout 明文由 ApiAccessLogInterceptor 代码级屏蔽本 URI body 打印不只靠 logging.level§3.2
@ApiAccessLog(sanitizeKeys = {"password"})
@Operation(summary = "用户名密码注册", description = "内测种子用户register_channel=password返回体与短信路 token 一字不差")
public CommonResult<LoginRespVO> passwordRegister(@Valid @RequestBody PasswordRegisterReqVO reqVO,
HttpServletRequest request) {
// WU1 §3.3 命门IP 一律采信连接层 getRemoteAddr()不读客户端可伪造的 XFF不走 ServletUtils.getClientIP
return success(passportService.passwordRegister(reqVO, request.getRemoteAddr()));
}
@PostMapping("/password-login")
@PermitAll // 免登内测种子用户用户名密码登录
// WU1 §3.2 安全实证 password-register脱敏持久化日志 password + 代码级屏蔽 stdout body 打印
@ApiAccessLog(sanitizeKeys = {"password"})
@Operation(summary = "用户名密码登录", description = "防枚举:查无用户名与密码错误统一错误码 + 假 hash 对齐耗时")
public CommonResult<LoginRespVO> passwordLogin(@Valid @RequestBody PasswordLoginReqVO reqVO,
HttpServletRequest request) {
// WU1 §3.3 命门取连接层 getRemoteAddr()不采信 XFF伪造 XFF 换桶绕过的堵漏
return success(passportService.passwordLogin(reqVO, request.getRemoteAddr()));
}
@GetMapping("/me")
@Operation(summary = "当前登录玩家信息", description = "登录态必需(无 @PermitAll未登录 401手机号脱敏返回")
public CommonResult<PlayerMeRespVO> getPlayerMe() {

View File

@ -0,0 +1,34 @@
package com.wanxiang.huijing.module.system.controller.app.passport.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 用户名密码登录 Request VO内测种子用户2026-07-07 WU1 §3.2
*
* 防枚举查无用户名与密码错误统一返 PLAYER_USERNAME_OR_PASSWORD_ERROR且查无用户名时也跑固定假 hash 对齐耗时
* password @NotEmpty登录不重复长度校验避免给出密码策略线索
*
* @author 绘境AI
*/
@Schema(description = "产品端 - 用户名密码登录 Request VO")
@Data
public class PasswordLoginReqVO {
@Schema(description = "用户名(登录主键之一)", requiredMode = Schema.RequiredMode.REQUIRED, example = "seed_alice")
@NotEmpty(message = "用户名不能为空")
@Pattern(regexp = "^[a-zA-Z0-9]{4,30}$", message = "用户名格式为数字以及字母")
private String username;
@Schema(description = "密码(明文)", requiredMode = Schema.RequiredMode.REQUIRED, example = "buzhidao")
@NotEmpty(message = "密码不能为空")
private String password;
@Schema(description = "客户端 anonId可选登录不落库仅遥测归因透传", example = "anon-xyz")
@Size(max = 64, message = "anonId 不能超过 64 个字符")
private String anonId;
}

View File

@ -0,0 +1,42 @@
package com.wanxiang.huijing.module.system.controller.app.passport.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
import org.hibernate.validator.constraints.Length;
/**
* 用户名密码注册 Request VO内测种子用户2026-07-07 WU1 §3.2
*
* 用户名沿用 admin AuthLoginReqVO 范式^[a-zA-Z0-9]{4,30}$密码明文由服务层立即 BCrypt 编码
* 下限收紧至 6本端点直面公网威胁模型比 admin 后台建号更严生产稳定优先
* password 为明文入参服务层编码后落库绝不回显绝不落日志端点已代码级绕过 stdout body 打印
*
* @author 绘境AI
*/
@Schema(description = "产品端 - 用户名密码注册 Request VO")
@Data
public class PasswordRegisterReqVO {
@Schema(description = "用户名(登录主键之一)", requiredMode = Schema.RequiredMode.REQUIRED, example = "seed_alice")
@NotEmpty(message = "用户名不能为空")
@Length(min = 4, max = 30, message = "用户名长度为 4-30 位")
@Pattern(regexp = "^[a-zA-Z0-9]{4,30}$", message = "用户名格式为数字以及字母")
private String username;
@Schema(description = "密码(明文,服务层立即 BCrypt 编码)", requiredMode = Schema.RequiredMode.REQUIRED, example = "buzhidao")
@NotEmpty(message = "密码不能为空")
@Length(min = 6, max = 32, message = "密码长度为 6-32 位")
private String password;
@Schema(description = "昵称(可选;不传则默认取用户名)", example = "新玩家")
@Size(max = 30, message = "昵称不能超过 30 个字符")
private String nickname;
@Schema(description = "客户端 anonId可选同 sms-login 口径,仅注册落 first_anon_id 一次,匿名↔登录归因)", example = "anon-xyz")
@Size(max = 64, message = "anonId 不能超过 64 个字符")
private String anonId;
}

View File

@ -17,9 +17,12 @@ public class PlayerMeRespVO {
@Schema(description = "玩家编号", example = "1024")
private Long userId;
@Schema(description = "脱敏手机号(如 138****0000安全基线禁回明文", example = "138****0000")
@Schema(description = "脱敏手机号(如 138****0000安全基线禁回明文;用户名用户 mobile 为 null 时回空串", example = "138****0000")
private String mobile;
@Schema(description = "用户名用户名密码路身份展示非敏感明文手机号用户为空2026-07-07 WU1", example = "seed_alice")
private String username;
@Schema(description = "昵称", example = "新玩家")
private String nickname;

View File

@ -0,0 +1,65 @@
package com.wanxiang.huijing.module.system.dal.dataobject.passport;
import com.wanxiang.huijing.framework.mybatis.core.dataobject.BaseDO;
import com.wanxiang.huijing.framework.tenant.core.aop.TenantIgnore;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/**
* 注册成功事件本地消息表 DO事务性 outbox2026-07-07 WU1 §3.6
*
* 对应表 game_passport_register_outbox三条注册路sms 自动注册 / invite / password共用出口
* 注册事务内与建号原子写一行status=0 未投递事务外投递器@Scheduled扫未投递发 RocketMQ
* 发成功置 status=1至少一次锚在 DB 事务上杜绝注册成功但事件从未入队黑洞
* <p>
* 多租户本表为机制表 {@link TenantIgnore}表不含 tenant_id 投递器 @Scheduled 无租户上下文
* 若走租户拦截器会因 getRequiredTenantId() 抛错忽略后可全表扫未投递行
*
* @author 绘境AI
*/
@TableName("game_passport_register_outbox")
@TenantIgnore // 机制表@Scheduled 投递器无租户上下文忽略多租户过滤表无 tenant_id
@Data
@EqualsAndHashCode(callSuper = true)
public class PassportRegisterOutboxDO extends BaseDO {
/** 投递状态:未投递(待投递器扫描发 MQ。 */
public static final int STATUS_PENDING = 0;
/** 投递状态:已投递(投递器发 MQ 成功后置位)。 */
public static final int STATUS_DELIVERED = 1;
/**
* 编号
*/
@TableId
private Long id;
/**
* 新注册玩家编号game_player.idWU2 开户充值幂等键
*/
private Long userId;
/**
* 注册通道sms / invite / password消费端口径统一在此不区分登录方式
*/
private String registerChannel;
/**
* 注册时携带的客户端 anonId匿名登录归因缺失落空串
*/
private String anonId;
/**
* 幂等键=register:{userId}uk_idempotent_key 保证每次注册至多一行 outbox
*/
private String idempotentKey;
/**
* 投递状态0未投递 1已投递
*/
private Integer status;
/**
* 投递成功时间status=1 时回填审计
*/
private LocalDateTime deliveredTime;
}

View File

@ -31,9 +31,17 @@ public class PlayerDO extends TenantBaseDO {
@TableId
private Long id;
/**
* 手机号登录主键日志/响应必须脱敏库内 MVP 明文 + uk_mobile 唯一键兜底并发重复注册
* 手机号登录主键之一2026-07-07 WU1 放宽可空承载无手机号的用户名用户日志/响应必须脱敏uk_mobile 兜底并发重复注册
*/
private String mobile;
/**
* 用户名登录主键之一内测用户名密码路^[a-zA-Z0-9]{4,30}$uk_username 唯一手机号用户为 NULL2026-07-07 WU1
*/
private String username;
/**
* 密码 BCrypt 密文 仅服务层内部用于编码/校验永不进任何 RespVO永不落日志非用户名用户为 NULL2026-07-07 WU1
*/
private String password;
/**
* 昵称注册默认生成可改
*/

View File

@ -0,0 +1,53 @@
package com.wanxiang.huijing.module.system.dal.mysql.passport;
import com.wanxiang.huijing.framework.mybatis.core.mapper.BaseMapperX;
import com.wanxiang.huijing.module.system.dal.dataobject.passport.PassportRegisterOutboxDO;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDateTime;
import java.util.List;
/**
* 注册成功事件本地消息表 Mapper2026-07-07 WU1 §3.6
*
* 投递器@Scheduled按未投递态分页扫描发 MQ发成功用条件更新 CAS 置已投递幂等避免重复 MQ 与并发双投
*
* @author 绘境AI
*/
@Mapper
public interface PassportRegisterOutboxMapper extends BaseMapperX<PassportRegisterOutboxDO> {
/**
* 扫描未投递事件status=0 id 升序取前 limit 投递器批量投递用
*
* @param limit 单批上限有界扫描避免一次拉全表
* @return 未投递 outbox 列表
*/
default List<PassportRegisterOutboxDO> selectPendingList(int limit) {
LambdaQueryWrapper<PassportRegisterOutboxDO> query = new LambdaQueryWrapper<PassportRegisterOutboxDO>()
.eq(PassportRegisterOutboxDO::getStatus, PassportRegisterOutboxDO.STATUS_PENDING)
.orderByAsc(PassportRegisterOutboxDO::getId)
.last("LIMIT " + limit);
return selectList(query);
}
/**
* 条件更新置已投递CAS仅当当前仍为未投递态才更新返回 1 表示本次投递赢得该行0 表示已被其他投递器/重入抢先
*
* @param id outbox 行编号
* @param deliveredTime 投递成功时间
* @return 实际更新行数1=本次置位成功0=已被抢先避免重复 MQ
*/
default int markDelivered(@Param("id") Long id, LocalDateTime deliveredTime) {
LambdaUpdateWrapper<PassportRegisterOutboxDO> update = new LambdaUpdateWrapper<PassportRegisterOutboxDO>()
.set(PassportRegisterOutboxDO::getStatus, PassportRegisterOutboxDO.STATUS_DELIVERED)
.set(PassportRegisterOutboxDO::getDeliveredTime, deliveredTime)
.eq(PassportRegisterOutboxDO::getId, id)
.eq(PassportRegisterOutboxDO::getStatus, PassportRegisterOutboxDO.STATUS_PENDING);
return update(null, update);
}
}

View File

@ -29,6 +29,16 @@ public interface PlayerMapper extends BaseMapperX<PlayerDO> {
return selectOne(PlayerDO::getMobile, mobile);
}
/**
* 按用户名查玩家命中 uk_username内测用户名密码路登录主键查询2026-07-07 WU1
*
* @param username 用户名
* @return 玩家 DO不存在返回 null
*/
default PlayerDO selectByUsername(String username) {
return selectOne(PlayerDO::getUsername, username);
}
/**
* 创作者白名单置位拍板4 A2 名单运营动作 userId 更新 creator_flag
*

View File

@ -1,5 +1,6 @@
package com.wanxiang.huijing.module.system.dal.redis.passport;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Repository;
@ -10,26 +11,45 @@ import java.time.format.DateTimeFormatter;
import java.util.concurrent.TimeUnit;
/**
* passport 发码 IP 频控计数 RedisDAOR6 切渠道安全前置§7.2
* passport 应用层纯 IP 频控计数 RedisDAOR6 发码前置 + 2026-07-07 WU1 泛化为按 scene 分桶
*
* passport 发码 Service 入层补IP 每日/每小时上限不改上游 SmsCodeServiceImpl避免 fork 侵入
* 上游 :65-66 该项为 TODO Redis 自增计数 + 首次设过期实现滑动自然窗口按日历日/小时分桶
* KEY 格式passport_sms_ip_day:{ip}:{yyyyMMdd} / passport_sms_ip_hour:{ip}:{yyyyMMddHH}
* VALUEString 计数TTL 在首次自增时设置日桶 1 小时桶 1 小时到期自动清
* passport 各免登端点入层按 IP 维度的时/日双桶计数key 只含 scene+IP+日期不含请求体
* Redis 自增 + 首次设过期实现按日历日/小时的自然窗口原发码专用 KEY 前缀泛化为 scene 分桶
* scene {sms 发码, register 用户名注册, login 登录(sms/password 共用), invite 邀请码注册}
* <p>
* KEY 格式passport_ip:{scene}:hour:{ip}:{yyyyMMddHH} / passport_ip:{scene}:day:{ip}:{yyyyMMdd}
* VALUEString 计数TTL 在首次自增时设置日桶 1 小时桶 1 小时到期自动清
* <p>
* 一次性重置§3.3 如实记账上线本改动那一刻旧前缀 passport_sms_ip_* 的存量计数桶全部失效
* 等于对已在计数的 IP 一次性重置限流窗口 TTL 有界只重置一次内测无害观测同步改名即可
* <p>
* <b>频控后端故障姿态§3.3 定死fail-open + WARN 告警</b>Redis 抖动/超时时捕获异常记一条含 IP+scene
* 告警日志返回 0视为未超限放行内测阶段认证可用性优先于把防刷做到严丝合缝绝不让 Redis 单点静默拖垮登录
* 代价Redis 挂时防刷短暂失效记账接受Redis 恢复后限流自动复位
*
* @author 绘境AI
*/
@Slf4j
@Repository
public class PassportSmsIpCounterRedisDAO {
/** 发码场景send-sms-code 回填此值,与原行为计数语义等价)。 */
public static final String SCENE_SMS = "sms";
/** 用户名密码注册场景。 */
public static final String SCENE_REGISTER = "register";
/** 登录场景sms-login 回填 + password-login 共用)。 */
public static final String SCENE_LOGIN = "login";
/** 邀请码注册场景。 */
public static final String SCENE_INVITE = "invite";
/**
* 日维度计数 KEY 前缀
* 日维度计数 KEY 模板passport_ip:{scene}:day:{ip}:{yyyyMMdd}
*/
private static final String KEY_DAY = "passport_sms_ip_day:%s:%s";
private static final String KEY_DAY = "passport_ip:%s:day:%s:%s";
/**
* 小时维度计数 KEY 前缀
* 小时维度计数 KEY 模板passport_ip:{scene}:hour:{ip}:{yyyyMMddHH}
*/
private static final String KEY_HOUR = "passport_sms_ip_hour:%s:%s";
private static final String KEY_HOUR = "passport_ip:%s:hour:%s:%s";
private static final DateTimeFormatter DAY_FMT = DateTimeFormatter.ofPattern("yyyyMMdd");
private static final DateTimeFormatter HOUR_FMT = DateTimeFormatter.ofPattern("yyyyMMddHH");
@ -38,35 +58,48 @@ public class PassportSmsIpCounterRedisDAO {
private StringRedisTemplate stringRedisTemplate;
/**
* 自增当日计数并返回自增后的值首次自增时设 1 TTL
* 自增指定场景当日计数并返回自增后的值首次自增时设 1 TTL
*
* @param ip 请求 IP
* @return 自增后的当日计数
* @param scene 场景{@link #SCENE_SMS}
* @param ip 请求 IP
* @return 自增后的当日计数Redis 异常时 fail-open 返回 0放行
*/
public long incrDayAndGet(String ip) {
String key = String.format(KEY_DAY, ip, LocalDate.now().format(DAY_FMT));
Long val = stringRedisTemplate.opsForValue().increment(key);
if (val != null && val == 1L) {
// 首次计数设过期避免 key 永久堆积日桶 1
stringRedisTemplate.expire(key, 1, TimeUnit.DAYS);
public long incrDayAndGet(String scene, String ip) {
String key = String.format(KEY_DAY, scene, ip, LocalDate.now().format(DAY_FMT));
try {
Long val = stringRedisTemplate.opsForValue().increment(key);
if (val != null && val == 1L) {
// 首次计数设过期避免 key 永久堆积日桶 1
stringRedisTemplate.expire(key, 1, TimeUnit.DAYS);
}
return val == null ? 0L : val;
} catch (Exception e) {
// 频控后端故障 fail-open§3.3记含 IP+scene 的告警放行本次绝不让 Redis 单点拖垮认证
log.warn("[incrDayAndGet] ⚠ 频控 Redis 异常fail-open 放行防刷本次失效记账接受scene={}, ip={}", scene, ip, e);
return 0L;
}
return val == null ? 0L : val;
}
/**
* 自增当前小时计数并返回自增后的值首次自增时设 1 小时 TTL
* 自增指定场景当前小时计数并返回自增后的值首次自增时设 1 小时 TTL
*
* @param ip 请求 IP
* @return 自增后的当前小时计数
* @param scene 场景
* @param ip 请求 IP
* @return 自增后的当前小时计数Redis 异常时 fail-open 返回 0放行
*/
public long incrHourAndGet(String ip) {
String key = String.format(KEY_HOUR, ip, LocalDateTime.now().format(HOUR_FMT));
Long val = stringRedisTemplate.opsForValue().increment(key);
if (val != null && val == 1L) {
// 首次计数设过期小时桶 1 小时
stringRedisTemplate.expire(key, 1, TimeUnit.HOURS);
public long incrHourAndGet(String scene, String ip) {
String key = String.format(KEY_HOUR, scene, ip, LocalDateTime.now().format(HOUR_FMT));
try {
Long val = stringRedisTemplate.opsForValue().increment(key);
if (val != null && val == 1L) {
// 首次计数设过期小时桶 1 小时
stringRedisTemplate.expire(key, 1, TimeUnit.HOURS);
}
return val == null ? 0L : val;
} catch (Exception e) {
log.warn("[incrHourAndGet] ⚠ 频控 Redis 异常fail-open 放行防刷本次失效记账接受scene={}, ip={}", scene, ip, e);
return 0L;
}
return val == null ? 0L : val;
}
}

View File

@ -0,0 +1,35 @@
package com.wanxiang.huijing.module.system.mq.message.passport;
import lombok.Data;
import java.io.Serializable;
/**
* 注册成功事件 MQ 消息体2026-07-07 WU1 §3.6
*
* 三条注册路sms 自动注册 / invite / password的公共出口事件消费的是注册成功而非password 注册成功
* 口径统一在三条路的公共出口WU2 new-api 开户充值消费端据 {@link #userId} 幂等至少一次投递必带重复
*
* @author 绘境AI
*/
@Data
public class PassportRegisterMessage implements Serializable {
/**
* 新注册玩家编号game_player.id消费端开户充值幂等键
*/
private Long userId;
/**
* 注册通道sms / invite / password归因用不区分登录方式的消费口径
*/
private String registerChannel;
/**
* 注册时携带的客户端 anonId匿名登录归因缺失为空串
*/
private String anonId;
/**
* 幂等键=register:{userId}消费端去重
*/
private String idempotentKey;
}

View File

@ -0,0 +1,122 @@
package com.wanxiang.huijing.module.system.mq.producer.passport;
import com.wanxiang.huijing.module.system.dal.dataobject.passport.PassportRegisterOutboxDO;
import com.wanxiang.huijing.module.system.dal.mysql.passport.PassportRegisterOutboxMapper;
import com.wanxiang.huijing.module.system.mq.message.passport.PassportRegisterMessage;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.client.producer.SendResult;
import org.apache.rocketmq.client.producer.SendStatus;
import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.apache.rocketmq.spring.support.RocketMQHeaders;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.messaging.Message;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import jakarta.annotation.Resource;
import java.time.LocalDateTime;
import java.util.List;
/**
* 注册成功事件 outbox 投递器事务外 @Scheduled2026-07-07 WU1 §3.6
*
* 与建号原子写入的 outbox {@link PassportRegisterOutboxDO}由本投递器周期扫描status=0 未投递
* 同步发 RocketMQ带超时发成功用条件更新 CAS 置已投递status=1至少一次锚在 DB 事务上而非
* MQ 即时可达性上建号成功则 outbox 必落库投递器保证最终发出MQ 抖动/超时时该行保留未投递态下轮重投
* <p>
* 外部交互纪律创始人铁律syncSend 带发送超时发送失败/ SEND_OK <b>不置已投递</b>下轮重投有界批扫兜底
* RocketMQTemplate {@link ObjectProvider} name-server 未装配如部分本地/测试 profile时优雅缺席
* 本轮跳过并 WARN绝不因 MQ 不可达抛穿定时线程幂等由消费侧据 userId 去重至少一次必带重复投递
*
* @author 绘境AI
*/
@Slf4j
@Component
public class PassportRegisterOutboxDeliverer {
/** 「注册成功」事件主题WU2 new-api 开户充值消费端订阅)。 */
public static final String TOPIC = "passport-register";
/** 发送超时(毫秒)——外部交互必设超时,避免定时线程被 MQ 抖动无限拖住。 */
private static final long SEND_TIMEOUT_MS = 3000L;
/** 单轮扫描上限(有界批扫,避免一次拉全表)。 */
private static final int BATCH_LIMIT = 200;
@Resource
private PassportRegisterOutboxMapper outboxMapper;
/**
* RocketMQ 模板rocketmq-spring 自动装配ObjectProvider 兜底 name-server 未配时缺席不阻断上下文/定时线程
*/
@Resource
private ObjectProvider<RocketMQTemplate> rocketMQTemplateProvider;
/**
* 周期扫描未投递 outbox 并投递fixedDelay 15s上轮结束后再隔 15s 起下轮避免堆叠
* initialDelay 30s 让应用先启动完成再首扫
*/
@Scheduled(fixedDelayString = "${wanxiang.passport.register-outbox.deliver-interval-ms:15000}", initialDelay = 30_000L)
public void deliverPendingBatch() {
RocketMQTemplate rocketMQTemplate = rocketMQTemplateProvider.getIfAvailable();
if (rocketMQTemplate == null) {
// name-server 未装配部分本地/测试 profile本轮跳过不阻断 MQ 就绪后下轮自然投出至少一次不丢
log.warn("[passport-outbox] RocketMQTemplate 缺席rocketmq name-server 未装配本轮跳过投递outbox 保留未投递态待后续");
return;
}
List<PassportRegisterOutboxDO> pending = outboxMapper.selectPendingList(BATCH_LIMIT);
if (pending.isEmpty()) {
return;
}
int ok = 0;
for (PassportRegisterOutboxDO row : pending) {
if (deliverOne(rocketMQTemplate, row)) {
ok++;
}
}
log.info("[passport-outbox] 本轮投递完成 total={}, delivered={}", pending.size(), ok);
}
/**
* 投递单条syncSend 成功且 SEND_OK CAS 置已投递否则保留未投递态下轮重投
*
* @return 是否本次投递并置位成功
*/
private boolean deliverOne(RocketMQTemplate rocketMQTemplate, PassportRegisterOutboxDO row) {
try {
PassportRegisterMessage payload = new PassportRegisterMessage();
payload.setUserId(row.getUserId());
payload.setRegisterChannel(row.getRegisterChannel());
payload.setAnonId(row.getAnonId());
payload.setIdempotentKey(row.getIdempotentKey());
// keys 置幂等键便于全链路排障对账消费侧幂等真身同为 idempotentKey/userId
Message<PassportRegisterMessage> message = MessageBuilder.withPayload(payload)
.setHeader(RocketMQHeaders.KEYS, row.getIdempotentKey())
.build();
SendResult result = rocketMQTemplate.syncSend(TOPIC, message, SEND_TIMEOUT_MS);
boolean sendOk = result != null && result.getSendStatus() == SendStatus.SEND_OK;
if (!sendOk) {
// SEND_OK刷盘/主从超时等不置位保留未投递态下轮重投
log.warn("[passport-outbox] 投递非 SEND_OK保留未投递态待重投 outboxId={}, userId={}, status={}",
row.getId(), row.getUserId(), result == null ? null : result.getSendStatus());
return false;
}
// CAS 置已投递防并发/重入双投affected=0 说明已被抢先本次不重复计
int affected = outboxMapper.markDelivered(row.getId(), LocalDateTime.now());
if (affected == 0) {
log.warn("[passport-outbox] 置已投递 CAS 未命中已被抢先outboxId={}, userId={}", row.getId(), row.getUserId());
return false;
}
log.info("[passport-outbox] 注册成功事件已投递 topic={}, outboxId={}, userId={}, channel={}, msgId={}",
TOPIC, row.getId(), row.getUserId(), row.getRegisterChannel(), result.getMsgId());
return true;
} catch (Exception e) {
// 外部交互错误路径必须留痕MQ 抖动/超时不外抛不拖垮整批保留未投递态下轮重投有界重试
log.warn("[passport-outbox] 投递失败不外抛保留未投递态待重投outboxId={}, userId={}", row.getId(), row.getUserId(), e);
return false;
}
}
}

View File

@ -3,6 +3,8 @@ package com.wanxiang.huijing.module.system.service.passport;
import com.wanxiang.huijing.module.system.api.passport.dto.PlayerRespDTO;
import com.wanxiang.huijing.module.system.controller.app.passport.vo.InviteRegisterReqVO;
import com.wanxiang.huijing.module.system.controller.app.passport.vo.LoginRespVO;
import com.wanxiang.huijing.module.system.controller.app.passport.vo.PasswordLoginReqVO;
import com.wanxiang.huijing.module.system.controller.app.passport.vo.PasswordRegisterReqVO;
import com.wanxiang.huijing.module.system.controller.app.passport.vo.PlayerMeRespVO;
import com.wanxiang.huijing.module.system.controller.app.passport.vo.SmsLoginReqVO;
@ -51,6 +53,33 @@ public interface PassportService {
*/
LoginRespVO inviteRegister(InviteRegisterReqVO reqVO, String clientIp);
/**
* 用户名密码注册内测种子用户register_channel=password2026-07-07 WU1 §3.2
*
* 总开关 wanxiang.passport.password-auth-enabled 关闭时抛 PASSWORD_AUTH_DISABLED信任边界=后端服务层
* 应用层纯 IP 计数防刷scene=register越限抛 TOO_MANY_REQUESTS密码 BCrypt 编码后落库
* 用户名撞 uk_username PLAYER_USERNAME_ALREADY_REGISTERED注册态明确报占用不静默转登录
* 注册事务内与建号原子写一行 outbox注册成功事件生产者三条注册路共用出口
*
* @param reqVO username/password/nickname?/anonId?
* @param clientIp 请求 IP取连接层 request.getRemoteAddr()不采信可伪造 XFF§3.3
* @return 登录响应 OAuth2 token + 身份摘要与短信路一字不差
*/
LoginRespVO passwordRegister(PasswordRegisterReqVO reqVO, String clientIp);
/**
* 用户名密码登录内测种子用户2026-07-07 WU1 §3.2
*
* 总开关关闭抛 PASSWORD_AUTH_DISABLED应用层纯 IP 计数scene=login越限抛 TOO_MANY_REQUESTS
* 防枚举查无用户名与密码错误统一返 PLAYER_USERNAME_OR_PASSWORD_ERROR且查无用户名时也跑一次固定假 BCrypt
* hash matches 对齐耗时消除用户名存在与否的时序侧信道禁用态拒登录
*
* @param reqVO username/password/anonId?
* @param clientIp 请求 IP取连接层 request.getRemoteAddr()不采信 XFF
* @return 登录响应同短信路 token
*/
LoginRespVO passwordLogin(PasswordLoginReqVO reqVO, String clientIp);
/**
* 当前登录玩家信息me 端点登录态手机号脱敏返回
*

View File

@ -11,10 +11,14 @@ import com.wanxiang.huijing.module.system.api.sms.dto.code.SmsCodeSendReqDTO;
import com.wanxiang.huijing.module.system.api.sms.dto.code.SmsCodeUseReqDTO;
import com.wanxiang.huijing.module.system.controller.app.passport.vo.InviteRegisterReqVO;
import com.wanxiang.huijing.module.system.controller.app.passport.vo.LoginRespVO;
import com.wanxiang.huijing.module.system.controller.app.passport.vo.PasswordLoginReqVO;
import com.wanxiang.huijing.module.system.controller.app.passport.vo.PasswordRegisterReqVO;
import com.wanxiang.huijing.module.system.controller.app.passport.vo.PlayerMeRespVO;
import com.wanxiang.huijing.module.system.controller.app.passport.vo.SmsLoginReqVO;
import com.wanxiang.huijing.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO;
import com.wanxiang.huijing.module.system.dal.dataobject.passport.PassportRegisterOutboxDO;
import com.wanxiang.huijing.module.system.dal.dataobject.passport.PlayerDO;
import com.wanxiang.huijing.module.system.dal.mysql.passport.PassportRegisterOutboxMapper;
import com.wanxiang.huijing.module.system.dal.mysql.passport.PlayerMapper;
import com.wanxiang.huijing.module.system.dal.redis.passport.PassportSmsIpCounterRedisDAO;
import com.wanxiang.huijing.module.system.enums.oauth2.OAuth2ClientConstants;
@ -24,6 +28,7 @@ import com.wanxiang.huijing.module.system.service.sms.SmsCodeService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
@ -54,6 +59,18 @@ public class PassportServiceImpl implements PassportService {
*/
private static final String DEFAULT_NICKNAME_PREFIX = "玩家";
/**
* 用户名密码注册通道值register_channel=password对齐 V31 迁移值域
*/
private static final String CHANNEL_PASSWORD = "password";
/**
* 生成固定假 BCrypt hash 的种子§3.2 防枚举时序对齐查无用户名时对本种子编出的假 hash 跑一次 matches
* 用户名存在/不存在两分支耗时不可区分 hash 由本类 passwordEncoder 惰性编码
* 与真实存储 hash cost 一致才能真正对齐耗时而非硬编码某 cost 造成新的耗时差
*/
private static final String FAKE_MATCH_SEED = "passport-fake-timing-seed";
@Resource
private SmsCodeService smsCodeService;
@Resource
@ -65,7 +82,16 @@ public class PassportServiceImpl implements PassportService {
@Resource
private SecurityProperties securityProperties; // mock 豁免判定依据§6.3
@Resource
private PassportSmsIpCounterRedisDAO smsIpCounterRedisDAO; // R6 IP 频控§7.2
private PassportSmsIpCounterRedisDAO smsIpCounterRedisDAO; // 应用层纯 IP 频控R6 + WU1 泛化 scene
@Resource
private PasswordEncoder passwordEncoder; // BCrypt 编码/校验复用 admin 范式§3.2
@Resource
private PassportRegisterOutboxMapper registerOutboxMapper; // 注册成功事件 outbox 生产者§3.6
/**
* 惰性缓存的固定假 BCrypt hash防枚举时序对齐用首次登录查无用户名时以本类 encoder 编出cost 与真实 hash 一致
*/
private volatile String fakeMatchHash;
/**
* 发码 IP 每小时上限R6 §7.2推断初值 10 /小时/IP可配置与运营确认后调整
@ -110,20 +136,42 @@ public class PassportServiceImpl implements PassportService {
@Value("${wanxiang.passport.sms-mock-code:8888}")
private String smsMockCode;
/**
* 用户名密码注册+登录总开关§3.4staging true缺省 false关闭时两端点返回 PASSWORD_AUTH_DISABLED
* 信任边界在后端服务层不靠前端隐藏 Tab
*/
@Value("${wanxiang.passport.password-auth-enabled:false}")
private boolean passwordAuthEnabled;
/**
* 注册场景 IP 每小时上限§3.4 推断初值 20注册比发码宽松一档与运营确认后调用于 password-register invite-register 回填
*/
@Value("${wanxiang.passport.register-ip-limit-per-hour:20}")
private int registerIpLimitPerHour;
/**
* 注册场景 IP 每日上限§3.4 推断初值 60
*/
@Value("${wanxiang.passport.register-ip-limit-per-day:60}")
private int registerIpLimitPerDay;
/**
* 登录场景 IP 每小时上限§3.4 推断初值 30用于 password-login sms-login 回填
*/
@Value("${wanxiang.passport.login-ip-limit-per-hour:30}")
private int loginIpLimitPerHour;
/**
* 登录场景 IP 每日上限§3.4 推断初值 100
*/
@Value("${wanxiang.passport.login-ip-limit-per-day:100}")
private int loginIpLimitPerDay;
@Override
public void sendSmsCode(String mobile, String clientIp) {
// R6 §7.2先做 IP 维度频控每小时/每日上限不改上游 SmsCodeServiceImpl手机号维度频控由其内部承载
// 计数即自增先增后判超限抛 TOO_MANY_REQUESTS复用全局错误码避免新增
if (StringUtils.hasText(clientIp)) {
long hourCount = smsIpCounterRedisDAO.incrHourAndGet(clientIp);
long dayCount = smsIpCounterRedisDAO.incrDayAndGet(clientIp);
if (hourCount > smsIpLimitPerHour || dayCount > smsIpLimitPerDay) {
log.warn("[sendSmsCode] 发码 IP 频控触发 ip={}, hourCount={}/{}, dayCount={}/{}",
clientIp, hourCount, smsIpLimitPerHour, dayCount, smsIpLimitPerDay);
// 不区分小时/统一返回请求过快与防枚举一致不泄露内部阈值细节
throw exception(GlobalErrorCodeConstants.TOO_MANY_REQUESTS);
}
}
// 计数即自增先增后判超限抛 TOO_MANY_REQUESTS复用全局错误码避免新增WU1 泛化scene=sms
checkIpLimit(PassportSmsIpCounterRedisDAO.SCENE_SMS, clientIp, smsIpLimitPerHour, smsIpLimitPerDay);
// 仅内测/staging免真实短信验证开关开启时发码不依赖真实网关mock 受理成功直接返回
// 原因staging 未配短信渠道/模板 SmsCodeService.sendSmsCode 会因渠道/模板缺失抛异常发不出码
// 开启后 sms-login 用固定测试码 smsMockCode 登录 smsLogin故此处无需真实落库验证码
@ -146,6 +194,8 @@ public class PassportServiceImpl implements PassportService {
@Override
@Transactional(rollbackFor = Exception.class) // 自动注册建号与验证码使用需同一本地事务避免验证码已用但建号失败的半截态
public LoginRespVO smsLogin(SmsLoginReqVO reqVO, String clientIp) {
// WU1 §3.3 回填此前 sms-login 裸奔无 IP 补服务层纯 IP 计数scene=login password-login 同桶越限 429
checkIpLimit(PassportSmsIpCounterRedisDAO.SCENE_LOGIN, clientIp, loginIpLimitPerHour, loginIpLimitPerDay);
// 1) 校验并使用验证码场景 MEMBER_LOGIN不存在/过期/已用由 SmsCodeService 抛精确错误码
// 仅内测/staging免短信验证开关开启 提交码==固定测试码 smsMockCode 跳过 system_sms_code 表校验
// 直接视为验证通过任意合法手机号 + 固定码即可登录/自动注册否则一律走真实校验
@ -166,8 +216,8 @@ public class PassportServiceImpl implements PassportService {
// 2) 已注册登录未注册自动注册register_channel=sms验证码即占有证明拍板3
PlayerDO player = playerMapper.selectByMobile(reqVO.getMobile());
if (player == null) {
// sms-login 自动注册不收昵称验证码路径无昵称入参 null 走默认生成
player = registerPlayer(reqVO.getMobile(), "sms", null, reqVO.getAnonId(), null, clientIp);
// sms-login 自动注册不收昵称验证码路径无昵称入参 null 走默认生成用户名/密码路参传 null手机号路
player = registerPlayer(reqVO.getMobile(), null, null, "sms", null, reqVO.getAnonId(), null, clientIp);
} else {
// 已注册禁用态拒登录正常态刷新最近登录时间
assertPlayerEnabled(player);
@ -184,6 +234,8 @@ public class PassportServiceImpl implements PassportService {
if (!inviteRegisterEnabled) {
throw exception(INVITE_REGISTER_DISABLED);
}
// WU1 §3.3 回填此前 invite-register 裸奔无 IP 补服务层纯 IP 计数scene=invite注册档阈值越限 429
checkIpLimit(PassportSmsIpCounterRedisDAO.SCENE_INVITE, clientIp, registerIpLimitPerHour, registerIpLimitPerDay);
// 2) 手机号已注册 提示直接验证码登录invite-register 路径明确报已注册 sms-login 防枚举口径区分
PlayerDO exist = playerMapper.selectByMobile(reqVO.getMobile());
if (exist != null) {
@ -191,31 +243,86 @@ public class PassportServiceImpl implements PassportService {
}
// 3) 核销邀请码条件更新原子累加同事务耗尽/停用/过期/不存在抛 1-002-091 段错误码 整体回滚
Long inviteCodeId = inviteCodeService.validateAndRedeem(reqVO.getInviteCode());
// 4) 建号register_channel=invite关联核销的 invite_code_id+ token
PlayerDO player = registerPlayer(reqVO.getMobile(), "invite", inviteCodeId,
// 4) 建号register_channel=invite关联核销的 invite_code_id用户名/密码路参传 null+ token
PlayerDO player = registerPlayer(reqVO.getMobile(), null, null, "invite", inviteCodeId,
reqVO.getAnonId(), reqVO.getNickname(), clientIp);
return buildLoginResp(player);
}
@Override
@Transactional(rollbackFor = Exception.class) // 建号 + outbox 写入需同一本地事务注册成功事件与建号原子落库§3.6
public LoginRespVO passwordRegister(PasswordRegisterReqVO reqVO, String clientIp) {
// 1) 总开关§3.4 信任边界=后端关闭时拒服务不靠前端隐藏 Tab
if (!passwordAuthEnabled) {
throw exception(PASSWORD_AUTH_DISABLED);
}
// 2) 应用层纯 IP 计数防刷scene=register先增后判越限 429IP 取连接层 remoteAddr controller 传入不采信 XFF
checkIpLimit(PassportSmsIpCounterRedisDAO.SCENE_REGISTER, clientIp, registerIpLimitPerHour, registerIpLimitPerDay);
// 3) 密码 BCrypt 编码复用 admin 范式 passwordEncoder.encode§3.2明文用后即弃绝不落库/落日志
String encodedPassword = passwordEncoder.encode(reqVO.getPassword());
// 4) 建号register_channel=passwordmobile=null 走用户名路+ 事务内写 outbox + token
// 用户名撞 uk_username registerPlayer PLAYER_USERNAME_ALREADY_REGISTERED注册态不静默转登录
PlayerDO player = registerPlayer(null, reqVO.getUsername(), encodedPassword, CHANNEL_PASSWORD, null,
reqVO.getAnonId(), reqVO.getNickname(), clientIp);
return buildLoginResp(player);
}
@Override
public LoginRespVO passwordLogin(PasswordLoginReqVO reqVO, String clientIp) {
// 1) 总开关关闭拒服务
if (!passwordAuthEnabled) {
throw exception(PASSWORD_AUTH_DISABLED);
}
// 2) 应用层纯 IP 计数scene=login sms-login 同桶越限 429
checkIpLimit(PassportSmsIpCounterRedisDAO.SCENE_LOGIN, clientIp, loginIpLimitPerHour, loginIpLimitPerDay);
// 3) 查用户名命中 uk_username
PlayerDO player = playerMapper.selectByUsername(reqVO.getUsername());
if (player == null) {
// 防枚举§3.2查无用户名时也对固定假 hash 跑一次 matches用户名存在/不存在耗时不可区分
// 堵住登录接口的时序侧信道随后与密码错误统一返 PLAYER_USERNAME_OR_PASSWORD_ERROR不给区分
passwordEncoder.matches(reqVO.getPassword(), getFakeMatchHash());
log.info("[passwordLogin] 登录失败:用户名不存在(防枚举统一错误码 + 假 hash 对齐耗时username={}", reqVO.getUsername());
throw exception(PLAYER_USERNAME_OR_PASSWORD_ERROR);
}
// 4) 校验密码BCrypt matches不匹配与查无统一错误码绝不区分
if (!passwordEncoder.matches(reqVO.getPassword(), player.getPassword())) {
log.info("[passwordLogin] 登录失败密码错误防枚举统一错误码userId={}", player.getId());
throw exception(PLAYER_USERNAME_OR_PASSWORD_ERROR);
}
// 5) 禁用态拒登录正常态刷新最近登录时间
assertPlayerEnabled(player);
playerMapper.updateLoginDate(player.getId(), LocalDateTime.now());
// 6) 发真 OAuth2 token与短信路一字不差
return buildLoginResp(player);
}
/**
* 建玩家自动注册 / 邀请码注册共用
* 建玩家sms 自动注册 / invite 邀请码注册 / password 用户名密码注册三路共用
*
* 竞态兜底uk_mobile 唯一键冲突并发同号注册 回查转登录语义与防枚举口径协调
* invite 通道命中冲突属极端竞态前置已查无回查后按已存在玩家继续邀请码已核销不退 WARN
* 落列按路径手机号路sms/invite mobileusername/password null用户名路password username/encodedPassword
* mobile null默认昵称 nickname 用之否则用户名路取 username 本身手机号路取手机号后 4
* 竞态兜底按 channel 区分冲突语义
* - 手机号路 uk_mobile 冲突 回查转登录与防枚举口径协调
* - 用户名路 uk_username 冲突 PLAYER_USERNAME_ALREADY_REGISTERED注册态不能撞名就送进别人账号不转登录
* 注册成功事件insert 成功后在**同一事务内**原子写一行 outbox§3.6三路共用出口投递器事务外发 MQ至少一次
*
* @param mobile 手机号
* @param channel 注册通道sms / invite
* @param inviteCodeId 核销的邀请码 IDsms 通道传 null
* @param anonId 客户端 anonId仅注册时落 first_anon_id缺失落空串
* @param nickname 昵称不传则默认生成
* @param clientIp 注册 IP
* @param mobile 手机号用户名路传 null
* @param username 用户名手机号路传 null
* @param encodedPassword BCrypt 编码的密码密文手机号路传 null
* @param channel 注册通道sms / invite / password
* @param inviteCodeId 核销的邀请码 IDsms/password 通道传 null
* @param anonId 客户端 anonId仅注册时落 first_anon_id缺失落空串
* @param nickname 昵称不传则默认生成
* @param clientIp 注册 IP
* @return 玩家 DO含回填 id
*/
private PlayerDO registerPlayer(String mobile, String channel, Long inviteCodeId,
String anonId, String nickname, String clientIp) {
private PlayerDO registerPlayer(String mobile, String username, String encodedPassword, String channel,
Long inviteCodeId, String anonId, String nickname, String clientIp) {
PlayerDO player = new PlayerDO();
player.setMobile(mobile);
player.setNickname(StringUtils.hasText(nickname) ? nickname : generateNickname(mobile));
player.setUsername(username);
player.setPassword(encodedPassword); // 密文不落日志
player.setNickname(resolveNickname(nickname, username, mobile));
player.setAvatar("");
player.setStatus(CommonStatusEnum.ENABLE.getStatus()); // 0 正常
player.setCreatorFlag(0); // 默认玩家非创作者A2 白名单由 admin set-creator 后置
@ -225,7 +332,7 @@ public class PassportServiceImpl implements PassportService {
// 目标3anonId 仅注册时落一次缺失落空串归因降级不阻断登录§12
player.setFirstAnonId(StringUtils.hasText(anonId) ? anonId : "");
player.setLoginDate(LocalDateTime.now());
// 2026-06-10 鉴权波 e2e 实证P0-1免登注册sms-login 自动注册 / invite-register 共用此点@PermitAll 无登录态
// 2026-06-10 鉴权波 e2e 实证P0-1免登注册三路共用此点@PermitAll 无登录态
// 且注册者本人 id 此刻尚未生成DefaultDBFieldHandler.insertFill 仅在 getLoginUserId()!=null 时填 creator/updater
// huijing-spring-boot-starter-mybatis .../handler/DefaultDBFieldHandler.java:38-43匿名两列保持 null NOT NULL 500
// 故无条件显式兜底 "0"与系统身份 id=0 同口径显式值优先于 handler isNull 才填不覆盖
@ -233,11 +340,19 @@ public class PassportServiceImpl implements PassportService {
player.setUpdater("0");
try {
playerMapper.insert(player);
log.info("[registerPlayer] 新玩家注册成功 userId={}, channel={}, mobile={}, hasAnonId={}",
player.getId(), channel, DesensitizedUtil.mobilePhone(mobile), StringUtils.hasText(anonId));
log.info("[registerPlayer] 新玩家注册成功 userId={}, channel={}, mobile={}, hasUsername={}, hasAnonId={}",
player.getId(), channel, DesensitizedUtil.mobilePhone(mobile),
StringUtils.hasText(username), StringUtils.hasText(anonId));
// §3.6 注册成功事件生产者同一事务内原子写 outbox建号成功则 outbox 必落库投递器保证最终发出
writeRegisterOutbox(player.getId(), channel, anonId);
return player;
} catch (DuplicateKeyException e) {
// uk_mobile 并发冲突回查转登录同手机号竞态兜底§12
// 用户名路password冲突必是 uk_usernamemobile=null 不触发 uk_mobile 明确报占用不转登录
if (CHANNEL_PASSWORD.equals(channel)) {
log.warn("[registerPlayer] 用户名并发注册冲突uk_username报占用不转登录 username={}", username);
throw exception(PLAYER_USERNAME_ALREADY_REGISTERED);
}
// 手机号路 uk_mobile 并发冲突回查转登录同手机号竞态兜底§12
log.warn("[registerPlayer] 手机号并发注册冲突,转登录语义 mobile={}, channel={}",
DesensitizedUtil.mobilePhone(mobile), channel);
PlayerDO existed = playerMapper.selectByMobile(mobile);
@ -250,6 +365,23 @@ public class PassportServiceImpl implements PassportService {
}
}
/**
* 注册成功事件 outbox 生产者§3.6注册事务内与建号原子写一行未投递记录事务外投递器扫描发 RocketMQ
* 幂等键 = register:{userId}uk_idempotent_key 保证每次注册至多一行creator/updater 显式兜底 "0"免登无登录态
*/
private void writeRegisterOutbox(Long userId, String channel, String anonId) {
PassportRegisterOutboxDO outbox = new PassportRegisterOutboxDO();
outbox.setUserId(userId);
outbox.setRegisterChannel(channel);
outbox.setAnonId(StringUtils.hasText(anonId) ? anonId : "");
outbox.setIdempotentKey("register:" + userId);
outbox.setStatus(PassportRegisterOutboxDO.STATUS_PENDING);
outbox.setCreator("0");
outbox.setUpdater("0");
registerOutboxMapper.insert(outbox);
log.info("[registerPlayer] 注册成功事件已写 outbox待投递器发 MQuserId={}, channel={}", userId, channel);
}
/**
* 校验玩家未被禁用禁用态拒登录
*/
@ -259,6 +391,20 @@ public class PassportServiceImpl implements PassportService {
}
}
/**
* 解析注册默认昵称§3.2 nickname 用之否则用户名路取 username 本身手机号路取玩家+手机号后4位
*/
private static String resolveNickname(String nickname, String username, String mobile) {
if (StringUtils.hasText(nickname)) {
return nickname;
}
// 用户名路无手机号直接以用户名为默认昵称避免生成玩家空后缀
if (StringUtils.hasText(username)) {
return username;
}
return generateNickname(mobile);
}
/**
* 生成默认昵称玩家+手机号后4位手机号不足4位时取全量兜底空串
*/
@ -268,6 +414,46 @@ public class PassportServiceImpl implements PassportService {
return DEFAULT_NICKNAME_PREFIX + suffix;
}
/**
* 应用层纯 IP 计数防刷§3.3 scene 分桶先增后判/日任一超限抛 TOO_MANY_REQUESTS复用全局 429不为限流新造码
* clientIp 空则跳过 IP 无从计数Redis 异常由计数组件内部 fail-open 放行返回 0本方法据此不误拒
*
* @param scene 场景{@link PassportSmsIpCounterRedisDAO#SCENE_SMS}
* @param clientIp 连接层 IPpassword 路取 request.getRemoteAddr()不采信 XFF
* @param hourLimit 每小时上限
* @param dayLimit 每日上限
*/
private void checkIpLimit(String scene, String clientIp, int hourLimit, int dayLimit) {
if (!StringUtils.hasText(clientIp)) {
return;
}
long hourCount = smsIpCounterRedisDAO.incrHourAndGet(scene, clientIp);
long dayCount = smsIpCounterRedisDAO.incrDayAndGet(scene, clientIp);
if (hourCount > hourLimit || dayCount > dayLimit) {
// 越限留痕含 scene+IP+计数§5 验收限流 WARN 可审计不区分小时/统一返回请求过快不泄露内部阈值
log.warn("[checkIpLimit] IP 频控触发 scene={}, ip={}, hourCount={}/{}, dayCount={}/{}",
scene, clientIp, hourCount, hourLimit, dayCount, dayLimit);
throw exception(GlobalErrorCodeConstants.TOO_MANY_REQUESTS);
}
}
/**
* 获取固定假 BCrypt hash§3.2 防枚举时序对齐惰性以本类 passwordEncoder 编出一次并缓存
* cost 与真实存储 hash 一致查无用户名时对它跑 matches 才能真正对齐 BCrypt 耗时
*/
private String getFakeMatchHash() {
String cached = fakeMatchHash;
if (cached == null) {
synchronized (this) {
if (fakeMatchHash == null) {
fakeMatchHash = passwordEncoder.encode(FAKE_MATCH_SEED);
}
cached = fakeMatchHash;
}
}
return cached;
}
/**
* OAuth2 token 并组装登录响应token 不落日志
*/
@ -297,7 +483,10 @@ public class PassportServiceImpl implements PassportService {
}
PlayerMeRespVO resp = new PlayerMeRespVO();
resp.setUserId(player.getId());
resp.setMobile(DesensitizedUtil.mobilePhone(player.getMobile())); // 安全基线脱敏返回禁回明文
// 安全基线手机号脱敏返回禁回明文WU1 §3.2 空安全用户名用户 mobile null短路回空串不把 null 塞进脱敏函数 NPE 回归
resp.setMobile(StringUtils.hasText(player.getMobile()) ? DesensitizedUtil.mobilePhone(player.getMobile()) : "");
// WU1 username明文非敏感用户名用户前端据此展示非手机号身份手机号用户 username null
resp.setUsername(player.getUsername());
resp.setNickname(player.getNickname());
resp.setAvatar(player.getAvatar());
resp.setCreatorFlag(player.getCreatorFlag());

View File

@ -0,0 +1,73 @@
package com.wanxiang.huijing.module.system.dal.redis.passport;
import com.wanxiang.huijing.framework.test.core.ut.BaseMockitoUnitTest;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.springframework.data.redis.RedisConnectionFailureException;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
/**
* {@link PassportSmsIpCounterRedisDAO} 单元测试 Mockito不依赖真实 Redis
*
* 重点验证 §3.3 定死的频控后端故障 fail-open姿态Redis 抖动/超时时不外抛返回 0放行
* 绝不让 Redis 单点静默拖垮认证正常路径返回自增值并对首次计数设 TTL
*
* @author 绘境AI
*/
class PassportSmsIpCounterRedisDAOTest extends BaseMockitoUnitTest {
@InjectMocks
private PassportSmsIpCounterRedisDAO counterDAO;
@Mock
private StringRedisTemplate stringRedisTemplate;
@Mock
private ValueOperations<String, String> valueOperations;
@Test
@DisplayName("小时计数Redis 异常 → fail-open 返回 0放行不外抛")
void testIncrHourAndGet_redisFailOpen() {
when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations);
// 模拟 Redis 抖动/超时increment 抛连接异常
when(valueOperations.increment(anyString()))
.thenThrow(new RedisConnectionFailureException("redis down"));
long[] result = new long[1];
assertDoesNotThrow(() -> result[0] = counterDAO.incrHourAndGet(
PassportSmsIpCounterRedisDAO.SCENE_LOGIN, "1.2.3.4"));
// fail-open返回 0服务层据此视为未超限放行
assertEquals(0L, result[0]);
}
@Test
@DisplayName("日计数Redis 异常 → fail-open 返回 0放行不外抛")
void testIncrDayAndGet_redisFailOpen() {
when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations);
when(valueOperations.increment(anyString()))
.thenThrow(new RedisConnectionFailureException("redis down"));
long[] result = new long[1];
assertDoesNotThrow(() -> result[0] = counterDAO.incrDayAndGet(
PassportSmsIpCounterRedisDAO.SCENE_REGISTER, "1.2.3.4"));
assertEquals(0L, result[0]);
}
@Test
@DisplayName("正常路径increment 返回自增值DAO 原样透传")
void testIncrHourAndGet_normal() {
when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations);
when(valueOperations.increment(anyString())).thenReturn(5L); // 非首次不设 TTL
long count = counterDAO.incrHourAndGet(PassportSmsIpCounterRedisDAO.SCENE_SMS, "1.2.3.4");
assertEquals(5L, count);
}
}

View File

@ -6,9 +6,13 @@ import com.wanxiang.huijing.framework.security.config.SecurityProperties;
import com.wanxiang.huijing.framework.test.core.ut.BaseMockitoUnitTest;
import com.wanxiang.huijing.module.system.controller.app.passport.vo.InviteRegisterReqVO;
import com.wanxiang.huijing.module.system.controller.app.passport.vo.LoginRespVO;
import com.wanxiang.huijing.module.system.controller.app.passport.vo.PasswordLoginReqVO;
import com.wanxiang.huijing.module.system.controller.app.passport.vo.PasswordRegisterReqVO;
import com.wanxiang.huijing.module.system.controller.app.passport.vo.SmsLoginReqVO;
import com.wanxiang.huijing.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO;
import com.wanxiang.huijing.module.system.dal.dataobject.passport.PassportRegisterOutboxDO;
import com.wanxiang.huijing.module.system.dal.dataobject.passport.PlayerDO;
import com.wanxiang.huijing.module.system.dal.mysql.passport.PassportRegisterOutboxMapper;
import com.wanxiang.huijing.module.system.dal.mysql.passport.PlayerMapper;
import com.wanxiang.huijing.module.system.dal.redis.passport.PassportSmsIpCounterRedisDAO;
import com.wanxiang.huijing.module.system.enums.oauth2.OAuth2ClientConstants;
@ -21,15 +25,20 @@ import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDateTime;
import static com.wanxiang.huijing.framework.common.exception.enums.GlobalErrorCodeConstants.TOO_MANY_REQUESTS;
import static com.wanxiang.huijing.module.system.dal.redis.passport.PassportSmsIpCounterRedisDAO.SCENE_LOGIN;
import static com.wanxiang.huijing.module.system.dal.redis.passport.PassportSmsIpCounterRedisDAO.SCENE_REGISTER;
import static com.wanxiang.huijing.module.system.dal.redis.passport.PassportSmsIpCounterRedisDAO.SCENE_SMS;
import static com.wanxiang.huijing.module.system.enums.ErrorCodeConstants.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
@ -58,6 +67,10 @@ class PassportServiceImplTest extends BaseMockitoUnitTest {
private SecurityProperties securityProperties;
@Mock
private PassportSmsIpCounterRedisDAO smsIpCounterRedisDAO;
@Mock
private PasswordEncoder passwordEncoder;
@Mock
private PassportRegisterOutboxMapper registerOutboxMapper;
@BeforeEach
void setUp() {
@ -68,6 +81,12 @@ class PassportServiceImplTest extends BaseMockitoUnitTest {
// 免短信验证开关默认 false与生产缺省一致mock 路径用例在各自 case 内显式置 true
ReflectionTestUtils.setField(passportService, "smsMockEnabled", false);
ReflectionTestUtils.setField(passportService, "smsMockCode", "8888");
// WU1 用户名密码路配置默认开启 + 注册/登录 IP 阈值 application-staging 初值一致
ReflectionTestUtils.setField(passportService, "passwordAuthEnabled", true);
ReflectionTestUtils.setField(passportService, "registerIpLimitPerHour", 20);
ReflectionTestUtils.setField(passportService, "registerIpLimitPerDay", 60);
ReflectionTestUtils.setField(passportService, "loginIpLimitPerHour", 30);
ReflectionTestUtils.setField(passportService, "loginIpLimitPerDay", 100);
}
// ============================== sendSmsCode IP 频控 ==============================
@ -75,8 +94,8 @@ class PassportServiceImplTest extends BaseMockitoUnitTest {
@Test
@DisplayName("发码IP 未超限 → 透传 SmsCodeService 发码(场景 MEMBER_LOGIN")
void testSendSmsCode_underLimit() {
when(smsIpCounterRedisDAO.incrHourAndGet("1.2.3.4")).thenReturn(1L);
when(smsIpCounterRedisDAO.incrDayAndGet("1.2.3.4")).thenReturn(1L);
when(smsIpCounterRedisDAO.incrHourAndGet(SCENE_SMS, "1.2.3.4")).thenReturn(1L);
when(smsIpCounterRedisDAO.incrDayAndGet(SCENE_SMS, "1.2.3.4")).thenReturn(1L);
passportService.sendSmsCode("13800000000", "1.2.3.4");
@ -91,8 +110,8 @@ class PassportServiceImplTest extends BaseMockitoUnitTest {
@Test
@DisplayName("发码IP 每小时超限 → 抛 429 且不发码(不区分小时/日,防泄露阈值)")
void testSendSmsCode_hourLimitExceeded() {
when(smsIpCounterRedisDAO.incrHourAndGet("1.2.3.4")).thenReturn(11L); // > 10
when(smsIpCounterRedisDAO.incrDayAndGet("1.2.3.4")).thenReturn(11L);
when(smsIpCounterRedisDAO.incrHourAndGet(SCENE_SMS, "1.2.3.4")).thenReturn(11L); // > 10
when(smsIpCounterRedisDAO.incrDayAndGet(SCENE_SMS, "1.2.3.4")).thenReturn(11L);
ServiceException ex = assertThrows(ServiceException.class,
() -> passportService.sendSmsCode("13800000000", "1.2.3.4"));
@ -104,8 +123,8 @@ class PassportServiceImplTest extends BaseMockitoUnitTest {
@Test
@DisplayName("发码IP 每日超限 → 抛 429 且不发码")
void testSendSmsCode_dayLimitExceeded() {
when(smsIpCounterRedisDAO.incrHourAndGet("1.2.3.4")).thenReturn(3L); // 小时未超
when(smsIpCounterRedisDAO.incrDayAndGet("1.2.3.4")).thenReturn(31L); // 日超 30
when(smsIpCounterRedisDAO.incrHourAndGet(SCENE_SMS, "1.2.3.4")).thenReturn(3L); // 小时未超
when(smsIpCounterRedisDAO.incrDayAndGet(SCENE_SMS, "1.2.3.4")).thenReturn(31L); // 日超 30
ServiceException ex = assertThrows(ServiceException.class,
() -> passportService.sendSmsCode("13800000000", "1.2.3.4"));
@ -119,15 +138,15 @@ class PassportServiceImplTest extends BaseMockitoUnitTest {
@DisplayName("免短信发码:开关开 → 跳过真实发码mock 受理)+ IP 频控仍生效")
void testSendSmsCode_mockEnabledSkipsRealSend() {
ReflectionTestUtils.setField(passportService, "smsMockEnabled", true);
when(smsIpCounterRedisDAO.incrHourAndGet("1.2.3.4")).thenReturn(1L);
when(smsIpCounterRedisDAO.incrDayAndGet("1.2.3.4")).thenReturn(1L);
when(smsIpCounterRedisDAO.incrHourAndGet(SCENE_SMS, "1.2.3.4")).thenReturn(1L);
when(smsIpCounterRedisDAO.incrDayAndGet(SCENE_SMS, "1.2.3.4")).thenReturn(1L);
passportService.sendSmsCode("13800000000", "1.2.3.4");
// 开关开不调用真实发码不依赖短信渠道/模板规避 staging 无网关失败
verify(smsCodeService, never()).sendSmsCode(any());
// IP 频控仍执行先增后判防刷不被绕过
verify(smsIpCounterRedisDAO).incrHourAndGet("1.2.3.4");
verify(smsIpCounterRedisDAO).incrHourAndGet(SCENE_SMS, "1.2.3.4");
}
@Test
@ -193,8 +212,8 @@ class PassportServiceImplTest extends BaseMockitoUnitTest {
@DisplayName("免短信发码:开关关(生产缺省)→ 照常走真实发码(生产不受影响)")
void testSendSmsCode_mockDisabledUsesRealSend() {
// smsMockEnabled 默认 falsesetUp 已置
when(smsIpCounterRedisDAO.incrHourAndGet("1.2.3.4")).thenReturn(1L);
when(smsIpCounterRedisDAO.incrDayAndGet("1.2.3.4")).thenReturn(1L);
when(smsIpCounterRedisDAO.incrHourAndGet(SCENE_SMS, "1.2.3.4")).thenReturn(1L);
when(smsIpCounterRedisDAO.incrDayAndGet(SCENE_SMS, "1.2.3.4")).thenReturn(1L);
passportService.sendSmsCode("13800000000", "1.2.3.4");
@ -395,6 +414,217 @@ class PassportServiceImplTest extends BaseMockitoUnitTest {
assertEquals(PLAYER_NOT_EXISTS.getCode(), ex.getCode());
}
// ============================== 内测用户名密码注册WU1 §3.2==============================
@Test
@DisplayName("用户名注册成功建号channel=password / username / BCrypt 密文 / mobile=null+ 事务内原子写 outbox")
void testPasswordRegister_success() {
PasswordRegisterReqVO reqVO = new PasswordRegisterReqVO();
reqVO.setUsername("seedalice");
reqVO.setPassword("secret6");
reqVO.setAnonId("anon-p");
when(passwordEncoder.encode("secret6")).thenReturn("$2a$10$encodedhash");
doAnswer(inv -> {
PlayerDO d = inv.getArgument(0);
d.setId(2048L);
return 1;
}).when(playerMapper).insert(any(PlayerDO.class));
stubToken(2048L);
LoginRespVO resp = passportService.passwordRegister(reqVO, "9.9.9.9");
// 建号channel=passwordusername 落列password BCrypt 密文mobile=null用户名路默认昵称取用户名
ArgumentCaptor<PlayerDO> pc = ArgumentCaptor.forClass(PlayerDO.class);
verify(playerMapper).insert(pc.capture());
assertEquals("password", pc.getValue().getRegisterChannel());
assertEquals("seedalice", pc.getValue().getUsername());
assertEquals("$2a$10$encodedhash", pc.getValue().getPassword());
assertNull(pc.getValue().getMobile());
assertEquals("seedalice", pc.getValue().getNickname()); // nickname 用户名路取 username
assertEquals("anon-p", pc.getValue().getFirstAnonId());
assertEquals("0", pc.getValue().getCreator());
// §3.6注册成功事件与建号原子写 outbox幂等键 register:{userId}未投递态
ArgumentCaptor<PassportRegisterOutboxDO> oc = ArgumentCaptor.forClass(PassportRegisterOutboxDO.class);
verify(registerOutboxMapper).insert(oc.capture());
assertEquals(2048L, oc.getValue().getUserId());
assertEquals("password", oc.getValue().getRegisterChannel());
assertEquals("register:2048", oc.getValue().getIdempotentKey());
assertEquals(PassportRegisterOutboxDO.STATUS_PENDING, oc.getValue().getStatus());
// 发真 token与短信路一字不差
assertEquals(2048L, resp.getUserId());
assertEquals("happy-token", resp.getAccessToken());
}
@Test
@DisplayName("用户名注册:并发撞 uk_username → 报 PLAYER_USERNAME_ALREADY_REGISTERED不转登录、不写 outbox")
void testPasswordRegister_usernameAlreadyRegistered() {
PasswordRegisterReqVO reqVO = new PasswordRegisterReqVO();
reqVO.setUsername("seedalice");
reqVO.setPassword("secret6");
when(passwordEncoder.encode("secret6")).thenReturn("$2a$10$encodedhash");
// 模拟 uk_username 并发冲突
doThrow(new org.springframework.dao.DuplicateKeyException("uk_username"))
.when(playerMapper).insert(any(PlayerDO.class));
ServiceException ex = assertThrows(ServiceException.class,
() -> passportService.passwordRegister(reqVO, "9.9.9.9"));
assertEquals(PLAYER_USERNAME_ALREADY_REGISTERED.getCode(), ex.getCode());
// 注册态撞名不静默转登录不回查手机号不发 token不写 outbox
verify(playerMapper, never()).selectByMobile(any());
verify(oauth2TokenService, never()).createAccessToken(anyLong(), any(), any(), any());
verify(registerOutboxMapper, never()).insert(any(PassportRegisterOutboxDO.class));
}
@Test
@DisplayName("用户名注册:总开关关闭 → 抛 PASSWORD_AUTH_DISABLED信任边界=后端,连 IP 都不计)")
void testPasswordRegister_authDisabled() {
ReflectionTestUtils.setField(passportService, "passwordAuthEnabled", false);
PasswordRegisterReqVO reqVO = new PasswordRegisterReqVO();
reqVO.setUsername("seedalice");
reqVO.setPassword("secret6");
ServiceException ex = assertThrows(ServiceException.class,
() -> passportService.passwordRegister(reqVO, "9.9.9.9"));
assertEquals(PASSWORD_AUTH_DISABLED.getCode(), ex.getCode());
verify(playerMapper, never()).insert(any(PlayerDO.class));
verify(smsIpCounterRedisDAO, never()).incrHourAndGet(any(), any());
}
@Test
@DisplayName("用户名注册:同 IP 越注册档小时阈值 → 抛 429 且不建号")
void testPasswordRegister_ipLimitExceeded() {
PasswordRegisterReqVO reqVO = new PasswordRegisterReqVO();
reqVO.setUsername("seedalice");
reqVO.setPassword("secret6");
when(smsIpCounterRedisDAO.incrHourAndGet(SCENE_REGISTER, "9.9.9.9")).thenReturn(21L); // > 20
when(smsIpCounterRedisDAO.incrDayAndGet(SCENE_REGISTER, "9.9.9.9")).thenReturn(21L);
ServiceException ex = assertThrows(ServiceException.class,
() -> passportService.passwordRegister(reqVO, "9.9.9.9"));
assertEquals(TOO_MANY_REQUESTS.getCode(), ex.getCode());
verify(playerMapper, never()).insert(any(PlayerDO.class));
verify(registerOutboxMapper, never()).insert(any(PassportRegisterOutboxDO.class));
}
// ============================== 内测用户名密码登录WU1 §3.2 防枚举==============================
@Test
@DisplayName("用户名登录:用户名+密码正确 → 刷新登录时间 + 发 token")
void testPasswordLogin_success() {
PasswordLoginReqVO reqVO = new PasswordLoginReqVO();
reqVO.setUsername("seedalice");
reqVO.setPassword("secret6");
PlayerDO exist = player(2048L, 0, 0);
exist.setPassword("$2a$10$stored");
when(playerMapper.selectByUsername("seedalice")).thenReturn(exist);
when(passwordEncoder.matches("secret6", "$2a$10$stored")).thenReturn(true);
stubToken(2048L);
LoginRespVO resp = passportService.passwordLogin(reqVO, "9.9.9.9");
verify(playerMapper).updateLoginDate(eq(2048L), any(LocalDateTime.class));
assertEquals(2048L, resp.getUserId());
assertEquals("happy-token", resp.getAccessToken());
}
@Test
@DisplayName("用户名登录·防枚举:查无用户名 → 跑固定假 hash 对齐耗时 + 统一 PLAYER_USERNAME_OR_PASSWORD_ERROR")
void testPasswordLogin_usernameNotFound_antiEnumeration() {
PasswordLoginReqVO reqVO = new PasswordLoginReqVO();
reqVO.setUsername("ghost");
reqVO.setPassword("secret6");
when(playerMapper.selectByUsername("ghost")).thenReturn(null);
when(passwordEncoder.encode(anyString())).thenReturn("$2a$10$fakehash"); // getFakeMatchHash 惰性编码
ServiceException ex = assertThrows(ServiceException.class,
() -> passportService.passwordLogin(reqVO, "9.9.9.9"));
assertEquals(PLAYER_USERNAME_OR_PASSWORD_ERROR.getCode(), ex.getCode());
// 时序对齐查无用户名时也对固定假 hash 跑一次 matches消除存在性 oracle
verify(passwordEncoder).matches("secret6", "$2a$10$fakehash");
}
@Test
@DisplayName("用户名登录·防枚举:密码错误与查无用户名返回同一错误码(不可区分)")
void testPasswordLogin_wrongPassword_sameErrorCode() {
PasswordLoginReqVO reqVO = new PasswordLoginReqVO();
reqVO.setUsername("seedalice");
reqVO.setPassword("wrongpwd");
PlayerDO exist = player(2048L, 0, 0);
exist.setPassword("$2a$10$stored");
when(playerMapper.selectByUsername("seedalice")).thenReturn(exist);
when(passwordEncoder.matches("wrongpwd", "$2a$10$stored")).thenReturn(false);
ServiceException ex = assertThrows(ServiceException.class,
() -> passportService.passwordLogin(reqVO, "9.9.9.9"));
// 查无用户名同码不给区分防枚举
assertEquals(PLAYER_USERNAME_OR_PASSWORD_ERROR.getCode(), ex.getCode());
verify(oauth2TokenService, never()).createAccessToken(anyLong(), any(), any(), any());
}
@Test
@DisplayName("用户名登录:账号被禁用(密码正确)→ 抛 PLAYER_IS_DISABLED")
void testPasswordLogin_disabledRejected() {
PasswordLoginReqVO reqVO = new PasswordLoginReqVO();
reqVO.setUsername("seedalice");
reqVO.setPassword("secret6");
PlayerDO exist = player(2048L, 1, 0); // status=1 禁用
exist.setPassword("$2a$10$stored");
when(playerMapper.selectByUsername("seedalice")).thenReturn(exist);
when(passwordEncoder.matches("secret6", "$2a$10$stored")).thenReturn(true);
ServiceException ex = assertThrows(ServiceException.class,
() -> passportService.passwordLogin(reqVO, "9.9.9.9"));
assertEquals(PLAYER_IS_DISABLED.getCode(), ex.getCode());
verify(oauth2TokenService, never()).createAccessToken(anyLong(), any(), any(), any());
}
@Test
@DisplayName("用户名登录:总开关关闭 → 抛 PASSWORD_AUTH_DISABLED连查库都不做")
void testPasswordLogin_authDisabled() {
ReflectionTestUtils.setField(passportService, "passwordAuthEnabled", false);
PasswordLoginReqVO reqVO = new PasswordLoginReqVO();
reqVO.setUsername("seedalice");
reqVO.setPassword("secret6");
ServiceException ex = assertThrows(ServiceException.class,
() -> passportService.passwordLogin(reqVO, "9.9.9.9"));
assertEquals(PASSWORD_AUTH_DISABLED.getCode(), ex.getCode());
verify(playerMapper, never()).selectByUsername(any());
}
@Test
@DisplayName("用户名登录:同 IP 越登录档小时阈值 → 抛 429 且不查库")
void testPasswordLogin_ipLimitExceeded() {
PasswordLoginReqVO reqVO = new PasswordLoginReqVO();
reqVO.setUsername("seedalice");
reqVO.setPassword("secret6");
when(smsIpCounterRedisDAO.incrHourAndGet(SCENE_LOGIN, "9.9.9.9")).thenReturn(31L); // > 30
when(smsIpCounterRedisDAO.incrDayAndGet(SCENE_LOGIN, "9.9.9.9")).thenReturn(31L);
ServiceException ex = assertThrows(ServiceException.class,
() -> passportService.passwordLogin(reqVO, "9.9.9.9"));
assertEquals(TOO_MANY_REQUESTS.getCode(), ex.getCode());
verify(playerMapper, never()).selectByUsername(any());
}
// ============================== /me 空安全WU1 §3.2 用户名用户 mobile=null==============================
@Test
@DisplayName("/me用户名用户 mobile 为 null → 脱敏短路回空串(不 NPE+ 返回 username")
void testGetPlayerMe_usernameUserNullMobileSafe() {
PlayerDO player = new PlayerDO();
player.setId(2048L);
player.setMobile(null); // 用户名用户无手机号
player.setUsername("seedalice");
player.setNickname("seedalice");
player.setCreatorFlag(0);
when(playerMapper.selectById(2048L)).thenReturn(player);
var resp = assertDoesNotThrow(() -> passportService.getPlayerMe(2048L));
assertEquals("", resp.getMobile()); // null 短路回空串不把 null 塞进脱敏函数
assertEquals("seedalice", resp.getUsername());
}
// ============================== 测试夹具 ==============================
/** stub 发 token 返回固定 access/refresh/过期时间 */

View File

@ -201,3 +201,9 @@ wanxiang:
# ⚠ 公网上线前必须置 false / 删除本项(=实名/短信网关日历闸门):开启=任何人凭 8888 登录任意手机号。
sms-mock-enabled: true # ⚠ 仅 staging 内测:免真实短信验证(缺省 false生产绝不开启
sms-mock-code: "8888" # ⚠ 仅 staging 内测:免短信验证的固定测试码(配合 sms-mock-enabled
# ========== 内测·用户名密码注册登录2026-07-07 WU1 §3.4==========
password-auth-enabled: true # 用户名密码注册+登录总开关staging 内测开启;缺省 false信任边界=后端服务层,不靠前端隐藏 Tab
register-ip-limit-per-hour: 20 # 注册场景 IP 每小时上限(推断初值,注册比发码宽松一档,与运营确认后调)
register-ip-limit-per-day: 60 # 注册场景 IP 每日上限(推断初值)
login-ip-limit-per-hour: 30 # 登录场景 IP 每小时上限推断初值sms-login 与 password-login 同桶 scene=login
login-ip-limit-per-day: 100 # 登录场景 IP 每日上限(推断初值)

View File

@ -0,0 +1,52 @@
-- =============================================================================
-- 契约 #2 DB 迁移 | 主题:内测·用户名+密码注册登录WU12026-07-07 设计 §3.1/§3.6| ownerpassportsystem 内扩展)
-- 文件V31.0.0__passport_player_add_username_password.sqlFlyway只 ALTER/新增;已合入禁止修改,回滚见补偿迁移 V31.0.1
-- 依据docs/agent-specs/2026-07-07-内测-WU1-注册登录用户名密码-设计.md草案创始人内测口径拍定
-- 版本定序:执行副本 db/migration/ 实测已连续到 V30.0.0(含 V26 aigc idempotency本件取其上下一号 V31.0.0(已 ls|sort -V|tail 复核)。
-- 守门①(沿用 V11本件同时落 contracts/db-schemas/(契约源)+ huijing-server 执行副本(唯一执行 classpath
-- 不放任何单模块 -server/db/migration/(避免同版本 V31 出现在多个 classpath jar 触发 Flyway 重复校验失败)。
-- 守门②:含中文 SQLmini-desktop 执行必须 --default-character-set=utf8mb4。
-- 内容两段:
-- 1. ALTER game_player —— 加 username/password 两列、放宽 mobile 可空、加 uk_username、register_channel 值域扩 password
-- 2. CREATE game_passport_register_outbox —— 「注册成功」事件本地消息表三条注册路共用出口至少一次投递生产者§3.6)。
-- =============================================================================
-- -----------------------------------------------------------------------------
-- 段 1ALTER game_player —— 单表双身份路径共存(手机号 / 用户名 二选一§3.1
-- 关键mobile 由 NOT NULL 放宽为 NULL承载无手机号的用户名用户uk_mobile 对 NULL 行不施约束,
-- uk_username 对 NULL 行(手机号用户)不施约束,两条唯一键在同表互不干扰。
-- 存量兼容:存量行 mobile 均有值、username/password 取新列默认 NULL天然落「手机号路径」侧放宽为松约束不破坏任何存量行。
-- password 列BCrypt 密文(约 60 字符,留至 100 兼容算法前缀/未来迁移);⚠ 永不返回任何 RespVO、永不落日志。
-- -----------------------------------------------------------------------------
ALTER TABLE `game_player`
ADD COLUMN `username` VARCHAR(30) NULL COMMENT '用户名(内测用户名密码路登录主键之一,^[a-zA-Z0-9]{4,30}$;手机号用户为 NULL' AFTER `mobile`,
ADD COLUMN `password` VARCHAR(100) NULL COMMENT 'BCrypt 密文(用户名路 60 字符左右,留 100 兼容算法前缀;⚠ 永不返回、永不落日志;非用户名用户为 NULL' AFTER `username`,
MODIFY COLUMN `mobile` VARCHAR(11) NULL COMMENT '手机号登录主键之一放宽可空承载无手机号的用户名用户2026-07-07 WU1日志/响应必须脱敏)',
MODIFY COLUMN `register_channel` VARCHAR(16) NOT NULL DEFAULT 'sms' COMMENT '注册通道sms验证码 / invite邀请码旁路 / password用户名密码漏斗归因2026-07-07 WU1 扩 password',
ADD UNIQUE KEY `uk_username` (`username`, `deleted`, `tenant_id`) COMMENT '用户名唯一(含 deleted/tenant_id 适配逻辑删+多租户,同 uk_mobile 范式NULL 行不参与约束,手机号用户不受限)';
-- -----------------------------------------------------------------------------
-- 段 2CREATE game_passport_register_outbox —— 「注册成功」事件本地消息表(事务性 outbox§3.6
-- 语义注册事务内与建号原子写一行status=0 未投递);事务外投递器(@Scheduled扫未投递发 RocketMQ、
-- 发成功置 status=1 已投递。把「至少一次」锚在 DB 事务上而非 MQ 即时可达性上,杜绝「注册成功但事件从未入队」黑洞。
-- 三条注册路sms 自动注册 / invite / password共用此出口WU2 new-api 开户充值消费端据 user_id 幂等(至少一次必带重复投递)。
-- 多租户本表为机制表DO 标 @TenantIgnore不含 tenant_id 列),投递器 @Scheduled 无租户上下文可全表扫。
-- -----------------------------------------------------------------------------
CREATE TABLE `game_passport_register_outbox` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '编号',
`user_id` BIGINT NOT NULL COMMENT '新注册玩家编号game_player.idWU2 开户充值幂等键)',
`register_channel` VARCHAR(16) NOT NULL COMMENT '注册通道sms / invite / password消费端口径统一在此不区分登录方式',
`anon_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '注册时携带的客户端 anonId匿名↔登录归因缺失落空串',
`idempotent_key` VARCHAR(64) NOT NULL COMMENT '幂等键(=register:{userId}uk 保证每次注册至多一行 outbox',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '投递状态0未投递 1已投递投递器发 MQ 成功后置 1',
`delivered_time` DATETIME NULL COMMENT '投递成功时间status=1 时回填,审计)',
-- 标准审计列(与 game_player 同范式;@PermitAll 免登注册无登录态creator/updater 服务层显式兜底 "0"
`creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者(审计列)',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '逻辑删除0未删 1已删',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_idempotent_key` (`idempotent_key`, `deleted`) COMMENT '幂等键唯一(防重复写 outbox',
KEY `idx_status` (`status`) COMMENT '投递器按未投递态扫描status=0'
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '注册成功事件本地消息表(事务性 outbox至少一次投递生产者WU1 §3.6';

View File

@ -0,0 +1,58 @@
-- =============================================================================
-- 契约 #2 DB 迁移 | 主题内测·new-api per-user ¥100 额度池WU22026-07-07 设计 §3.5| owneraigc派发期映射查询 co-locate
-- 文件V32.0.0__create_newapi_quota_pool.sqlFlyway纯新增表已合入禁止修改回滚 = drop 表 + 摘事件消费者)
-- 依据docs/agent-specs/2026-07-07-内测-WU2-newapi额度接入-设计.md草案创始人拍定「离线预置池 + 注册 claim」
-- 版本定序:执行副本 db/migration/ 实测已连续到 V31.0.0WU1 game_player 增列 + outbox本件顺延取 V32.0.0(已 ls|sort -V|tail 复核)。
-- WU1 与 WU2 同批新增迁移——WU1 取 V31、WU2 顺延 V32避免 Flyway 撞号校验失败。
-- 守门①(沿用 V11/V31本件同时落 contracts/db-schemas/(契约源)+ huijing-server 执行副本(唯一执行 classpath
-- 不放任何单模块 -server/db/migration/(避免同版本出现在多个 classpath jar 触发 Flyway 重复校验失败)。
-- 守门②:含中文 SQLmini-desktop 执行必须 --default-character-set=utf8mb4。
-- 内容CREATE newapi_quota_pool —— 离线 ops 脚本预建 FREE 条目 + 注册 claim 绑玩家FREE→CLAIMED账本在 new-api 网关。
-- =============================================================================
-- -----------------------------------------------------------------------------
-- CREATE newapi_quota_pool —— new-api per-user 额度预置池§3.5
-- 语义ops 脚本game-runtime/tools/newapi_pool_provision.py离线预建 N 个 (new-api user + token + ¥100)
-- 按条目 INSERTstatus=FREE、claimed_* 空);注册/懒 claim 时某条被 CAS 抢占翻转 CLAIMED 并绑 game_player。
-- 账本口径唯一 = new-api 网关(权威余额=token remain_quota、权威消耗=user used_quota本表只存 grant_quota 审计快照,不在 game-cloud 记余额。
-- 列名严格对齐 ops 脚本 emit 的 INSERT(newapi_user_id, newapi_token_id, newapi_token_key, grant_quota,
-- quota_per_unit_snapshot, usd_rate_snapshot, status),去重键 uk_newapi_user(newapi_user_id) 供 ON DUPLICATE KEY UPDATE 幂等补池。
-- 审计列creator/create_time/updater/update_time/deleted与 tenant_id 均给默认值ops 脚本 raw INSERT 只给业务列,
-- 审计列/租户列走 DB 默认落值;运行时 claim/查询经 TenantUtils.executeIgnore 跨租户存取(池是系统级资源)。
-- 幂等三键(仿 game_trade_grant 的 uk_biz_no
-- · uk_newapi_user —— 预建条目按 new-api 用户去重(脚本补池锚);
-- · uk_claimed_by —— 一玩家至多一条 CLAIMEDFREE 时 claimed_by 为 NULLNULL 不参与唯一约束,多条 FREE 并存);
-- · uk_biz_no —— claim 幂等键 claim_<gamePlayerId>FREE 时为 NULL
-- 并发/重投由 status='FREE' 单条 CAS + 上述两 NULL 唯一键三重收敛成一条:同玩家不占第二条、两玩家不抢同一条。
-- newapi_token_key 是 new-api 调用凭据明文落库(内测可接受、须视为敏感):随 job 内网下发须日志脱敏,生产化应加密列(红线待办,本阶段不做)。
-- 前后兼容:纯新增表,不改任何既有表结构,无迁移风险。
-- ⚠ 再 claimS4 凭据失效路,本单不实现):将 CLAIMED 隔离为 INVALID 时必须同步清空 claimed_by_game_player_id/biz_no
-- 否则与该玩家新 CLAIMED 条目撞 uk_claimed_by/uk_biz_no。本单只落 FREE→CLAIMEDINVALID 转换延后到 S4。
-- -----------------------------------------------------------------------------
CREATE TABLE `newapi_quota_pool` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '池条目编号',
`newapi_user_id` BIGINT NOT NULL COMMENT '预建的 new-api users.id脚本灌数去重键',
`newapi_token_id` BIGINT NOT NULL COMMENT '预建的 new-api tokens.id',
`newapi_token_key` VARCHAR(64) NOT NULL COMMENT 'new-api tokens.key48 位 sk-…;⚠ 敏感,随 job 下发须脱敏,永不整条落日志)',
`grant_quota` BIGINT NOT NULL COMMENT '该条目预置的 ¥100 折算 quota审计快照权威余额以网关 token.remain_quota 为准)',
`quota_per_unit_snapshot` BIGINT NOT NULL COMMENT '折算时网关 quota_per_unit默认 500000审计追溯用',
`usd_rate_snapshot` DECIMAL(10,4) NOT NULL COMMENT '折算时 usd_exchange_rate默认 7.3,审计追溯用)',
`status` VARCHAR(16) NOT NULL DEFAULT 'FREE' COMMENT '状态FREE可claim / CLAIMED已绑玩家 / INVALID token失效隔离S4claim CAS 抢占锚',
`claimed_by_game_player_id` BIGINT NULL COMMENT 'claim 后 ↔ game_player.idFREE 时 NULLuk 允许多 NULLCLAIMED 保证一玩家至多一条)',
`claimed_at` DATETIME NULL COMMENT 'claim 时间CLAIMED 时回填)',
`biz_no` VARCHAR(64) NULL COMMENT 'claim 幂等键 claim_<gamePlayerId>FREE 时 NULL',
`retry_count` INT NOT NULL DEFAULT 0 COMMENT '预留claim/隔离重试计数',
`remark` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '备注(隔离原因等)',
-- 标准审计列DO 继承 TenantBaseDOops 脚本 raw INSERT 不给这些列,走默认落值,运行时 executeIgnore 跨租户)
`creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者(审计列)',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '逻辑删除0未删 1已删',
`tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户编号(系统级池资源,运行时 executeIgnore 跨租户存取,值仅占位)',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_newapi_user` (`newapi_user_id`) COMMENT '预建条目按 new-api 用户去重(脚本 ON DUPLICATE KEY UPDATE 补池锚)',
UNIQUE KEY `uk_claimed_by` (`claimed_by_game_player_id`) COMMENT '一玩家至多一条 CLAIMEDNULL 不参与约束,多条 FREE 并存)',
UNIQUE KEY `uk_biz_no` (`biz_no`) COMMENT 'claim 幂等键唯一NULL 不参与约束)',
KEY `idx_status` (`status`) COMMENT 'claim 抢占按 status=FREE 扫、水位按 FREE 计数'
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'new-api per-user ¥100 额度预置池(离线预建 FREE + 注册 claim 绑玩家WU2 §3.5';

View File

@ -0,0 +1,421 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""newapi_pool_provision.py -- 内测 new-api per-user ¥100 额度池离线预置脚本WU2 · 接线点 B
权威设计docs/agent-specs/2026-07-07-内测-WU2-newapi额度接入-设计.md §3.3离线 ops 预置脚本
+ §3.5池表 newapi_quota_pool 契约
它做什么
--------
mini-infra 上一次性预建 N 个专属 (new-api user + token + ¥100 额度) 账户
把每个条目 emit 成可导入 game-cloud 池表 newapi_quota_pool JSON + SQL WU2 落地后灌数
provision 全部离线做S0 实测坐实new-api admin 令牌不能替他人建 tokenaccess_token
不可经 API per-user token 必须直连其 postgres运行时零 new-api admin 耦合
每个池条目四步建成S0 坐实的唯一可行路径
------------------------------------------------
1. POST /api/user/root 令牌建用户username = 确定性 `<前缀><序号>` postgres uid
2. postgres 直写 UPDATE users SET access_token=<确定性 32 位串> WHERE id=uid
access_token 无法经 API 又是下一步以该用户身份建 token的前提
3. POST /api/token/以该用户 access_token + New-Api-User:uid token
remain_quota=¥100 折算unlimited_quota=falseexpired_time=-1不过期 postgres token_id/key
4. PUT /api/user/root 令牌 user.quota=¥100 折算账户自洽权威余额闸仍是 token.remain_quota
幂等可重跑补池
------------------
按确定性 username 去重重跑时若用户已存在则复用其 uid逐步补齐缺失的 access_token /
token / quotaensure 语义不重复建号补池 = 提高 --count 重跑单条失败只记该条不污染后续
外部交互红线
------------
- HTTP连接/读超时urlopen timeout=读10s内部连粒度由 socket 2xx / success=false 归错
5xx/超时重试 2 指数退避4xx 不重试全程绕系统代理ProxyHandler({})内网直连 100.64.x
- postgres `docker exec infra-postgres psql` 执行S0 验证路径失败即抛该条标记失败
- 日志token / access_token 一律脱敏前后各留 4 可追溯时间戳 + 步骤 + 条目序号
凭据不硬编码
----------------
- postgres 密码环境变量 NEWAPI_PG_PASSWORD必填 docs/内网凭据与端点.md
- root 管理令牌环境变量 NEWAPI_ROOT_TOKEN选填未设则自动从 postgres users.id=1
用法 mini-infra 上跑docker 本地可用
------------------------------------------
export NEWAPI_PG_PASSWORD=<见凭据文档>
# 预置 2 条测试条目验证用前缀明确可辨dry-run 先看:
python3 newapi_pool_provision.py --count 2 --prefix neice_s0test_ --dry-run
python3 newapi_pool_provision.py --count 2 --prefix neice_s0test_
# 清理测试条目(删 tokens + users 行):
python3 newapi_pool_provision.py --cleanup 'neice_s0test_%'
"""
from __future__ import annotations
import argparse
import hashlib
import json
import logging
import os
import re
import secrets
import subprocess
import sys
import time
import urllib.error
import urllib.request
from datetime import datetime
from pathlib import Path
# ─── 常量 ────────────────────────────────────────────────────────────────
NEWAPI_BASE = os.environ.get("NEWAPI_BASE", "http://localhost:3000") # 脚本宿主 mini-infra本地直连
PG_CONTAINER = os.environ.get("NEWAPI_PG_CONTAINER", "infra-postgres")
PG_DB = os.environ.get("NEWAPI_PG_DB", "new-api")
PG_USER = os.environ.get("NEWAPI_PG_USER", "root")
ROOT_UID = 1 # new-api root 用户 idqingse持系统管理 access_token
HTTP_CONNECT_TIMEOUT = 5.0
HTTP_READ_TIMEOUT = 10.0
HTTP_RETRIES = 2 # 5xx / 超时最多重试 2 次
USERNAME_RE = re.compile(r"^[A-Za-z0-9_]+$") # 只允许安全 username杜绝 SQL/命令注入
POOL_TABLE = "newapi_quota_pool"
log = logging.getLogger("newapi_pool")
def _mask(token: str | None) -> str:
"""脱敏敏感串:前后各留 4 位中间打码。用于日志红线token/access_token 不整条打日志)。"""
if not token:
return "<empty>"
if len(token) <= 10:
return token[:2] + "***"
return f"{token[:4]}{token[-4:]}(len={len(token)})"
# ─── postgres 直连(经 docker exec psqlS0 验证路径) ──────────────────────
class Postgres:
"""封装对 new-api postgres 的只读/写操作。所有写走事务语义(单条 SQL 天然原子)。"""
def __init__(self, password: str):
if not password:
raise RuntimeError(
"缺少 NEWAPI_PG_PASSWORD 环境变量——无法连接 new-api postgres。"
"见 docs/内网凭据与端点.md new-api 段。"
)
self._password = password
def _run(self, sql: str) -> str:
"""执行一条 SQL返回 tuples-only、unaligned、以 | 分隔的原始 stdout。失败即抛。"""
cmd = [
"docker", "exec",
"-e", f"PGPASSWORD={self._password}",
PG_CONTAINER,
"psql", "-U", PG_USER, "-d", PG_DB,
"-t", "-A", "-F", "|", "-c", sql,
]
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
except subprocess.TimeoutExpired as e:
raise RuntimeError(f"postgres 执行超时:{sql[:80]}") from e
if proc.returncode != 0:
# 可追溯错误日志:暴露 psql stderr但不含敏感值SQL 里的 access_token 已在调用侧脱敏日志)
raise RuntimeError(f"postgres 执行失败 rc={proc.returncode}: {proc.stderr.strip()}")
return proc.stdout.strip()
def query_root_token(self) -> str:
out = self._run(f"SELECT access_token FROM users WHERE id={ROOT_UID};")
if not out:
raise RuntimeError("postgres 未查到 root(id=1) access_token")
return out.splitlines()[0].strip()
def query_user_id(self, username: str) -> int | None:
out = self._run(f"SELECT id FROM users WHERE username='{username}';")
line = out.splitlines()[0].strip() if out else ""
return int(line) if line else None
def query_access_token(self, uid: int) -> str:
out = self._run(f"SELECT access_token FROM users WHERE id={uid};")
return out.splitlines()[0].strip() if out else ""
def query_user_quota(self, uid: int) -> int | None:
out = self._run(f"SELECT quota FROM users WHERE id={uid};")
line = out.splitlines()[0].strip() if out else ""
return int(line) if line else None
def set_access_token(self, uid: int, access_token: str) -> None:
# access_token 无法经 API 设,必须 DB 直写S0 坐实的关键步)
self._run(f"UPDATE users SET access_token='{access_token}' WHERE id={uid};")
def query_token(self, uid: int, name: str) -> tuple[int, str] | None:
"""按 user_id + token name 查 token返回 (token_id, token_key)。"""
out = self._run(
f"SELECT id, key FROM tokens WHERE user_id={uid} AND name='{name}' ORDER BY id LIMIT 1;"
)
line = out.splitlines()[0].strip() if out else ""
if not line or "|" not in line:
return None
tid, key = line.split("|", 1)
return int(tid), key.strip()
def delete_user_cascade(self, username_like: str) -> tuple[int, int]:
"""清理:按 username LIKE 删 users + 其 tokens。返回 (删 tokens 数, 删 users 数)。
安全护栏拒绝任何可能命中 root(id<=1) 的模式只允许显式 % 通配"""
# 先查将被删的 uid 列表,护栏校验
uids_out = self._run(f"SELECT id FROM users WHERE username LIKE '{username_like}';")
uids = [int(x) for x in uids_out.splitlines() if x.strip()]
if not uids:
return (0, 0)
if any(u <= ROOT_UID for u in uids):
raise RuntimeError(f"清理护栏:模式 {username_like} 命中受保护用户 id<={ROOT_UID},拒绝执行")
uid_csv = ",".join(str(u) for u in uids)
tok_out = self._run(f"WITH d AS (DELETE FROM tokens WHERE user_id IN ({uid_csv}) RETURNING 1) SELECT count(*) FROM d;")
usr_out = self._run(f"WITH d AS (DELETE FROM users WHERE id IN ({uid_csv}) RETURNING 1) SELECT count(*) FROM d;")
return (int(tok_out.strip() or 0), int(usr_out.strip() or 0))
# ─── new-api HTTPstdlib urllib绕系统代理带超时+重试) ──────────────────
_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({})) # 空 ProxyHandler = 禁用系统代理
def _http_json(method: str, path: str, token: str, newapi_user: int, body: dict | None = None) -> dict:
"""对 new-api 发一个 JSON 请求。红线:绕代理 / 超时 / 非 2xx 归错 / 5xx 重试。
path 必须带尾斜杠new-api gin RedirectTrailingSlash否则 307
鉴权头Authorization: Bearer <token> + New-Api-User: <uid>缺后者即 401
"""
url = NEWAPI_BASE.rstrip("/") + path
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {
"Authorization": f"Bearer {token}",
"New-Api-User": str(newapi_user),
"Content-Type": "application/json",
}
last_err: Exception | None = None
for attempt in range(HTTP_RETRIES + 1):
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with _OPENER.open(req, timeout=HTTP_READ_TIMEOUT) as resp:
raw = resp.read().decode("utf-8")
payload = json.loads(raw) if raw else {}
# new-api 标准返回体 {success,message,data}success=false 视为业务错
if isinstance(payload, dict) and payload.get("success") is False:
raise RuntimeError(f"{method} {path} 业务失败: {payload.get('message')}")
return payload
except urllib.error.HTTPError as e:
body_txt = e.read().decode("utf-8", "ignore")[:200]
last_err = RuntimeError(f"{method} {path} HTTP {e.code}: {body_txt}")
if 500 <= e.code < 600 and attempt < HTTP_RETRIES:
time.sleep(0.5 * (2 ** attempt))
log.warning("[http] %s %s 5xx 重试 %d/%d", method, path, attempt + 1, HTTP_RETRIES)
continue
raise last_err # 4xx 不重试
except (urllib.error.URLError, TimeoutError, OSError) as e:
last_err = RuntimeError(f"{method} {path} 网络错误: {e}")
if attempt < HTTP_RETRIES:
time.sleep(0.5 * (2 ** attempt))
log.warning("[http] %s %s 网络错重试 %d/%d: %s", method, path, attempt + 1, HTTP_RETRIES, e)
continue
raise last_err
raise last_err or RuntimeError("unreachable")
# ─── ¥ ↔ quota 折算(口径与 cost.py 同源,读网关而非硬编码) ─────────────────
def fetch_conversion() -> tuple[int, float]:
"""GET /api/status 拿 quota_per_unit / usd_exchange_rate无需鉴权。缺 usd 用 7.3 默认。"""
url = NEWAPI_BASE.rstrip("/") + "/api/status"
req = urllib.request.Request(url, method="GET")
with _OPENER.open(req, timeout=HTTP_READ_TIMEOUT) as resp:
data = json.loads(resp.read().decode("utf-8")).get("data", {})
qpu = int(data.get("quota_per_unit") or 500000)
usd = data.get("usd_exchange_rate")
usd = float(usd) if usd else 7.3 # DB options 未覆盖时默认 7.3
return qpu, usd
def compute_grant(yuan: float, qpu: int, usd: float) -> int:
"""¥ → quotaround(yuan / usd × qpu)。¥100 @ 500000/7.3 ≈ 6,849,315。"""
return round(yuan / usd * qpu)
# ─── 单条目预置ensure 语义,幂等可补齐) ──────────────────────────────────
def provision_one(pg: Postgres, root_token: str, username: str, grant: int,
qpu: int, usd: float, dry_run: bool) -> dict:
"""预置/补齐一个池条目,返回条目 dict对齐 §3.5 池表契约)。任一步失败即抛,由调用方记为失败。"""
if not USERNAME_RE.match(username):
raise RuntimeError(f"非法 username: {username}")
if dry_run:
log.info("[dry-run] 计划预置 %sgrant=%d, unlimited=false, expired=-1", username, grant)
return {"username": username, "dry_run": True, "grant_quota": grant}
# 步骤 1建用户幂等——已存在则复用
uid = pg.query_user_id(username)
if uid is None:
password = secrets.token_urlsafe(12) # 随机密码 16 位new-api Password max=20仅占位从不密码登录
_http_json("POST", "/api/user/", root_token, ROOT_UID, {
"username": username,
"password": password,
"display_name": username[:20], # new-api DisplayName max=20直接用 username截断兜底
})
uid = pg.query_user_id(username)
if uid is None:
raise RuntimeError(f"建用户 {username} 后 postgres 未查到 uid")
log.info("[1/4] 建用户 %s → uid=%d", username, uid)
else:
log.info("[1/4] 用户 %s 已存在 uid=%d(复用)", username, uid)
# 步骤 2DB 直写 access_token确定性可重复 UPDATE 幂等)
access_token = hashlib.sha256(f"neice-quota-pool::v1::{username}".encode()).hexdigest()[:32]
existing_at = pg.query_access_token(uid)
if existing_at != access_token:
pg.set_access_token(uid, access_token)
log.info("[2/4] DB 写 access_token uid=%d%s", uid, _mask(access_token))
else:
log.info("[2/4] access_token uid=%d 已就绪 %s(复用)", uid, _mask(access_token))
# 步骤 3以该用户身份建 token幂等——已存在同名 token 则复用)
tok = pg.query_token(uid, username)
if tok is None:
_http_json("POST", "/api/token/", access_token, uid, {
"name": username,
"remain_quota": grant,
"unlimited_quota": False,
"expired_time": -1, # 不过期,避免自伤式过期(设计 §3.9
})
tok = pg.query_token(uid, username)
if tok is None:
raise RuntimeError(f"建 token 后 postgres 未查到 user={uid} name={username}")
log.info("[3/4] 建 token uid=%d → token_id=%d key=%s", uid, tok[0], _mask(tok[1]))
else:
log.info("[3/4] token uid=%d name=%s 已存在 token_id=%d(复用)", uid, username, tok[0])
token_id, token_key = tok
# 步骤 4设 user.quotaroot 令牌;账户自洽,权威余额闸仍是 token.remain_quota
cur_quota = pg.query_user_quota(uid)
if cur_quota != grant:
_http_json("PUT", "/api/user/", root_token, ROOT_UID, {
"id": uid,
"username": username,
"display_name": username[:20],
"quota": grant,
"group": "default",
})
log.info("[4/4] 设 user.quota uid=%d%d", uid, grant)
else:
log.info("[4/4] user.quota uid=%d 已=%d(复用)", uid, grant)
# 组装池表条目§3.5 契约字段)
return {
"newapi_user_id": uid,
"newapi_token_id": token_id,
"newapi_token_key": token_key,
"grant_quota": grant,
"quota_per_unit_snapshot": qpu,
"usd_rate_snapshot": usd,
"status": "FREE",
"username": username, # 仅审计参考,非池表列
}
# ─── emitJSON + SQL对齐 §3.5,供 WU2 落地后导入 game-cloud ─────────────
def emit_outputs(entries: list[dict], out_dir: Path, prefix: str) -> tuple[Path, Path]:
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
out_dir.mkdir(parents=True, exist_ok=True)
stem = f"newapi_pool_{prefix.rstrip('_')}_{ts}"
json_path = out_dir / f"{stem}.json"
sql_path = out_dir / f"{stem}.sql"
# 只保留池表列(去掉 username 审计字段)
pool_cols = ["newapi_user_id", "newapi_token_id", "newapi_token_key",
"grant_quota", "quota_per_unit_snapshot", "usd_rate_snapshot", "status"]
json_rows = [{k: e[k] for k in pool_cols} for e in entries]
json_path.write_text(json.dumps(json_rows, ensure_ascii=False, indent=2), encoding="utf-8")
# SQLINSERT ... ON DUPLICATE KEY UPDATE去重键 uk(newapi_user_id)§3.3(a)
# 刻意不在 ON DUPLICATE 里改 status/claimed_*——重跑不把已 CLAIMED 条目刷回 FREE防免费续杯 / 防误 unclaim
lines = [
f"-- 内测 new-api 额度池预置产物 {ts},共 {len(json_rows)} 条。",
f"-- 对齐 docs/agent-specs/2026-07-07-内测-WU2-newapi额度接入-设计.md §3.5 池表 {POOL_TABLE}V32 待落)。",
f"-- 导入前提game-cloud 已建 {POOL_TABLE} 表(含 uk(newapi_user_id)、审计列默认值)。",
f"-- ON DUPLICATE 只更 token/额度快照,不动 status/claimed_*(幂等补池不 unclaim 已绑条目)。",
]
for r in json_rows:
key_sql = r["newapi_token_key"].replace("'", "''")
lines.append(
f"INSERT INTO {POOL_TABLE} "
f"(newapi_user_id, newapi_token_id, newapi_token_key, grant_quota, "
f"quota_per_unit_snapshot, usd_rate_snapshot, status) VALUES "
f"({r['newapi_user_id']}, {r['newapi_token_id']}, '{key_sql}', {r['grant_quota']}, "
f"{r['quota_per_unit_snapshot']}, {r['usd_rate_snapshot']}, '{r['status']}') "
f"ON DUPLICATE KEY UPDATE "
f"newapi_token_id=VALUES(newapi_token_id), newapi_token_key=VALUES(newapi_token_key), "
f"grant_quota=VALUES(grant_quota), quota_per_unit_snapshot=VALUES(quota_per_unit_snapshot), "
f"usd_rate_snapshot=VALUES(usd_rate_snapshot);"
)
sql_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
return json_path, sql_path
# ─── 主流程 ──────────────────────────────────────────────────────────────
def cmd_provision(args) -> int:
pg = Postgres(os.environ.get("NEWAPI_PG_PASSWORD", ""))
root_token = os.environ.get("NEWAPI_ROOT_TOKEN") or pg.query_root_token()
log.info("root 管理令牌就绪 %s", _mask(root_token))
qpu, usd = fetch_conversion()
grant = compute_grant(args.yuan, qpu, usd)
log.info("折算:¥%.0f @ quota_per_unit=%d usd=%.4f → grant_quota=%d", args.yuan, qpu, usd, grant)
entries: list[dict] = []
failures: list[dict] = []
for i in range(args.start, args.start + args.count):
username = f"{args.prefix}{i:03d}"
try:
entries.append(provision_one(pg, root_token, username, grant, qpu, usd, args.dry_run))
except Exception as e: # 单条失败不污染后续(红线)
log.error("[条目 %s] 预置失败:%s", username, e)
failures.append({"username": username, "error": str(e)})
log.info("完成:成功 %d / 失败 %d", len(entries), len(failures))
if args.dry_run:
log.info("dry-run 结束,未写 new-api、未 emit 文件。")
return 0 if not failures else 1
if entries:
json_path, sql_path = emit_outputs(entries, Path(args.out_dir), args.prefix)
log.info("emit JSON → %s", json_path)
log.info("emit SQL → %s", sql_path)
if failures:
log.warning("失败条目:%s", json.dumps(failures, ensure_ascii=False))
return 1
return 0
def cmd_cleanup(args) -> int:
"""清理:按 username LIKE 模式删 tokens + users护栏拒绝命中 root"""
pg = Postgres(os.environ.get("NEWAPI_PG_PASSWORD", ""))
ntok, nusr = pg.delete_user_cascade(args.cleanup)
log.info("清理模式 %s:删 tokens %d 行 / users %d", args.cleanup, ntok, nusr)
return 0
def main() -> int:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
stream=sys.stderr,
)
p = argparse.ArgumentParser(description="内测 new-api per-user ¥100 额度池离线预置")
p.add_argument("--count", type=int, default=1, help="目标条目数 N本次预置到 start..start+count")
p.add_argument("--start", type=int, default=1, help="起始序号(默认 1")
p.add_argument("--prefix", default="neice_", help="username 前缀(默认 neice_测试用 neice_s0test_")
p.add_argument("--yuan", type=float, default=100.0, help="每条目额度(元,默认 100")
p.add_argument("--out-dir", default="./newapi-pool-out", help="JSON/SQL 产物目录")
p.add_argument("--dry-run", action="store_true", help="只打印计划,不写 new-api、不 emit")
p.add_argument("--cleanup", metavar="LIKE_PATTERN",
help="清理模式:删匹配 username LIKE 的 users+tokens'neice_s0test_%%'")
args = p.parse_args()
if args.cleanup:
return cmd_cleanup(args)
return cmd_provision(args)
if __name__ == "__main__":
sys.exit(main())

View File

@ -39,6 +39,35 @@ export interface InviteRegisterReq {
nickname?: string
}
/**
* PasswordRegisterReqVOWU1 §3.2
* username ^[a-zA-Z0-9]{4,30}$password 6-32
* 1-002-090-004 request Toast
*/
export interface PasswordRegisterReq {
/** 用户名(登录主键之一;^[a-zA-Z0-9]{4,30}$ */
username: string
/** 密码明文6-32 位;后端立即 BCrypt 编码,前端不落日志、不回显) */
password: string
/** 昵称(可选;不传则后端默认取用户名) */
nickname?: string
/** 客户端 anonId可选仅注册落 first_anon_id 一次,匿名↔登录归因) */
anonId?: string
}
/**
* PasswordLoginReqVOWU1 §3.2
* 1-002-090-005
*/
export interface PasswordLoginReq {
/** 用户名(登录主键之一;^[a-zA-Z0-9]{4,30}$ */
username: string
/** 密码(明文;登录不重复长度校验,仅非空) */
password: string
/** 客户端 anonId可选登录不落库仅遥测归因透传 */
anonId?: string
}
/**
* / LoginRespVOsms-login invite-register
* token 线
@ -65,8 +94,10 @@ export interface LoginResp {
export interface PlayerMeResp {
/** 玩家编号 */
userId: number
/** 脱敏手机号(如 138****0000后端禁回明文 */
/** 脱敏手机号(如 138****0000后端禁回明文;用户名用户 mobile 为 null 时回空串 */
mobile: string
/** 用户名用户名密码路身份展示非敏感明文手机号用户为空串WU1 §3.2 */
username: string
/** 昵称 */
nickname: string
/** 头像 URL */
@ -117,6 +148,34 @@ export function inviteRegister(dto: InviteRegisterReq): Promise<LoginResp> {
})
}
/**
* @PermitAll password-auth-enabled WU1 §3.2
* POST /app-api/passport/password-register
* @param dto username + password + nickname/anonId
* @returns OAuth2 token token
*/
export function passwordRegister(dto: PasswordRegisterReq): Promise<LoginResp> {
return request<LoginResp>({
url: '/app-api/passport/password-register',
method: 'post',
data: dto,
})
}
/**
* @PermitAll 1-002-090-005WU1 §3.2
* POST /app-api/passport/password-login
* @param dto username + password + anonId
* @returns OAuth2 token sms-login
*/
export function passwordLogin(dto: PasswordLoginReq): Promise<LoginResp> {
return request<LoginResp>({
url: '/app-api/passport/password-login',
method: 'post',
data: dto,
})
}
/**
* 401 request.ts 401
* GET /app-api/passport/me

View File

@ -9,18 +9,28 @@ export default {
subtitle: 'Sign in to create and interact with your mini-games',
tabSms: 'Code login',
tabInvite: 'Invite register',
tabPassword: 'Username',
mobileLabel: 'Phone',
mobilePlaceholder: 'Enter phone number',
codeLabel: 'Code',
codePlaceholder: 'Enter SMS code',
inviteLabel: 'Invite code',
invitePlaceholder: 'Enter beta invite code',
modeLogin: 'Log in',
modeRegister: 'Register',
usernameLabel: 'Username',
usernamePlaceholder: '4-30 letters or digits',
passwordLabel: 'Password',
passwordPlaceholder: '6-32 characters',
passwordHint: 'Seed users: register and sign in with a username and password, no phone needed',
sendCode: 'Get code',
sending: 'Sending…',
resendIn: 'Resend in {n}s',
smsHint: 'Unregistered numbers will be registered and signed in automatically',
inviteHint: 'Beta invite: currently in restricted activation, an invite code is required',
invalidMobile: 'Please enter a valid phone number',
invalidUsername: 'Username must be 4-30 letters or digits',
invalidPassword: 'Password must be 6-32 characters',
codeSent: 'Code sent (seed-phase Debug channel, check the backend for the code)',
needAgree: 'Please read and accept the User Agreement and Privacy Policy first',
loginSuccess: 'Signed in',
@ -31,6 +41,8 @@ export default {
agreePlaceholder: '(placeholder, final copy pending legal)',
submitSms: 'Log in / register',
submitInvite: 'Register & log in',
submitPasswordLogin: 'Log in',
submitPasswordRegister: 'Register & log in',
},
// —— Share landing ——

View File

@ -10,9 +10,10 @@ export default {
login: {
// 品牌副标题(标题本身用 common.brand
subtitle: '登录后即可创作、互动你的小游戏',
// Tab 名
// Tab 名
tabSms: '验证码登录',
tabInvite: '邀请码注册',
tabPassword: '用户名密码',
// 手机号 / 验证码 / 邀请码占位符 + 标签
mobileLabel: '手机号',
mobilePlaceholder: '请输入手机号',
@ -20,6 +21,14 @@ export default {
codePlaceholder: '请输入短信验证码',
inviteLabel: '邀请码',
invitePlaceholder: '请输入内测邀请码',
// 用户名密码(第三 Tab内测种子用户WU1 §3.5):子模式切换 + 输入占位 + 提示
modeLogin: '登录',
modeRegister: '注册',
usernameLabel: '用户名',
usernamePlaceholder: '4-30 位数字或字母',
passwordLabel: '密码',
passwordPlaceholder: '6-32 位密码',
passwordHint: '内测种子用户:用户名 + 密码即可注册登录,无需手机号',
// 发码按钮三态:默认 / 发送中 / 倒计时重发({n} 为剩余秒数,模板插值)
sendCode: '获取验证码',
sending: '发送中…',
@ -29,6 +38,8 @@ export default {
inviteHint: '内测邀请:当前为受限激活期,需邀请码注册',
// 表单校验 / 流程 Toast
invalidMobile: '请输入正确的手机号',
invalidUsername: '用户名为 4-30 位数字或字母',
invalidPassword: '密码长度为 6-32 位',
codeSent: '验证码已发送(种子期 Debug 渠道,后台查码)',
needAgree: '请先阅读并勾选用户协议与隐私政策',
loginSuccess: '登录成功',
@ -38,9 +49,11 @@ export default {
agreeAnd: '与',
agreePrivacy: '《隐私政策》',
agreePlaceholder: '(占位,正式文案待法务)',
// 提交按钮两态(按当前 Tab
// 提交按钮多态(按当前 Tab用户名密码 Tab 再按登录/注册子模式
submitSms: '登录 / 注册',
submitInvite: '注册并登录',
submitPasswordLogin: '登录',
submitPasswordRegister: '注册并登录',
},
// —— 分享落地页Share.vue/share/:gameId——

View File

@ -3,10 +3,13 @@
* 登录页 | Login.vue路由 /login真实鉴权件 spec §9.1
* ----------------------------------------------------------------------------
* 职责
* 1. Tab 登录/注册
* 1. Tab 登录/注册
* 手机号 + 验证码主路径拍板1Csend-sms-code 发码 sms-login 登录/自动注册一体
* 邀请码注册受限激活期陌生玩家入口拍板2/3mobile + inviteCode invite-register
* 页内文案注明内测邀请
* 用户名密码内测种子用户WU1 §3.5username + password子模式登录/注册二选一
* password-login / password-register建号即登录前端做与后端一致的用户名正则+
* 密码长度预校验仅提示信任边界在后端
* 2. 隐私政策/用户协议占位勾选P-ACC-02 占位提前未勾选不可提交
* 3. 携带 useUserStore().anonId 入参匿名登录身份衔接拍板7=B
* 4. 支持 ?redirect= 回跳守卫/互动跳转登录后回到原页
@ -19,7 +22,14 @@ import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { showToast } from 'vant'
import { useUserStore } from '../../store/user'
import { sendSmsCode, smsLogin, inviteRegister, type LoginResp } from '../../api/passport'
import {
sendSmsCode,
smsLogin,
inviteRegister,
passwordRegister,
passwordLogin,
type LoginResp,
} from '../../api/passport'
import { track } from '../../telemetry'
import { AppButton } from '../../components'
import entryZh from '../../locales/entry.zh'
@ -36,15 +46,21 @@ const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
/** 当前 Tab'sms'=验证码登录(默认主路径) / 'invite'=邀请码注册 */
const activeTab = ref<'sms' | 'invite'>('sms')
/** 当前 Tab'sms'=验证码登录(默认主路径) / 'invite'=邀请码注册 / 'password'=用户名密码 */
const activeTab = ref<'sms' | 'invite' | 'password'>('sms')
/** 手机号(两 Tab 共用) */
/** 手机号(sms/invite 两 Tab 共password Tab 不用) */
const mobile = ref<string>('')
/** 短信验证码sms Tab */
const code = ref<string>('')
/** 邀请码invite Tab */
const inviteCode = ref<string>('')
/** 用户名password Tab */
const username = ref<string>('')
/** 密码password Tab不 trim允许含空格绝不落日志/遥测) */
const password = ref<string>('')
/** 用户名密码 Tab 子模式:'login'=登录(默认)/ 'register'=注册(建号即登录) */
const passwordMode = ref<'login' | 'register'>('login')
/** 隐私政策/用户协议勾选(占位;未勾选不可提交) */
const agreed = ref<boolean>(false)
@ -60,6 +76,21 @@ const MOBILE_RE = /^1[3-9]\d{9}$/
/** 手机号是否合法 */
const mobileValid = computed(() => MOBILE_RE.test(mobile.value.trim()))
/** 用户名校验:与后端 PasswordReqVO 一致 ^[a-zA-Z0-9]{4,30}$(前端仅提示,信任边界在后端) */
const USERNAME_RE = /^[a-zA-Z0-9]{4,30}$/
/** 用户名是否合法 */
const usernameValid = computed(() => USERNAME_RE.test(username.value.trim()))
/**
* 密码是否合法与后端一致的长度预校验仅提示
* - 注册6-32 后端 @Length(6,32)
* - 登录仅非空后端登录不重复长度校验避免给出策略线索
* 密码不 trim前导/尾随空格可能是密码一部分
*/
const passwordValid = computed(() => {
const len = password.value.length
return passwordMode.value === 'register' ? len >= 6 && len <= 32 : len > 0
})
/**
* 解析回跳地址?redirect=
* 仅接受站内相对路径 / 开头且非 // /feed
@ -78,11 +109,26 @@ const canSend = computed(() => mobileValid.value && !sending.value && countdown.
/** 提交按钮是否可点(按当前 Tab 校验必填项 + 已勾选协议 + 不在提交中) */
const canSubmit = computed(() => {
if (!agreed.value || submitting.value || !mobileValid.value) return false
if (!agreed.value || submitting.value) return false
if (activeTab.value === 'password') {
// Tab username +
return usernameValid.value && passwordValid.value
}
// sms / invite Tab
if (!mobileValid.value) return false
if (activeTab.value === 'sms') return code.value.trim().length > 0
return inviteCode.value.trim().length > 0
})
/** 提交按钮文案(按当前 Tab用户名密码 Tab 再按登录/注册子模式二态) */
const submitLabel = computed(() => {
if (activeTab.value === 'sms') return t('login.submitSms')
if (activeTab.value === 'invite') return t('login.submitInvite')
return passwordMode.value === 'register'
? t('login.submitPasswordRegister')
: t('login.submitPasswordLogin')
})
/** 启动 60s 发码倒计时 */
function startCountdown(): void {
countdown.value = 60
@ -138,12 +184,21 @@ function afterLoginSuccess(resp: LoginResp): void {
/**
* 提交按当前 Tab 分派
* - smssmsLogin已注册登录 / 未注册自动注册一体
* - inviteinviteRegister手机号+邀请码受限激活
* 两路均携带 anonId身份衔接可选失败由拦截器 Toast 错误码仅解锁
* - inviteinviteRegister手机号+邀请码受限激活
* - password登录子模式 passwordLogin / 注册子模式 passwordRegister建号即登录
* 三路均携带 anonId身份衔接可选失败由拦截器 Toast 错误码1-002-090/091仅解锁
*/
async function onSubmit(): Promise<void> {
if (!canSubmit.value) {
if (!agreed.value) showToast({ message: t('login.needAgree'), type: 'fail' })
if (!agreed.value) {
showToast({ message: t('login.needAgree'), type: 'fail' })
return
}
// Tab
if (activeTab.value === 'password') {
if (!usernameValid.value) showToast({ message: t('login.invalidUsername'), type: 'fail' })
else if (!passwordValid.value) showToast({ message: t('login.invalidPassword'), type: 'fail' })
}
return
}
submitting.value = true
@ -152,16 +207,23 @@ async function onSubmit(): Promise<void> {
let resp: LoginResp
if (activeTab.value === 'sms') {
resp = await smsLogin({ mobile: mobile.value.trim(), code: code.value.trim(), anonId })
} else {
} else if (activeTab.value === 'invite') {
resp = await inviteRegister({
mobile: mobile.value.trim(),
inviteCode: inviteCode.value.trim(),
anonId,
})
} else {
// username password trim/
const uname = username.value.trim()
resp =
passwordMode.value === 'register'
? await passwordRegister({ username: uname, password: password.value, anonId })
: await passwordLogin({ username: uname, password: password.value, anonId })
}
afterLoginSuccess(resp)
} catch {
// / ·· / 1-002-090/091 Toast
// / ·· / · 1-002-090/091 Toast
submitting.value = false
}
// router.replace submitting
@ -176,7 +238,7 @@ async function onSubmit(): Promise<void> {
<p class="login-sub">{{ t('login.subtitle') }}</p>
</header>
<!-- Tab 切换验证码登录 / 邀请码注册 -->
<!-- Tab 切换验证码登录 / 邀请码注册 / 用户名密码 -->
<div class="login-tabs">
<button
class="login-tab"
@ -194,13 +256,21 @@ async function onSubmit(): Promise<void> {
>
{{ t('login.tabInvite') }}
</button>
<button
class="login-tab"
:class="{ 'is-active': activeTab === 'password' }"
type="button"
@click="activeTab = 'password'"
>
{{ t('login.tabPassword') }}
</button>
</div>
<!-- 表单区 -->
<section class="login-form">
<!-- 手机号 Tab 共用保留原生 inputmaxlength/inputmode/type 属输入校验 UX
与倒计时/校验逻辑相关不替成 FieldField 不透传 maxlength/inputmode仅令牌化样式 + i18n 占位 -->
<div class="field">
<!-- 手机号sms/invite Tab 共用password Tab 无手机号不渲染保留原生 input
maxlength/inputmode/type 属输入校验 UX与倒计时/校验逻辑相关不替成 Field仅令牌化样式 + i18n 占位 -->
<div v-if="activeTab !== 'password'" class="field">
<input
v-model="mobile"
class="field-input"
@ -230,7 +300,7 @@ async function onSubmit(): Promise<void> {
</template>
<!-- 邀请码注册邀请码受限激活期陌生玩家入口 -->
<template v-else>
<template v-else-if="activeTab === 'invite'">
<div class="field">
<input
v-model="inviteCode"
@ -243,6 +313,51 @@ async function onSubmit(): Promise<void> {
<p class="login-hint">{{ t('login.inviteHint') }}</p>
</template>
<!-- 用户名密码子模式登录/注册切换 + 用户名 + 密码内测种子用户WU1 §3.5 -->
<template v-else>
<!-- 登录/注册子模式切换注册走 password-register 建号即登录登录走 password-login -->
<div class="pwd-mode">
<button
class="pwd-mode-btn"
:class="{ 'is-active': passwordMode === 'login' }"
type="button"
@click="passwordMode = 'login'"
>
{{ t('login.modeLogin') }}
</button>
<button
class="pwd-mode-btn"
:class="{ 'is-active': passwordMode === 'register' }"
type="button"
@click="passwordMode = 'register'"
>
{{ t('login.modeRegister') }}
</button>
</div>
<div class="field">
<input
v-model="username"
class="field-input"
type="text"
inputmode="text"
autocomplete="username"
maxlength="30"
:placeholder="t('login.usernamePlaceholder')"
/>
</div>
<div class="field">
<input
v-model="password"
class="field-input"
type="password"
:autocomplete="passwordMode === 'register' ? 'new-password' : 'current-password'"
maxlength="32"
:placeholder="t('login.passwordPlaceholder')"
/>
</div>
<p class="login-hint">{{ t('login.passwordHint') }}</p>
</template>
<!-- 隐私政策/用户协议占位勾选未勾选不可提交协议/隐私链接名 + 占位说明均抽 key -->
<label class="agree">
<input v-model="agreed" type="checkbox" class="agree-box" />
@ -255,9 +370,9 @@ async function onSubmit(): Promise<void> {
</span>
</label>
<!-- 提交按当前 Tab 文案二 -->
<!-- 提交按当前 Tab / password 子模式文案多 -->
<AppButton block size="large" :loading="submitting" :disabled="!canSubmit" @click="onSubmit">
{{ activeTab === 'sms' ? t('login.submitSms') : t('login.submitInvite') }}
{{ submitLabel }}
</AppButton>
</section>
</div>
@ -365,6 +480,31 @@ async function onSubmit(): Promise<void> {
opacity: 0.6;
}
/* —— 用户名密码:登录/注册子模式切换(比主 Tab 更轻,作段控) —— */
.pwd-mode {
display: flex;
gap: 6px;
padding: 3px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.pwd-mode-btn {
flex: 1;
padding: 8px 0;
border: 0;
border-radius: calc(var(--radius) - 3px);
background: transparent;
color: var(--muted);
font-size: 13px;
font-weight: 600;
transition: background 0.15s ease, color 0.15s ease;
}
.pwd-mode-btn.is-active {
background: color-mix(in srgb, var(--accent) 16%, transparent);
color: var(--accent);
}
/* —— 提示文案 —— */
.login-hint {
margin: -6px 2px 0;

View File

@ -54,12 +54,13 @@ const meLoading = ref(true)
/**
* userStore 现有字段构造兜底账号信息getMe 失败/mock 模式时用
* 手机号 store 无持久化兜底留空passport.me 才回脱敏手机号
* 手机号/用户名 store 无持久化兜底留空passport.me 才回脱敏手机号与用户名
*/
function fallbackMe(): PlayerMeResp {
return {
userId: Number(userStore.userId) || 0,
mobile: '',
username: '',
nickname: userStore.nickname,
avatar: userStore.avatar,
creatorFlag: userStore.creatorFlag,

View File

@ -43,18 +43,96 @@ for _k in ("NO_PROXY", "no_proxy"):
import openai # noqa: E402 —— 必须在代理旁路之后导入
# WU2 §3.7:回落全局 env key 时打 WARN;但 get_api_key 每次 chat 都会被调,故一进程只 WARN 一次防刷屏。
_ENV_FALLBACK_WARNED = False
def get_api_key():
"""读 NEWAPI_KEY缺失即抛密钥不入库、复现前先设 .env 或 export"""
class NewapiQuotaExhaustedError(RuntimeError):
"""new-api 网关按 per-user token 判额度耗尽(WU2 §3.9)。
one-api 惯例:HTTP 402/403 或错误体含额度耗尽关键词**不可重试**干净失败worker 据此回调
failure_reason=quota_exhausted(而非含糊 llm_error);凭据失效(401)不塌成本错误维持可重试 llm_error
阶段2 待验402/403 与确切错误体 message S0 只坐实管理面未测 /v1/chat/completions 耗尽错误体,
确切匹配待 S4 真机耗尽/凭据失效测试;现按 one-api 惯例初值分辨(状态码优先关键词兜底)
"""
def _mask_token(tok) -> str:
"""token 日志脱敏(WU2 §3.8):前后各留 4 位、中间省略;过短/空则整体隐藏。绝不整条打凭据。"""
if not tok:
return "<none>"
s = str(tok)
return "****" if len(s) <= 8 else f"{s[:4]}{s[-4:]}"
def _newapi_error_status_text(exc):
"""从 new-api 调用异常尽力抽 (http_status, 错误体文本);抽不到 status 返 None,文本恒兜底 str(exc)。
兼容 openai SDK APIStatusError(.status_code / .response.status_code / .body / .message)与被上层
框架包装后的普通异常(只剩 str) classify_newapi_failure_reason 分辨
"""
status = getattr(exc, "status_code", None)
if status is None:
status = getattr(exc, "code", None)
if status is None:
resp = getattr(exc, "response", None)
status = getattr(resp, "status_code", None) if resp is not None else None
parts = []
for attr in ("message", "body"):
v = getattr(exc, attr, None)
if v:
parts.append(str(v))
parts.append(str(exc))
return status, " ".join(parts)
def classify_newapi_failure_reason(status=None, text: str = "") -> str:
"""据 one-api 惯例把网关非 2xx 分辨成失败因(WU2 §3.9)。
返回 'quota_exhausted'(额度耗尽,不可重试) 'llm_error'(凭据失效 401/其他,可重试不塌成额度耗尽)
判据:HTTP 402/403 额度耗尽;401 凭据失效;状态码取不到时按错误体关键词兜底
阶段2 待验确切 message/code S4 真机耗尽/凭据失效测试坐实;此处状态码优先关键词兜底为初值
"""
try:
s = int(status) if status is not None else None
except (TypeError, ValueError):
s = None
if s in (402, 403):
return "quota_exhausted"
if s == 401:
return "llm_error" # 凭据失效:维持可重试,不塌成 quota_exhausted(§3.9 ②)
t = (text or "").lower()
quota_markers = ("insufficient user quota", "insufficient quota", "余额不足", "额度不足", "quota not enough")
if any(m in t for m in quota_markers):
return "quota_exhausted"
return "llm_error"
def get_api_key(user_token=None):
"""解析本次调用的 new-api 凭据(WU2 §3.7 F)。
优先用传入的 per-user token(user_token, §6.1 job 派来);缺失回落全局 env NEWAPI_KEY(缺失即抛,
密钥不入库复现前先设 .env export)回落是系统/编排/bake-off/spike 触发的正常预期( game_player
成员身份后端 dispatch 已按身份旁路不塞 token);额度门在后端 dispatch 层按身份堵,worker 层只做有则用
无则回落回落时打一条 WARN(一进程一次,防每次 chat 刷屏)便于发现本该带 token member create job 异常
"""
if user_token:
return user_token
key = os.environ.get("NEWAPI_KEY")
if not key:
raise RuntimeError("NEWAPI_KEY 未设:请在 wg1/gen-worker/.env 写入或 export NEWAPI_KEY=...")
global _ENV_FALLBACK_WARNED
if not _ENV_FALLBACK_WARNED:
_ENV_FALLBACK_WARNED = True
print(f"[wg1-worker] 无 per-user userToken → 回落全局 env NEWAPI_KEY({_mask_token(key)})。"
f"系统/编排/bake-off/spike 为正常预期;若为真实 member create job 则异常走共享计量(§3.7)。",
flush=True)
return key
def get_client():
"""构建指向 new-api 的裸 OpenAI 兼容客户端。"""
return openai.OpenAI(api_key=get_api_key(), base_url=BASE_URL, max_retries=2, timeout=180.0)
def get_client(user_token=None):
"""构建指向 new-api 的裸 OpenAI 兼容客户端(WU2 §3.7:凭据优先 per-user token、缺失回落 env)"""
return openai.OpenAI(api_key=get_api_key(user_token), base_url=BASE_URL, max_retries=2, timeout=180.0)
def _extra_body(model, enable_thinking):
@ -87,14 +165,18 @@ def _cache_hit_from_usage(usage):
return 0
def chat(model, system, user, max_tokens=16000, temperature=0.0, tries=3, retry_delay=2.0, enable_thinking=False):
def chat(model, system, user, max_tokens=16000, temperature=0.0, tries=3, retry_delay=2.0,
enable_thinking=False, user_token=None):
"""单次裸调用 chat.completions。确定性产出 temperature=0瞬时网关错误(502 burst)重试。
返回 dict{content, prompt_tokens, completion_tokens, prompt_cache_hit_tokens, total_tokens, model, wall_s, id, finish_reason}
usage 字段用 openai 2.41.1 口径prompt_tokens / completion_tokens供成本折算与 token 取证
prompt_cache_hit_tokensU4/B9 新增additive = 命中缓存的 prompt token两套字段取 max缺则 0 cost.py 折缓存价
WU2 §3.7user_token 给定则用它调网关(per-user 额度)缺失回落全局 env key
WU2 §3.9网关非 2xx one-api 惯例分辨额度耗尽(402/403)立即抛 NewapiQuotaExhaustedError 不再重试;
凭据失效(401)/瞬时错(502)维持原重试语义(不塌成额度耗尽)
"""
client = get_client()
client = get_client(user_token)
last_err = None
for attempt in range(tries):
try:
@ -130,6 +212,11 @@ def chat(model, system, user, max_tokens=16000, temperature=0.0, tries=3, retry_
"id": getattr(resp, "id", None),
}
except Exception as e: # 瞬时网关错误重试spike 实测 new-api 偶发 502 burst
# WU2 §3.9:额度耗尽(402/403)重试无意义,立即抛典型错误让 worker 回调 quota_exhausted;
# 凭据失效(401)/瞬时错(502)不塌成额度耗尽,维持下方重试语义(可重试 llm_error)。
if classify_newapi_failure_reason(*_newapi_error_status_text(e)) == "quota_exhausted":
raise NewapiQuotaExhaustedError(
f"new-api 额度耗尽(按 §3.9 分辨,阶段2 待精确 message){type(e).__name__}: {e}") from e
last_err = e
if attempt < tries - 1:
time.sleep(retry_delay)

View File

@ -54,6 +54,7 @@ if str(WORKER_DIR) not in sys.path:
sys.path.insert(0, str(WORKER_DIR))
import run # noqa: E402 worker/run.pyGEN_DIR/REPO_ROOT 常量 + scaffold/build/play
import _client # noqa: E402 裸 new-api 客户端WU2 §3.9NewapiQuotaExhaustedError 分辨额度耗尽)
from agent_loop.studio import run_studio # noqa: E402 L2 agentic 闭环design 展一句话→九门)
from agent_loop import config # noqa: E402 models.yaml 阶段(默认 default_stage
@ -333,6 +334,13 @@ def handle_job(state, job):
log(f"❌ pass 但 bundle 文件缺失:{bundle_path}")
else:
failure_reason = "nine_gate_failed"
except _client.NewapiQuotaExhaustedError as e:
# WU2 §3.9:new-api 按 per-user token 判额度耗尽(402/403)→ 干净失败 quota_exhausted(而非含糊 llm_error),
# 后端据此把任务终态落 quota_exhausted、前端展示「生成额度已用完」。凭据失效(401)不走此路、维持可重试。
# 注:wg1 主生成经 AgentScope 模型封装调网关(config.py credential),402 埋在框架内、此典型错误仅从
# _client.chat 直连路(如 run.py 旧路)propagate;主生成路的额度耗尽分辨待阶段2 拦 AgentScope 模型封装。
failure_reason = "quota_exhausted"
log(f"❌ new-api 额度耗尽 trace_id={trace_id}(§3.9 quota_exhausted): {type(e).__name__}: {e}")
except Exception as e:
failure_reason = f"{type(e).__name__}: {e}"
log(f"❌ 真生成异常 trace_id={trace_id}: {failure_reason}")