diff --git a/cheap-worker/cheap_service_app.py b/cheap-worker/cheap_service_app.py index 888fb578..ddea391c 100644 --- a/cheap-worker/cheap_service_app.py +++ b/cheap-worker/cheap_service_app.py @@ -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( diff --git a/cheap-worker/cheap_service_driver.py b/cheap-worker/cheap_service_driver.py index 85486553..7315d83b 100644 --- a/cheap-worker/cheap_service_driver.py +++ b/cheap-worker/cheap_service_driver.py @@ -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 "" + 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 已从凭据档注入 env、client.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_credential、base_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 往下走—— diff --git a/cheap-worker/result_out.py b/cheap-worker/result_out.py index 5afa56eb..90c1cd56 100644 --- a/cheap-worker/result_out.py +++ b/cheap-worker/result_out.py @@ -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" diff --git a/cheap-worker/tests/test_cheap_service_driver.py b/cheap-worker/tests/test_cheap_service_driver.py index aa6a0876..82271fbd 100644 --- a/cheap-worker/tests/test_cheap_service_driver.py +++ b/cheap-worker/tests/test_cheap_service_driver.py @@ -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)) diff --git a/cheap-worker/tests/test_result_out.py b/cheap-worker/tests/test_result_out.py index 4510db36..cef6ae7c 100644 --- a/cheap-worker/tests/test_result_out.py +++ b/cheap-worker/tests/test_result_out.py @@ -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()) diff --git a/cheap-worker/worker_service.py b/cheap-worker/worker_service.py index 9a31d36e..f0ac2882 100644 --- a/cheap-worker/worker_service.py +++ b/cheap-worker/worker_service.py @@ -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 "" + 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()}) diff --git a/contracts/api-schemas/aigc.yaml b/contracts/api-schemas/aigc.yaml index d6ce09d5..57b7e123 100644 --- a/contracts/api-schemas/aigc.yaml +++ b/contracts/api-schemas/aigc.yaml @@ -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] diff --git a/contracts/db-schemas/V31.0.0__passport_player_add_username_password.sql b/contracts/db-schemas/V31.0.0__passport_player_add_username_password.sql new file mode 100644 index 00000000..985f7cb7 --- /dev/null +++ b/contracts/db-schemas/V31.0.0__passport_player_add_username_password.sql @@ -0,0 +1,52 @@ +-- ============================================================================= +-- 契约 #2 DB 迁移 | 主题:内测·用户名+密码注册登录(WU1,2026-07-07 设计 §3.1/§3.6)| owner:passport(system 内扩展) +-- 文件:V31.0.0__passport_player_add_username_password.sql(Flyway,只 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 重复校验失败)。 +-- 守门②:含中文 SQL,mini-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)。 +-- ============================================================================= + +-- ----------------------------------------------------------------------------- +-- 段 1:ALTER 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 行不参与约束,手机号用户不受限)'; + +-- ----------------------------------------------------------------------------- +-- 段 2:CREATE 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.id;WU2 开户充值幂等键)', + `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)'; diff --git a/contracts/db-schemas/V31.0.1__passport_player_add_username_password_rollback.sql b/contracts/db-schemas/V31.0.1__passport_player_add_username_password_rollback.sql new file mode 100644 index 00000000..c9a685b1 --- /dev/null +++ b/contracts/db-schemas/V31.0.1__passport_player_add_username_password_rollback.sql @@ -0,0 +1,24 @@ +-- ============================================================================= +-- 回滚补偿迁移 | 主题:撤销 V31.0.0 用户名+密码注册登录 schema(WU1) +-- 文件: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'; + +-- 撤销段 1:drop 唯一键与两列,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 值域,属纯注释放宽、无需强制回退(存量数据不含该值时收回亦可,按需)。 + +-- 撤销段 2:drop outbox 机制表 +DROP TABLE IF EXISTS `game_passport_register_outbox`; diff --git a/contracts/db-schemas/V32.0.0__create_newapi_quota_pool.sql b/contracts/db-schemas/V32.0.0__create_newapi_quota_pool.sql new file mode 100644 index 00000000..b63a0ce1 --- /dev/null +++ b/contracts/db-schemas/V32.0.0__create_newapi_quota_pool.sql @@ -0,0 +1,58 @@ +-- ============================================================================= +-- 契约 #2 DB 迁移 | 主题:内测·new-api per-user ¥100 额度池(WU2,2026-07-07 设计 §3.5)| owner:aigc(派发期映射查询 co-locate) +-- 文件:V32.0.0__create_newapi_quota_pool.sql(Flyway,纯新增表;已合入禁止修改,回滚 = drop 表 + 摘事件消费者) +-- 依据:docs/agent-specs/2026-07-07-内测-WU2-newapi额度接入-设计.md(草案,创始人拍定「离线预置池 + 注册 claim」)。 +-- 版本定序:执行副本 db/migration/ 实测已连续到 V31.0.0(WU1 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 重复校验失败)。 +-- 守门②:含中文 SQL,mini-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), +-- 按条目 INSERT(status=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 —— 一玩家至多一条 CLAIMED(FREE 时 claimed_by 为 NULL,NULL 不参与唯一约束,多条 FREE 并存); +-- · uk_biz_no —— claim 幂等键 claim_(FREE 时为 NULL)。 +-- 并发/重投由 status='FREE' 单条 CAS + 上述两 NULL 唯一键三重收敛成一条:同玩家不占第二条、两玩家不抢同一条。 +-- newapi_token_key 是 new-api 调用凭据明文落库(内测可接受、须视为敏感):随 job 内网下发须日志脱敏,生产化应加密列(红线待办,本阶段不做)。 +-- 前后兼容:纯新增表,不改任何既有表结构,无迁移风险。 +-- ⚠ 再 claim(S4 凭据失效路,本单不实现):将 CLAIMED 隔离为 INVALID 时必须同步清空 claimed_by_game_player_id/biz_no, +-- 否则与该玩家新 CLAIMED 条目撞 uk_claimed_by/uk_biz_no。本单只落 FREE→CLAIMED,INVALID 转换延后到 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.key(48 位 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失效隔离(S4);claim CAS 抢占锚', + `claimed_by_game_player_id` BIGINT NULL COMMENT 'claim 后 ↔ game_player.id;FREE 时 NULL(uk 允许多 NULL,CLAIMED 保证一玩家至多一条)', + `claimed_at` DATETIME NULL COMMENT 'claim 时间(CLAIMED 时回填)', + `biz_no` VARCHAR(64) NULL COMMENT 'claim 幂等键 claim_;FREE 时 NULL', + `retry_count` INT NOT NULL DEFAULT 0 COMMENT '预留:claim/隔离重试计数', + `remark` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '备注(隔离原因等)', + -- 标准审计列(DO 继承 TenantBaseDO;ops 脚本 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 '一玩家至多一条 CLAIMED(NULL 不参与约束,多条 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)'; diff --git a/contracts/dify-workflow-io.json b/contracts/dify-workflow-io.json index 60da6e23..66152029 100644 --- a/contracts/dify-workflow-io.json +++ b/contracts/dify-workflow-io.json @@ -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"] } } } diff --git a/docs/agent-specs/2026-07-07-内测-WU1-注册登录用户名密码-设计.md b/docs/agent-specs/2026-07-07-内测-WU1-注册登录用户名密码-设计.md new file mode 100644 index 00000000..c2217413 --- /dev/null +++ b/docs/agent-specs/2026-07-07-内测-WU1-注册登录用户名密码-设计.md @@ -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 @ cb9f135d(registerPlayer / 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 @ cb9f135d(BCrypt 范式) + - 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
password-login
(app-api/passport)"] + REG --> IP{"同 IP 时/日
计数闸"} + IP -->|超阈值| DENY["429 请求过频"] + IP -->|放行| SVC["PassportServiceImpl
registerPlayer + BCrypt + buildLoginResp"] + SVC --> DB[("game_player
username/password 新列
mobile 放宽 NULL")] + SVC --> TOK["OAuth2 token
userType=MEMBER"] + SVC -.事务内写 outbox.-> HOOK["WU2 new-api 开户充值 hook
(至少一次·幂等·本档只交生产者)"] +``` + +**核心思想**:不新建鉴权体系,在 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
username=NULL
password=NULL"] + end + subgraph 用户名密码用户 + B["mobile=NULL
username=seed_alice
password=BCrypt(...)"] + end + A -.受 uk_mobile 约束.-> UKM["uk_mobile 唯一
(NULL 行不参与)"] + B -.受 uk_username 约束.-> UKU["uk_username 唯一
(NULL 行不参与)"] +``` + +**契约变更(DB schema,contract-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,缺 V14–V20 与 V26–V30,也就是执行副本反而**领先**契约源(V11 守门①「同版本同时落源+执行副本」其实早已长期失守,方向与直觉相反)。本单 V31 必须**同时**落契约源与执行副本把守门①在本次做实;缺失的 V14–V20、V26–V30 回填契约源属既有欠账,另案登记补齐,不在本单强行夹带。文件不放任何单模块 `-server/db/migration/`,否则同版本出现在多个 classpath jar 会触发 Flyway 重复校验失败。含中文 SQL,mini-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["① 契约
Flyway V31 + VO + 错误码 + PlayerDO/Mapper"] --> S2["② 服务层
registerPlayer 扩参 + password 两方法 + BCrypt + IP 计数泛化"] + S2 --> S3["③ 端点+配置
两 @PermitAll 端点 + wanxiang.passport 开关阈值 + 回填 sms/invite IP 闸"] + S3 --> S4["④ 前端
Login.vue Tab + passport.ts + locales"] + S4 --> S5["⑤ 真机验收
注册→登录→限流→零回归 + 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 一图看懂(§0,Mermaid,事实源)— 现行 +- 图1 单表双身份路径共存(§3.1,Mermaid)— 现行 +- 图2 注册/登录时序含 IP 闸与开户 hook(§3.2,Mermaid)— 现行 +- 图3 步骤计划(§4,Mermaid)— 现行 + +SVG 门面(§0 概览大图,给人 30 秒看懂)按 feature-design-doc 规约由图层 opus 子代理据本文字+Mermaid 派生,尚未产出,收口前补;图文冲突以本文字为准。本档过双评审门(Codex + Opus,Codex 挂回落 Opus 单评)后交创始人批,批准后随阶段 1 落代码物。 diff --git a/docs/agent-specs/2026-07-07-内测-WU2-newapi额度接入-设计.md b/docs/agent-specs/2026-07-07-内测-WU2-newapi额度接入-设计.md new file mode 100644 index 00000000..2aeec6c8 --- /dev/null +++ b/docs/agent-specs/2026-07-07-内测-WU2-newapi额度接入-设计.md @@ -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-注册登录用户名密码-设计.md(WU1 §3.6 是唯一注册接缝:afterCommit 发「注册成功」领域事件/RocketMQ 消息,载荷 userId/registerChannel/anonId。本设计是该事件的消费者,不在注册事务内挂点;WU1 迁移取 V31,本设计顺延取 V32) +关联: + - game-cloud/huijing-module-system/.../passport/PassportServiceImpl.java:214 @ cb9f135d(WU1 注册锚,本设计不改它,只消费其 afterCommit 事件) + - game-cloud/game-module-aigc/.../service/task/AigcTaskServiceImpl.java:202-204 @ cb9f135d(编排器/bake-off「伪装在线提交」经 submitGenerate→dispatchGeneric,task_source 现全落 online) + - game-cloud/game-module-aigc/.../executor/AigcGenerateExecutor.java:658-681 @ cb9f135d(dispatchGeneric 组 §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 带到 worker,worker 用它去调网关,网关按这把 token 扣该用户的额度。额度花完,网关直接拒,用户看到一句「生成额度已用完」。 + + + + 内测额度口径:账本在 new-api——离线预置池,运行时只 claim / 扣额 + + + + 离线预置(ops 脚本 · 一次性建 N 条 · 补池=重跑) + + + ops 预置脚本 + 建户→DB写access_token→建token→设quota + + + new-api 预建账户 + N×(user + token + ¥100) + + + game-cloud 额度池表 (D) + N 条 status=FREE + + + 凭据A+postgres 脏活 + + 脚本回写 FREE 条目 + + + + 用户注册 + registerPlayer + + + 建 game_player(WU1) + afterCommit 发注册成功事件 + + + WU2 消费事件 → 从池 claim + CAS 绑定·幂等·渠道过滤 + + + + + claim FREE→CLAIMED + + + + 生成任务派发 + job 加 userToken 位(查池表 CLAIMED) + + + worker + 用 job.userToken 调网关 + + + new-api 网关(账本) + tokens.remain_quota 减 + users.used_quota 增 = 权威消耗 + 100.64.0.8:3000 + + + + 该 token → 扣该用户额度 + + + + 额度耗尽 → 网关 402/403 → 干净失败「生成额度已用完」 + + + 核心思想:new-api per-user quota 当账本;provision 离线预置池,运行时只 claim 一个条目 + 生成扣额,零运行时 admin 耦合。 + 边界:不建钱包 / 不自助充值续额 / 运行时不调 admin API。新失败面=池空(懒 claim + 水位告警 + 补池)。成功=真机五验。 + + + + + + + + +三句话:**核心思想**——把 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 + 专属 token,token `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 管理凭据
ops 脚本用·不进运行时"] + B["B 离线 ops 预置脚本
预建 N×(user+token+¥100)"] + C["C 注册 claim 消费者
从池 CAS 绑定·渠道过滤"] + D["D 额度池表
FREE/CLAIMED 条目"] + E["E job 加 userToken 位
§6.1 契约追加字段"] + F["F worker 取 job token
缺失回落全局 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 预置脚本
建户 API→DB 写 access_token
→以该用户建 token API→设 quota API"] + OPS -->|凭据 A + postgres 脏活| NAP["new-api:预建 N×(user+token+¥100)"] + NAP -->|脚本回写条目 FREE| POOL["额度池表 newapi_quota_pool
N 条 FREE (D)"] + end + + U[用户] -->|注册| REG["registerPlayer(WU1)
注册事务提交"] + REG -->|同事务| PL["game_player 落库"] + REG -.->|afterCommit 发事件| EVT["注册成功事件/RocketMQ
userId·registerChannel·anonId"] + EVT -->|WU2 消费(C)· 渠道过滤| CLAIM["从池 CAS claim 一个 FREE
绑 game_player·写 biz_no"] + CLAIM -->|抢占 FREE→CLAIMED| POOL + CLAIM -.->|池空| ALERT["不抛·水位告警
懒 claim 兜(§3.6)"] + + U -->|创作生成| GEN["dispatchGeneric
组 §6.1 job (E)"] + GEN -->|creatorUserId 是否为 member?| MEMB{有 game_player 行} + MEMB -->|否 系统/编排/bake-off| GLOB["旁路:回落全局 key
不懒 claim"] + MEMB -->|是 真实 member| LOOK["查池表该玩家 CLAIMED 条目"] + LOOK -->|命中| TOK["job.userToken=token_key"] + LOOK -->|无 CLAIMED 条目| LZ["懒 claim 单 CAS 抢一次
池空→干净失败+水位告警"] + LZ -->|抢到| TOK + TOK -->|job.userToken| WK["worker (F)"] + WK -->|用 job.userToken 调网关| NA["new-api 网关"] + NA -->|扣该 user 额度| LEDGER["token remain_quota 减
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 的管理凭据。S0(2026-07-07,真机亲验、测试用户已清理)把这套管理面全部坐实,不再是假设: + +- **root 与令牌**:root 用户 = `id=1` `qingse`(`role=100`、`status=1`),持 32 字符 system access_token(存 new-api postgres `users.access_token`)。 +- **鉴权头**:`Authorization: Bearer ` **且** `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-infra(SSH 直连、绕系统代理,遵守内网直连纪律),建议 **Python**(可同时用 HTTP API 与 psycopg 直连 postgres,把干净活和 §3.2 那一步碰 postgres 的脏活一体承载)。脚本一次预建 N 个额度账户(内测 ≤200 规模,N 取略高于当前种子用户数、留补池余量)。 + +**每个池条目四步建成(S0 坐实的唯一可行路径)**: + +1. `POST /api/user/`(API,root 令牌)建用户,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=`——这一步是 S0 坐实的关键,`access_token` 无法经 API 设、又是下一步「以该用户身份建 token」的前提。 +3. `POST /api/token/`(API,**用该用户的 access_token + `New-Api-User:`**,即以该用户身份)建 token,name 用 `neice_<序号>`、`unlimited_quota=false`、`expired_time=不过期`,拿 48 位 `token_key`。 +4. `PUT /api/user/`(API,root 令牌)设 `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、读超时 10s,5xx/超时重试 2 次(指数退避 0.5s→1s),4xx 不重试;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_' 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 脚本离线预建
(user+token+¥100) 写池表 + FREE --> CLAIMED: 注册/懒 claim CAS 抢占
绑 game_player·写 biz_no + CLAIMED --> INVALID: 运行时发现 token 失效(401)
隔离·告警 ops(§3.9) + INVALID --> [*]: ops 排查/补池替换 + CLAIMED --> [*]: 玩家生命周期内长期持有 + FREE --> FREE: 池空→水位告警·懒 claim 兜(§3.6) +``` + +### 3.5 D · 额度池表:新表 newapi_quota_pool(V32.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.9);claim 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_`;`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` 返非 2xx(one-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`;重投事件不二次 claim(uk+CAS 拦);把池 FREE 清零后注册仍成功、水位告警触发、补池后首次生成懒 claim 绑上 | S1 池 + **WU1 事件接缝已落** | 池空是新失败面(懒 claim + 告警兜);幂等靠 uk + 单 CAS | +| S3 job 加位 + 身份门 + worker 取 token(E·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.5,contract-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,315);game-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_'`、`claimed_at` 非空;池 `FREE` 计数 -1。 +3. **生成扣减**:记录一次真 gen 前后该 claim 到的 user `SELECT used_quota FROM users WHERE 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 真机确认。 diff --git a/docs/agent-specs/2026-07-07-内测-WU3-dify游戏开发节点-设计.md b/docs/agent-specs/2026-07-07-内测-WU3-dify游戏开发节点-设计.md new file mode 100644 index 00000000..4895a029 --- /dev/null +++ b/docs/agent-specs/2026-07-07-内测-WU3-dify游戏开发节点-设计.md @@ -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-/、: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
nginx :18080/:18443"] + DN["游戏开发节点
(HTTP Request)"] + NA["new-api :3000
(LLM 网关)"] + D --> DN + end + subgraph GEN["便宜档生成栈 (dev,如 mini-desktop)"] + W["cheap-worker :9501
/generate 有界队列"] + S["cheap Service :8300
/chat agentscope 生成"] + DIR[("game_dir 落盘
src/ 多文件源工程")] + W --> S + S --> DIR + end + DN -->|"§6.1 job POST
(Tailscale)"| W + S -->|"OpenAI 兼容调模型"| NA + W -.->|"回流三选项
见 图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.3),dify 走的不是用户的计费门(决定了额度接缝,§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-` | worker 用作 `game_dir` 目录名与 OTLP conversation.id;形态 A 下无后端含义 | +| `traceId` | 与 `job_id` 同值,建议 `dify-` | 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 返 503,dify 节点据此快速失败。 + +### 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 生成完
product 已落 game_dir"] --> Q{"callback.target?"} + Q -->|"省略 (a-min · 推荐)"| A["worker 跳过回调
产物留在 game_dir 磁盘
= 落点"] + Q -->|"指向 dify 捕获入口 (a-dify)"| B["第二条 dify workflow
接住 result-out
= 产物回 dify 自己消费"] + Q -->|"指向后端 /dify/callback-internal (a-backend)"| C["handleCallbackTx
selectByTraceId 未命中
→ AIGC_TASK_NOT_EXISTS 拒
= 不进 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-/` 里那份 `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-closed,job.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-dify(dify 侧捕获入口自有一套隔离密钥、不碰生产),或用一个临时/隔离密钥做接线验证,别为一次烟测把生产 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-` 同值,所以**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//evidence/verdict.json` 九门 pass;③核 `game_dir/src/game-logic.js` 已偏离 `_template` 基线(agent 真改过游戏本体)。产物落点目录是 **`game-runtime/games/amgen-/src/`**(`cheap_run.game_dir` 返 `games/amgen-/`,dify 令 `gameId=dify-` 时实际目录是 `amgen-dify-/`,`gameId` 已含 `dify-` 前缀,别按字面去找 `dify-`)。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` 在实施落地后回写《内网凭据与端点》。 diff --git a/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/com/wanxiang/huijing/game/module/aigc/enums/FailureReasonEnum.java b/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/com/wanxiang/huijing/game/module/aigc/enums/FailureReasonEnum.java index 83112655..1f34be51 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/com/wanxiang/huijing/game/module/aigc/enums/FailureReasonEnum.java +++ b/game-cloud/game-module-aigc/game-module-aigc-api/src/main/java/com/wanxiang/huijing/game/module/aigc/enums/FailureReasonEnum.java @@ -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.9:new-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; diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/dal/dataobject/quota/NewapiQuotaPoolDO.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/dal/dataobject/quota/NewapiQuotaPoolDO.java new file mode 100644 index 00000000..ea5caa9e --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/dal/dataobject/quota/NewapiQuotaPoolDO.java @@ -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_pool,WU2 §3.5) + * + * 池表存的是「预建条目」而非「一玩家一映射行」——离线 ops 脚本 + * (game-runtime/tools/newapi_pool_provision.py)预建 N 个 (new-api user + token + ¥100) 按条目 INSERT + * (status=FREE、claimed_* 空);注册/懒 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_no):uk_newapi_user(脚本去重)/ uk_claimed_by(一玩家至多一条 CLAIMED)/ + * uk_biz_no(claim 幂等键 claim_<gamePlayerId>)——并发/重投由 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.key(48 位 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.id;FREE 时 NULL(uk_claimed_by 允许多 NULL) */ + private Long claimedByGamePlayerId; + /** claim 时间(CLAIMED 时回填) */ + private LocalDateTime claimedAt; + /** claim 幂等键 claim_<gamePlayerId>;FREE 时 NULL */ + private String bizNo; + /** 预留:claim/隔离重试计数 */ + private Integer retryCount; + /** 备注(隔离原因等) */ + private String remark; + +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/dal/mysql/quota/NewapiQuotaPoolMapper.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/dal/mysql/quota/NewapiQuotaPoolMapper.java new file mode 100644 index 00000000..424aebb0 --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/dal/mysql/quota/NewapiQuotaPoolMapper.java @@ -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 额度池 Mapper(WU2 §3.5) + * + *

跨租户存取铁律:池是系统级资源、注册消费/派发线均无租户上下文——调用方({@code NewapiQuotaServiceImpl}) + * 必须用 {@code TenantUtils.executeIgnore(...)} 包裹本 Mapper 的每次调用,否则多租户插件会按空租户上下文过滤致查不到条目。 + * + *

claim 幂等靠 CAS + 唯一键:{@link #claimOneFree} 是单条 {@code FREE→CLAIMED} 行级 CAS;并发/重投由 + * {@code status='FREE'} 条件 + uk_claimed_by + uk_biz_no 三重收敛,任何路径不给同一玩家占第二条、不让两玩家抢同一条。 + * + * @author 绘境AI + */ +@Mapper +public interface NewapiQuotaPoolMapper extends BaseMapperX { + + /** + * 按已绑玩家取其 CLAIMED 条目(懒 claim 前查 / 派发取 token;uk_claimed_by 保证至多一条)。 + * + * @param gamePlayerId game_player.id + * @return CLAIMED 条目;未绑返回 null + */ + default NewapiQuotaPoolDO selectClaimedByPlayer(Long gamePlayerId) { + return selectOne(new LambdaQueryWrapperX() + .eq(NewapiQuotaPoolDO::getClaimedByGamePlayerId, gamePlayerId) + .eq(NewapiQuotaPoolDO::getStatus, NewapiQuotaStatusEnum.CLAIMED.getStatus())); + } + + /** + * 池水位:FREE 条目计数(低于阈值触发补池告警,§3.4)。 + * + * @return 当前 FREE 条目数 + */ + default Long countFree() { + return selectCount(new LambdaQueryWrapperX() + .eq(NewapiQuotaPoolDO::getStatus, NewapiQuotaStatusEnum.FREE.getStatus())); + } + + /** + * 原子 CAS:抢一个 FREE 条目翻转 CLAIMED 并绑玩家(§3.4)。 + * + *

双层派生表 {@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_<gamePlayerId> + * @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); + +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/enums/NewapiQuotaStatusEnum.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/enums/NewapiQuotaStatusEnum.java new file mode 100644 index 00000000..798154e9 --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/enums/NewapiQuotaStatusEnum.java @@ -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 路,本单只落 FREE→CLAIMED,不产出 INVALID)。 + * 落库列 newapi_quota_pool.status(varchar(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; + +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/framework/executor/config/AigcExecutorConfiguration.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/framework/executor/config/AigcExecutorConfiguration.java index 79715643..b8473ccf 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/framework/executor/config/AigcExecutorConfiguration.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/framework/executor/config/AigcExecutorConfiguration.java @@ -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 sourceProjectApiProvider, - ObjectProvider genTaskProducerProvider) { + ObjectProvider genTaskProducerProvider, + ObjectProvider newapiQuotaServiceProvider) { // A11 M1②:软取 SourceProjectApi(studio @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); } } diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/mq/NewapiQuotaClaimConsumer.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/mq/NewapiQuotaClaimConsumer.java new file mode 100644 index 00000000..af778e5a --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/mq/NewapiQuotaClaimConsumer.java @@ -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 绑玩家。 + * + *

接缝:订阅 WU1 outbox 投递器的 topic {@code passport-register}(生产侧常量 + * {@code PassportRegisterOutboxDeliverer.TOPIC};跨模块不依赖 system-server,故此处以字面量对齐 wire 契约)。 + * 消息载荷是 JSON,反序列化进本模块镜像体 {@link PassportRegisterEvent}(字段与生产侧 {@code PassportRegisterMessage} 同名)。 + * + *

装配开关:随 {@code aigc.newapi-quota.enabled=true} 装配——关闭(默认)时不起消费者、不连 NameServer, + * WU2 整体未启用时零副作用(与 {@code NewapiQuotaServiceImpl} 主开关同源)。 + * + *

幂等 + 不外抛: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.TOPIC(wire 契约,跨模块不引 -server) + consumerGroup = "newapi_quota_claim_consumer" +) +public class NewapiQuotaClaimConsumer implements RocketMQListener { + + /** 额度池服务(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); + } + } + +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/mq/message/PassportRegisterEvent.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/mq/message/PassportRegisterEvent.java new file mode 100644 index 00000000..81cc2144 --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/mq/message/PassportRegisterEvent.java @@ -0,0 +1,29 @@ +package com.wanxiang.huijing.game.module.aigc.mq.message; + +import lombok.Data; + +import java.io.Serializable; + +/** + * 「注册成功」事件消费侧镜像体(WU2 §3.4) + * + *

WU1 的生产侧消息类 {@code PassportRegisterMessage} 落在 huijing-module-system-server(-server,非 -api), + * 跨模块不依赖对方 -server(守门④)。故本模块按同名字段建一个消费侧镜像 DTO——RocketMQ 以 JSON 传输载荷, + * 消费端 {@code RocketMQListener} 按字段名反序列化即得,与生产侧解耦(topic 是唯一 wire 契约)。 + * 字段须与生产侧 {@code PassportRegisterMessage} 逐一同名(userId/registerChannel/anonId/idempotentKey)。 + * + * @author 绘境AI + */ +@Data +public class PassportRegisterEvent implements Serializable { + + /** 新注册玩家编号(game_player.id;claim 幂等键) */ + private Long userId; + /** 注册通道:sms / invite / password(渠道过滤据此) */ + private String registerChannel; + /** 注册时携带的客户端 anonId(本消费不使用,保真镜像) */ + private String anonId; + /** 幂等键(=register:{userId}) */ + private String idempotentKey; + +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/executor/AigcGenerateExecutor.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/executor/AigcGenerateExecutor.java index aeaf9d5c..75db60f3 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/executor/AigcGenerateExecutor.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/executor/AigcGenerateExecutor.java @@ -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 额度门 seam(WU2 §3.6,可为 null):真实 member 在线创作派发时取其 claim 到的 token 放进 + * §6.1 job 的 {@code userToken} 位;系统/编排/bake-off(无 game_player 行)旁路留空、worker 回落全局 key。 + *

null 时(旧构造/单测态):不注入 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 时对所有创作者返 null,opt-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; // C3:gen 队列生产者(兜底 tick 重发信号用);非空=MQ 主触发 + 兜底重发信号(投递可靠性补偿),null=兜底退回直派(单测/降级态)。 this.genTaskProducer = genTaskProducer; + // WU2 §3.6:per-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 worker(Python,无 DB)只被动消费 job["sourceProject"](键名同 SAA 路 K_SOURCE_PROJECT);反查失败/缺源则不放该键(best-effort 非阻断)。 putModifyFieldsIntoJob(job, task); + // ── WU2 §3.6:per-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.8):token 是 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 路。 diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/quota/NewapiQuotaService.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/quota/NewapiQuotaService.java new file mode 100644 index 00000000..011aad69 --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/quota/NewapiQuotaService.java @@ -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 门) + * + *

账本在 new-api 网关,本服务只做两件纯 game-cloud 内部 DB 操作、零 new-api 外呼: + *

    + *
  1. 注册 claim({@link #claimForRegister}):消费 WU1「注册成功」事件,从预置池 CAS 抢一个 FREE 绑玩家;
  2. + *
  3. 派发取 token({@link #resolveUserTokenForDispatch}):真实 member 在线创作时取其 claim 到的 token 随 job 下发。
  4. + *
+ * + *

主开关:{@code aigc.newapi-quota.enabled}(默认 false)——关时两个入口全旁路(现行行为,worker 回落全局 key), + * 开时注册 claim 消费者装配 + per-user 门生效。这样 WU2 可整体 opt-in,池未预置前不误伤既有生成/批跑验证路。 + * + * @author 绘境AI + */ +public interface NewapiQuotaService { + + /** + * 注册 claim(§3.4):消费「注册成功」事件,对种子渠道从池 CAS 抢一个 FREE 绑该玩家(幂等)。 + * + *

渠道过滤:只对 {@code password/invite} claim;{@code sms} 自动注册与 {@code sms-mock} 后门跳过 + * (避免一次性手机号每次首登占掉一个 ¥100 池条目,这类若真去生成由懒 claim 兜)。 + *

幂等:uk_claimed_by + uk_biz_no + 单条 CAS 三重收敛,重投/并发不给同一玩家占第二条。 + *

池空:不抛异常(补池是离线人工动作、非 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.6)——per-user 门只对真实 member 在线创作生效。 + * + *

    + *
  • 主开关关 / creatorUserId 空 → 返 null(旁路,worker 回落全局 key);
  • + *
  • 无 game_player 行(系统/编排/bake-off/admin 触发)→ 返 null(旁路,不查池不懒 claim);
  • + *
  • 命中 game_player 行(真实 member)→ 取其 CLAIMED 条目的 token_key;无 CLAIMED 则单 CAS 懒 claim 抢一个 FREE: + * 抢到返 token_key,池空抢不到 → 抛 {@link QuotaPoolExhaustedException}(当次干净失败给可读拒因 + 水位告警)。
  • + *
+ * + * @param creatorUserId 任务创作者({@code AigcTaskDO.creatorUserId}) + * @return per-user token_key(member 命中/懒 claim 成功);null(旁路:系统触发或主开关关) + * @throws QuotaPoolExhaustedException member 需要 token 但池空、懒 claim 抢不到 + */ + String resolveUserTokenForDispatch(Long creatorUserId); + +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/quota/NewapiQuotaServiceImpl.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/quota/NewapiQuotaServiceImpl.java new file mode 100644 index 00000000..d1d807d6 --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/quota/NewapiQuotaServiceImpl.java @@ -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) + * + *

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; + } + // 渠道过滤:只对种子渠道 claim;sms 自动注册 / 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 行 → 旁路:不查池、不懒 claim,userToken 留空,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) { + // 水位查询失败不阻断 claim(best-effort 观测信号);错误路径留痕。 + log.warn("[newapi-quota] 池水位查询失败(不阻断 claim)scene={}", 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); + } + +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/quota/QuotaPoolExhaustedException.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/quota/QuotaPoolExhaustedException.java new file mode 100644 index 00000000..b4a019e0 --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/quota/QuotaPoolExhaustedException.java @@ -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 下去静默烧共享额度。运行时异常:不强制调用方 try(dispatch 显式捕获处置)。 + * + * @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; + } + +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/enums/FailureReasonEnumQuotaTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/enums/FailureReasonEnumQuotaTest.java new file mode 100644 index 00000000..45830bfe --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/enums/FailureReasonEnumQuotaTest.java @@ -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} 跨契约共享枚举存在且取值稳定。 + * + *

该值须与 contracts/api-schemas/aigc.yaml 的 FailureReason.enum、contracts/dify-workflow-io.json 的 + * output.failureReason.enum 三处一致(contract-first,docs 门对账);落库列 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()); + } + +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/mq/NewapiQuotaClaimConsumerTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/mq/NewapiQuotaClaimConsumerTest.java new file mode 100644 index 00000000..5decbfbd --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/mq/NewapiQuotaClaimConsumerTest.java @@ -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」与「不外抛」契约。 + * + *

覆盖:正常事件透传 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"))); + } + +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/quota/NewapiQuotaServiceImplTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/quota/NewapiQuotaServiceImplTest.java new file mode 100644 index 00000000..f06b7ebd --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/quota/NewapiQuotaServiceImplTest.java @@ -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 门契约。 + * + *

覆盖: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_。 */ + @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")); + } + + /** 并发同玩家 claim:CAS 撞 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)); + } + +} diff --git a/game-cloud/huijing-framework/huijing-spring-boot-starter-web/src/main/java/com/wanxiang/huijing/framework/apilog/core/interceptor/ApiAccessLogInterceptor.java b/game-cloud/huijing-framework/huijing-spring-boot-starter-web/src/main/java/com/wanxiang/huijing/framework/apilog/core/interceptor/ApiAccessLogInterceptor.java index 89784251..0a68ea35 100644 --- a/game-cloud/huijing-framework/huijing-spring-boot-starter-web/src/main/java/com/wanxiang/huijing/framework/apilog/core/interceptor/ApiAccessLogInterceptor.java +++ b/game-cloud/huijing-framework/huijing-spring-boot-starter-web/src/main/java/com/wanxiang/huijing/framework/apilog/core/interceptor/ApiAccessLogInterceptor.java @@ -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/控制台。 + *

+ * 背景:本 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 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 方法路径 */ diff --git a/game-cloud/huijing-module-system/huijing-module-system-api/src/main/java/com/wanxiang/huijing/module/system/enums/ErrorCodeConstants.java b/game-cloud/huijing-module-system/huijing-module-system-api/src/main/java/com/wanxiang/huijing/module/system/enums/ErrorCodeConstants.java index ca27d9e7..080aa522 100644 --- a/game-cloud/huijing-module-system/huijing-module-system-api/src/main/java/com/wanxiang/huijing/module/system/enums/ErrorCodeConstants.java +++ b/game-cloud/huijing-module-system/huijing-module-system-api/src/main/java/com/wanxiang/huijing/module/system/enums/ErrorCodeConstants.java @@ -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, "创作功能限内测白名单,当前账号暂无创作权限"); // 拍板4:creator_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, "邀请码无效"); // 码不存在(与已停用/已过期/已耗尽区分,便于运营定位) diff --git a/game-cloud/huijing-module-system/huijing-module-system-server/pom.xml b/game-cloud/huijing-module-system/huijing-module-system-server/pom.xml index f6da8ceb..6995339a 100644 --- a/game-cloud/huijing-module-system/huijing-module-system-server/pom.xml +++ b/game-cloud/huijing-module-system/huijing-module-system-server/pom.xml @@ -97,6 +97,13 @@ huijing-spring-boot-starter-mq + + + org.apache.rocketmq + rocketmq-spring-boot-starter + + com.wanxiang diff --git a/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/controller/app/passport/AppPassportController.java b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/controller/app/passport/AppPassportController.java index 5b8b5746..7803ba85 100644 --- a/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/controller/app/passport/AppPassportController.java +++ b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/controller/app/passport/AppPassportController.java @@ -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 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 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 getPlayerMe() { diff --git a/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/controller/app/passport/vo/PasswordLoginReqVO.java b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/controller/app/passport/vo/PasswordLoginReqVO.java new file mode 100644 index 00000000..5489e3ba --- /dev/null +++ b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/controller/app/passport/vo/PasswordLoginReqVO.java @@ -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; + +} diff --git a/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/controller/app/passport/vo/PasswordRegisterReqVO.java b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/controller/app/passport/vo/PasswordRegisterReqVO.java new file mode 100644 index 00000000..d977dc40 --- /dev/null +++ b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/controller/app/passport/vo/PasswordRegisterReqVO.java @@ -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; + +} diff --git a/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/controller/app/passport/vo/PlayerMeRespVO.java b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/controller/app/passport/vo/PlayerMeRespVO.java index 410d6533..39312323 100644 --- a/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/controller/app/passport/vo/PlayerMeRespVO.java +++ b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/controller/app/passport/vo/PlayerMeRespVO.java @@ -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; diff --git a/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/dal/dataobject/passport/PassportRegisterOutboxDO.java b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/dal/dataobject/passport/PassportRegisterOutboxDO.java new file mode 100644 index 00000000..21019ced --- /dev/null +++ b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/dal/dataobject/passport/PassportRegisterOutboxDO.java @@ -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(事务性 outbox,2026-07-07 WU1 §3.6) + * + * 对应表 game_passport_register_outbox。三条注册路(sms 自动注册 / invite / password)共用出口: + * 注册事务内与建号原子写一行(status=0 未投递);事务外投递器(@Scheduled)扫未投递发 RocketMQ、 + * 发成功置 status=1。把「至少一次」锚在 DB 事务上,杜绝「注册成功但事件从未入队」黑洞。 + *

+ * 多租户:本表为机制表,标 {@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.id;WU2 开户充值幂等键) + */ + 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; + +} diff --git a/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/dal/dataobject/passport/PlayerDO.java b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/dal/dataobject/passport/PlayerDO.java index 0bf2cac8..03e3714a 100644 --- a/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/dal/dataobject/passport/PlayerDO.java +++ b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/dal/dataobject/passport/PlayerDO.java @@ -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 唯一;手机号用户为 NULL,2026-07-07 WU1) + */ + private String username; + /** + * 密码 BCrypt 密文(⚠ 仅服务层内部用于编码/校验,永不进任何 RespVO、永不落日志;非用户名用户为 NULL,2026-07-07 WU1) + */ + private String password; /** * 昵称(注册默认生成,可改) */ diff --git a/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/dal/mysql/passport/PassportRegisterOutboxMapper.java b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/dal/mysql/passport/PassportRegisterOutboxMapper.java new file mode 100644 index 00000000..dd48fe18 --- /dev/null +++ b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/dal/mysql/passport/PassportRegisterOutboxMapper.java @@ -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; + +/** + * 「注册成功」事件本地消息表 Mapper(2026-07-07 WU1 §3.6) + * + * 投递器(@Scheduled)按未投递态分页扫描发 MQ;发成功用条件更新 CAS 置已投递(幂等,避免重复 MQ 与并发双投)。 + * + * @author 绘境AI + */ +@Mapper +public interface PassportRegisterOutboxMapper extends BaseMapperX { + + /** + * 扫描未投递事件(status=0),按 id 升序取前 limit 条(投递器批量投递用) + * + * @param limit 单批上限(有界扫描,避免一次拉全表) + * @return 未投递 outbox 列表 + */ + default List selectPendingList(int limit) { + LambdaQueryWrapper query = new LambdaQueryWrapper() + .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 update = new LambdaUpdateWrapper() + .set(PassportRegisterOutboxDO::getStatus, PassportRegisterOutboxDO.STATUS_DELIVERED) + .set(PassportRegisterOutboxDO::getDeliveredTime, deliveredTime) + .eq(PassportRegisterOutboxDO::getId, id) + .eq(PassportRegisterOutboxDO::getStatus, PassportRegisterOutboxDO.STATUS_PENDING); + return update(null, update); + } + +} diff --git a/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/dal/mysql/passport/PlayerMapper.java b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/dal/mysql/passport/PlayerMapper.java index 5794afb1..8a40603b 100644 --- a/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/dal/mysql/passport/PlayerMapper.java +++ b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/dal/mysql/passport/PlayerMapper.java @@ -29,6 +29,16 @@ public interface PlayerMapper extends BaseMapperX { 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 * diff --git a/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/dal/redis/passport/PassportSmsIpCounterRedisDAO.java b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/dal/redis/passport/PassportSmsIpCounterRedisDAO.java index 943c0e90..aa575372 100644 --- a/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/dal/redis/passport/PassportSmsIpCounterRedisDAO.java +++ b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/dal/redis/passport/PassportSmsIpCounterRedisDAO.java @@ -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 频控计数 RedisDAO(R6 切渠道安全前置,§7.2) + * passport 应用层纯 IP 频控计数 RedisDAO(R6 发码前置 + 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} - * VALUE:String 计数;TTL 在首次自增时设置(日桶 1 天、小时桶 1 小时),到期自动清。 + * 在 passport 各免登端点入层按「纯 IP 维度」的时/日双桶计数(key 只含 scene+IP+日期,不含请求体), + * 以 Redis 自增 + 首次设过期实现按日历日/小时的自然窗口。原发码专用 KEY 前缀泛化为 scene 分桶, + * scene ∈ {sms 发码, register 用户名注册, login 登录(sms/password 共用), invite 邀请码注册}。 + *

+ * KEY 格式:passport_ip:{scene}:hour:{ip}:{yyyyMMddHH} / passport_ip:{scene}:day:{ip}:{yyyyMMdd} + * VALUE:String 计数;TTL 在首次自增时设置(日桶 1 天、小时桶 1 小时),到期自动清。 + *

+ * ⚠ 一次性重置(§3.3 如实记账):上线本改动那一刻,旧前缀 passport_sms_ip_* 的存量计数桶全部失效, + * 等于对已在计数的 IP 一次性重置限流窗口;桶 TTL 有界、只重置一次,内测无害,观测同步改名即可。 + *

+ * 频控后端故障姿态(§3.3 定死):fail-open + WARN 告警。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; } } diff --git a/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/mq/message/passport/PassportRegisterMessage.java b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/mq/message/passport/PassportRegisterMessage.java new file mode 100644 index 00000000..94a2be26 --- /dev/null +++ b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/mq/message/passport/PassportRegisterMessage.java @@ -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; + +} diff --git a/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/mq/producer/passport/PassportRegisterOutboxDeliverer.java b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/mq/producer/passport/PassportRegisterOutboxDeliverer.java new file mode 100644 index 00000000..8d9ebc63 --- /dev/null +++ b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/mq/producer/passport/PassportRegisterOutboxDeliverer.java @@ -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 投递器(事务外 @Scheduled,2026-07-07 WU1 §3.6) + * + * 与建号原子写入的 outbox 行({@link PassportRegisterOutboxDO})由本投递器周期扫描(status=0 未投递), + * 同步发 RocketMQ(带超时),发成功用条件更新 CAS 置已投递(status=1)。把「至少一次」锚在 DB 事务上而非 + * MQ 即时可达性上:建号成功则 outbox 必落库,投递器保证最终发出;MQ 抖动/超时时该行保留未投递态下轮重投。 + *

+ * 外部交互纪律(创始人铁律):syncSend 带发送超时;发送失败/非 SEND_OK 不置已投递(下轮重投,有界批扫兜底); + * 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 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 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 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; + } + } + +} diff --git a/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/service/passport/PassportService.java b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/service/passport/PassportService.java index 1ffc05aa..abaa678b 100644 --- a/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/service/passport/PassportService.java +++ b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/service/passport/PassportService.java @@ -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=password;2026-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 端点,登录态);手机号脱敏返回 * diff --git a/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/service/passport/PassportServiceImpl.java b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/service/passport/PassportServiceImpl.java index d3dff35a..542f31db 100644 --- a/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/service/passport/PassportServiceImpl.java +++ b/game-cloud/huijing-module-system/huijing-module-system-server/src/main/java/com/wanxiang/huijing/module/system/service/passport/PassportServiceImpl.java @@ -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.4,staging 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,先增后判,越限 429;IP 取连接层 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=password,mobile=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)落 mobile、username/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 核销的邀请码 ID(sms 通道传 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 核销的邀请码 ID(sms/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 { // 目标3:anonId 仅注册时落一次;缺失落空串(归因降级不阻断登录,§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_username(mobile=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(待投递器发 MQ)userId={}, 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 连接层 IP(password 路取 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()); diff --git a/game-cloud/huijing-module-system/huijing-module-system-server/src/test/java/com/wanxiang/huijing/module/system/dal/redis/passport/PassportSmsIpCounterRedisDAOTest.java b/game-cloud/huijing-module-system/huijing-module-system-server/src/test/java/com/wanxiang/huijing/module/system/dal/redis/passport/PassportSmsIpCounterRedisDAOTest.java new file mode 100644 index 00000000..e0f551f6 --- /dev/null +++ b/game-cloud/huijing-module-system/huijing-module-system-server/src/test/java/com/wanxiang/huijing/module/system/dal/redis/passport/PassportSmsIpCounterRedisDAOTest.java @@ -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 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); + } + +} diff --git a/game-cloud/huijing-module-system/huijing-module-system-server/src/test/java/com/wanxiang/huijing/module/system/service/passport/PassportServiceImplTest.java b/game-cloud/huijing-module-system/huijing-module-system-server/src/test/java/com/wanxiang/huijing/module/system/service/passport/PassportServiceImplTest.java index 0da13f35..e58dd0be 100644 --- a/game-cloud/huijing-module-system/huijing-module-system-server/src/test/java/com/wanxiang/huijing/module/system/service/passport/PassportServiceImplTest.java +++ b/game-cloud/huijing-module-system/huijing-module-system-server/src/test/java/com/wanxiang/huijing/module/system/service/passport/PassportServiceImplTest.java @@ -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 默认 false(setUp 已置) - 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=password、username 落列、password 落 BCrypt 密文、mobile=null(用户名路)、默认昵称取用户名 + ArgumentCaptor 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 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/过期时间 */ diff --git a/game-cloud/huijing-server/src/main/resources/application-staging.yaml b/game-cloud/huijing-server/src/main/resources/application-staging.yaml index 069eec30..bf0cb904 100644 --- a/game-cloud/huijing-server/src/main/resources/application-staging.yaml +++ b/game-cloud/huijing-server/src/main/resources/application-staging.yaml @@ -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 每日上限(推断初值) diff --git a/game-cloud/huijing-server/src/main/resources/db/migration/V31.0.0__passport_player_add_username_password.sql b/game-cloud/huijing-server/src/main/resources/db/migration/V31.0.0__passport_player_add_username_password.sql new file mode 100644 index 00000000..985f7cb7 --- /dev/null +++ b/game-cloud/huijing-server/src/main/resources/db/migration/V31.0.0__passport_player_add_username_password.sql @@ -0,0 +1,52 @@ +-- ============================================================================= +-- 契约 #2 DB 迁移 | 主题:内测·用户名+密码注册登录(WU1,2026-07-07 设计 §3.1/§3.6)| owner:passport(system 内扩展) +-- 文件:V31.0.0__passport_player_add_username_password.sql(Flyway,只 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 重复校验失败)。 +-- 守门②:含中文 SQL,mini-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)。 +-- ============================================================================= + +-- ----------------------------------------------------------------------------- +-- 段 1:ALTER 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 行不参与约束,手机号用户不受限)'; + +-- ----------------------------------------------------------------------------- +-- 段 2:CREATE 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.id;WU2 开户充值幂等键)', + `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)'; diff --git a/game-cloud/huijing-server/src/main/resources/db/migration/V32.0.0__create_newapi_quota_pool.sql b/game-cloud/huijing-server/src/main/resources/db/migration/V32.0.0__create_newapi_quota_pool.sql new file mode 100644 index 00000000..b63a0ce1 --- /dev/null +++ b/game-cloud/huijing-server/src/main/resources/db/migration/V32.0.0__create_newapi_quota_pool.sql @@ -0,0 +1,58 @@ +-- ============================================================================= +-- 契约 #2 DB 迁移 | 主题:内测·new-api per-user ¥100 额度池(WU2,2026-07-07 设计 §3.5)| owner:aigc(派发期映射查询 co-locate) +-- 文件:V32.0.0__create_newapi_quota_pool.sql(Flyway,纯新增表;已合入禁止修改,回滚 = drop 表 + 摘事件消费者) +-- 依据:docs/agent-specs/2026-07-07-内测-WU2-newapi额度接入-设计.md(草案,创始人拍定「离线预置池 + 注册 claim」)。 +-- 版本定序:执行副本 db/migration/ 实测已连续到 V31.0.0(WU1 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 重复校验失败)。 +-- 守门②:含中文 SQL,mini-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), +-- 按条目 INSERT(status=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 —— 一玩家至多一条 CLAIMED(FREE 时 claimed_by 为 NULL,NULL 不参与唯一约束,多条 FREE 并存); +-- · uk_biz_no —— claim 幂等键 claim_(FREE 时为 NULL)。 +-- 并发/重投由 status='FREE' 单条 CAS + 上述两 NULL 唯一键三重收敛成一条:同玩家不占第二条、两玩家不抢同一条。 +-- newapi_token_key 是 new-api 调用凭据明文落库(内测可接受、须视为敏感):随 job 内网下发须日志脱敏,生产化应加密列(红线待办,本阶段不做)。 +-- 前后兼容:纯新增表,不改任何既有表结构,无迁移风险。 +-- ⚠ 再 claim(S4 凭据失效路,本单不实现):将 CLAIMED 隔离为 INVALID 时必须同步清空 claimed_by_game_player_id/biz_no, +-- 否则与该玩家新 CLAIMED 条目撞 uk_claimed_by/uk_biz_no。本单只落 FREE→CLAIMED,INVALID 转换延后到 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.key(48 位 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失效隔离(S4);claim CAS 抢占锚', + `claimed_by_game_player_id` BIGINT NULL COMMENT 'claim 后 ↔ game_player.id;FREE 时 NULL(uk 允许多 NULL,CLAIMED 保证一玩家至多一条)', + `claimed_at` DATETIME NULL COMMENT 'claim 时间(CLAIMED 时回填)', + `biz_no` VARCHAR(64) NULL COMMENT 'claim 幂等键 claim_;FREE 时 NULL', + `retry_count` INT NOT NULL DEFAULT 0 COMMENT '预留:claim/隔离重试计数', + `remark` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '备注(隔离原因等)', + -- 标准审计列(DO 继承 TenantBaseDO;ops 脚本 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 '一玩家至多一条 CLAIMED(NULL 不参与约束,多条 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)'; diff --git a/game-runtime/tools/newapi_pool_provision.py b/game-runtime/tools/newapi_pool_provision.py new file mode 100644 index 00000000..e48b03c1 --- /dev/null +++ b/game-runtime/tools/newapi_pool_provision.py @@ -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 令牌不能替他人建 token、access_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=false、expired_time=-1(不过期);从 postgres 查 token_id/key。 + 4. PUT /api/user/(root 令牌)设 user.quota=¥100 折算(账户自洽;权威余额闸仍是 token.remain_quota)。 + +幂等(可重跑补池) +------------------ +按确定性 username 去重:重跑时若用户已存在则复用其 uid,逐步「补齐」缺失的 access_token / +token / quota(ensure 语义),不重复建号。补池 = 提高 --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 用户 id(qingse),持系统管理 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 "" + if len(token) <= 10: + return token[:2] + "***" + return f"{token[:4]}…{token[-4:]}(len={len(token)})" + + +# ─── postgres 直连(经 docker exec psql,S0 验证路径) ────────────────────── +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 HTTP(stdlib 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 + New-Api-User: (缺后者即 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: + """¥ → quota:round(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] 计划预置 %s(grant=%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) + + # 步骤 2:DB 直写 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.quota(root 令牌;账户自洽,权威余额闸仍是 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, # 仅审计参考,非池表列 + } + + +# ─── emit:JSON + 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") + + # SQL:INSERT ... 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()) diff --git a/game-studio/src/api/passport.ts b/game-studio/src/api/passport.ts index 79206aa9..ff2706c5 100644 --- a/game-studio/src/api/passport.ts +++ b/game-studio/src/api/passport.ts @@ -39,6 +39,35 @@ export interface InviteRegisterReq { nickname?: string } +/** + * 用户名密码注册请求体(贴 PasswordRegisterReqVO;内测种子用户建号即登录,WU1 §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 +} + +/** + * 用户名密码登录请求体(贴 PasswordLoginReqVO;WU1 §3.2)。 + * 防枚举:后端对「查无用户名」与「密码错误」统一返 1-002-090-005(文案一致,不可区分)。 + */ +export interface PasswordLoginReq { + /** 用户名(登录主键之一;^[a-zA-Z0-9]{4,30}$) */ + username: string + /** 密码(明文;登录不重复长度校验,仅非空) */ + password: string + /** 客户端 anonId(可选;登录不落库,仅遥测归因透传) */ + anonId?: string +} + /** * 登录/注册成功响应(贴 LoginRespVO;sms-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 { }) } +/** + * 用户名密码注册(建号即登录,@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 { + return request({ + url: '/app-api/passport/password-register', + method: 'post', + data: dto, + }) +} + +/** + * 用户名密码登录(@PermitAll 免登;防枚举:查无用户名与密码错误统一 1-002-090-005,WU1 §3.2)。 + * POST /app-api/passport/password-login + * @param dto 登录请求体(username + password + 可选 anonId) + * @returns 真 OAuth2 token 与玩家身份摘要(同 sms-login 响应) + */ +export function passwordLogin(dto: PasswordLoginReq): Promise { + return request({ + url: '/app-api/passport/password-login', + method: 'post', + data: dto, + }) +} + /** * 当前登录玩家信息(登录态必需;未登录返回 401,由 request.ts 401 拦截兜底)。 * GET /app-api/passport/me diff --git a/game-studio/src/locales/entry.en.ts b/game-studio/src/locales/entry.en.ts index ece12aa8..186cc731 100644 --- a/game-studio/src/locales/entry.en.ts +++ b/game-studio/src/locales/entry.en.ts @@ -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 —— diff --git a/game-studio/src/locales/entry.zh.ts b/game-studio/src/locales/entry.zh.ts index 15703888..4f04e288 100644 --- a/game-studio/src/locales/entry.zh.ts +++ b/game-studio/src/locales/entry.zh.ts @@ -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)—— diff --git a/game-studio/src/views/login/Login.vue b/game-studio/src/views/login/Login.vue index 857b66c6..988c3820 100644 --- a/game-studio/src/views/login/Login.vue +++ b/game-studio/src/views/login/Login.vue @@ -3,10 +3,13 @@ * 登录页 | Login.vue(路由 /login,真实鉴权件 spec §9.1) * ---------------------------------------------------------------------------- * 职责: - * 1. 两 Tab 登录/注册: + * 1. 三 Tab 登录/注册: * ① 手机号 + 验证码(主路径,拍板1C):send-sms-code 发码 → sms-login 登录/自动注册一体; * ② 邀请码注册(受限激活期陌生玩家入口,拍板2/3):mobile + inviteCode → invite-register, * 页内文案注明「内测邀请」。 + * ③ 用户名密码(内测种子用户,WU1 §3.5):username + 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('') /** 短信验证码(sms Tab) */ const code = ref('') /** 邀请码(invite Tab) */ const inviteCode = ref('') +/** 用户名(password Tab) */ +const username = ref('') +/** 密码(password Tab;不 trim,允许含空格,绝不落日志/遥测) */ +const password = ref('') +/** 用户名密码 Tab 子模式:'login'=登录(默认)/ 'register'=注册(建号即登录) */ +const passwordMode = ref<'login' | 'register'>('login') /** 隐私政策/用户协议勾选(占位;未勾选不可提交) */ const agreed = ref(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 分派): * - sms:smsLogin(已注册登录 / 未注册自动注册一体); - * - invite:inviteRegister(手机号+邀请码受限激活)。 - * 两路均携带 anonId(身份衔接,可选);失败由拦截器 Toast 错误码,仅解锁。 + * - invite:inviteRegister(手机号+邀请码受限激活); + * - password:登录子模式 passwordLogin / 注册子模式 passwordRegister(建号即登录)。 + * 三路均携带 anonId(身份衔接,可选);失败由拦截器 Toast 错误码(1-002-090/091),仅解锁。 */ async function onSubmit(): Promise { 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 { 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 {

- +