merge(neice): 内测闭环阶段1 integration 并入 dev/2.0.0
WU1 注册登录(用户名+密码/无短信/网关IP限流/防枚举/outbox) WU2 new-api per-user ¥100 额度(预置池 claim/job userToken 位/per-user 门/quota_exhausted) WU3 dify 形态A 游戏开发节点设计 + ops 池预置脚本 全 opt-in 默认 off(AIGC_NEWAPI_QUOTA_ENABLED / wanxiang.passport.password-auth-enabled)。 阶段2 dev 真机全环 e2e LIVE 反假绿。dev 前进 36 文件与本支 57 文件零交集。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
210ce70160
@ -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(
|
||||
|
||||
@ -25,23 +25,46 @@ def _resolve_base() -> str:
|
||||
return client.resolve_base_url()
|
||||
|
||||
|
||||
def _resolve_key() -> str:
|
||||
"""NEWAPI_KEY(_bootstrap 已从凭据档注入 env;client.get_api_key 从 env 读)。"""
|
||||
def _mask_token(tok) -> str:
|
||||
"""token 日志脱敏(§3.8):前后各留 4 位、中间省略;过短/空则整体隐藏。绝不整条打 new-api 凭据。"""
|
||||
if not tok:
|
||||
return "<none>"
|
||||
s = str(tok)
|
||||
return "****" if len(s) <= 8 else f"{s[:4]}…{s[-4:]}"
|
||||
|
||||
|
||||
def _resolve_key(user_token: str | None = None) -> str:
|
||||
"""解析本次生成用的 new-api 调用凭据(WU2 §3.7 F)。
|
||||
|
||||
优先用 job 随派带来的 per-user token(user_token);缺失则回落全局 env NEWAPI_KEY
|
||||
(_bootstrap 已从凭据档注入 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 往下走——
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -46,6 +46,14 @@ def log(msg: str) -> None:
|
||||
print(f"[cheap-worker-service] {msg}", flush=True)
|
||||
|
||||
|
||||
def _mask_token(tok) -> str:
|
||||
"""token 日志脱敏(WU2 §3.8):前后各留 4 位、中间省略;过短/空则整体隐藏。绝不整条打 userToken。"""
|
||||
if not tok:
|
||||
return "<none>"
|
||||
s = str(tok)
|
||||
return "****" if len(s) <= 8 else f"{s[:4]}…{s[-4:]}"
|
||||
|
||||
|
||||
# ---------- §6.1 job-in 解析 ----------
|
||||
|
||||
def parse_job(raw: bytes) -> dict | None:
|
||||
@ -163,6 +171,9 @@ def _service_run_fn(job: dict):
|
||||
import cheap_service_driver
|
||||
|
||||
base_url = os.environ.get("CHEAP_SERVICE_URL", "http://127.0.0.1:8300")
|
||||
# WU2 §3.7 F:整条 job(含 §6.1 追加的 userToken)原样透传给 driver;driver 从 job.get("userToken") 取
|
||||
# per-user token 装 new-api 凭据、缺失回落全局 env key(见 cheap_service_driver._resolve_key)。
|
||||
# 此处不拆字段单独传递,避免冗余管道——透传整 job 即满足「worker 取 job token」。
|
||||
return asyncio.run(cheap_service_driver.drive_cheap_generation(job, base_url=base_url))
|
||||
|
||||
|
||||
@ -541,8 +552,10 @@ def make_handler(state: WorkerState):
|
||||
pass
|
||||
trace_id = job.get("traceId") or job.get("job_id")
|
||||
accepted, code = try_enqueue(state, job)
|
||||
# WU2 §3.7/§3.8:记 userToken 是否随 job 带来(脱敏)——便于排「member create 该带 token 却没带」。
|
||||
log(f"收到 job trace_id={trace_id}, templateId={job.get('templateId')}, "
|
||||
f"gameId={job.get('gameId')} → {'受理' if accepted else '队满拒'}({code})")
|
||||
f"gameId={job.get('gameId')}, userToken={_mask_token(job.get('userToken'))} "
|
||||
f"→ {'受理' if accepted else '队满拒'}({code})")
|
||||
self._send_json(code, {"accepted": accepted, "job_id": job.get("job_id"),
|
||||
"traceId": trace_id, "queued": state.queue.qsize()})
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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)';
|
||||
@ -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`;
|
||||
58
contracts/db-schemas/V32.0.0__create_newapi_quota_pool.sql
Normal file
58
contracts/db-schemas/V32.0.0__create_newapi_quota_pool.sql
Normal file
@ -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_<gamePlayerId>(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_<gamePlayerId>;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)';
|
||||
@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
270
docs/agent-specs/2026-07-07-内测-WU1-注册登录用户名密码-设计.md
Normal file
270
docs/agent-specs/2026-07-07-内测-WU1-注册登录用户名密码-设计.md
Normal file
@ -0,0 +1,270 @@
|
||||
---
|
||||
date: 2026-07-07
|
||||
topic: 内测-注册登录-用户名密码
|
||||
status: 草案
|
||||
sot-impact: 修订 topic 鉴权与权限(新增用户名+密码鉴权路径,与短信/邀请码并存);修订 topic 数据模型(game_player 增 username/password 列、mobile 放宽可空、加 uk_username)。收口时把两条结论折进 后端/鉴权与权限.md 与 后端/数据模型.md,本档降为留痕。
|
||||
上级: docs/mvp/MVP作战清单.md
|
||||
关联:
|
||||
- contracts/db-schemas/V11.0.0__create_passport_player_invite.sql @ cb9f135d(被扩展的 game_player 建表)
|
||||
- game-cloud/huijing-module-system/.../service/passport/PassportServiceImpl.java @ 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<br/>password-login<br/>(app-api/passport)"]
|
||||
REG --> IP{"同 IP 时/日<br/>计数闸"}
|
||||
IP -->|超阈值| DENY["429 请求过频"]
|
||||
IP -->|放行| SVC["PassportServiceImpl<br/>registerPlayer + BCrypt + buildLoginResp"]
|
||||
SVC --> DB[("game_player<br/>username/password 新列<br/>mobile 放宽 NULL")]
|
||||
SVC --> TOK["OAuth2 token<br/>userType=MEMBER"]
|
||||
SVC -.事务内写 outbox.-> HOOK["WU2 new-api 开户充值 hook<br/>(至少一次·幂等·本档只交生产者)"]
|
||||
```
|
||||
|
||||
**核心思想**:不新建鉴权体系,在 passport 现有骨架上加一条与短信/邀请码平权的登录路径,用一列 `username`、一列 `password` 和一处 `mobile` 放宽承载;防刷收敛到一处纯 IP 计数组件,避免每个端点各写一套。
|
||||
**边界**:只做用户名密码的注册与登录(内测种子),不做找回密码、改密、手机号绑定、图形验证码、真正的边缘网关限流。
|
||||
**怎么算成功**:真机上用户名密码注册返回 OAuth2 token、随后同凭据登录成功、同 IP 高频尝试被 429 拒、存量短信与邀请码两条路零回归。
|
||||
|
||||
## 1 意图与目标
|
||||
|
||||
内测阶段要把种子用户请进来试玩和创作,可当前 passport 只认手机号:登录靠短信验证码,注册靠"验证码即占有证明"的自动注册,或报备前的邀请码旁路。staging 未接真实短信网关,只能靠 `sms-mock-enabled` 发一个固定码 8888 兜底登录——这是给自己人测试用的后门,把它当成种子用户的注册入口既不体面也不安全(任何人凭 8888 登录任意手机号)。创始人据此拍定内测口径:种子用户"输入用户名和密码即可"注册登录,无需短信;网关层对同 IP 的注册/登录尝试做限流防刷。
|
||||
|
||||
目标有三条,都要能验:
|
||||
|
||||
- **可注册可登录**:陌生用户提交合法用户名与密码即建号并拿到真 OAuth2 token;用同一对凭据能再次登录。token 结构、userType、client 与短信路完全一致,下游鉴权、`/me`、创作守卫无需感知登录方式的差异。
|
||||
- **可防刷**:同一 IP 在时窗内的注册与登录尝试次数受限,越限返回 429,日志留痕可审计;这层保护同时补到目前裸奔的 `sms-login` 与 `invite-register`。
|
||||
- **零回归**:短信验证码登录、自动注册、邀请码注册、`sms-mock` 后门四条既有路径行为不变。
|
||||
|
||||
## 2 边界
|
||||
|
||||
**做**:`game_player` 增 `username` / `password` 两列并放宽 `mobile` 为可空、加 `uk_username`;新增 `password-register` 与 `password-login` 两个免登端点及其请求/响应契约;用户名密码校验、BCrypt 编码与校验、防枚举口径;应用层纯 IP 计数防刷(新端点 + 回填 `sms-login` / `invite-register`);`wanxiang.passport.*` 段新增开关与阈值;`game-studio` 登录页新增用户名密码 Tab、`passport.ts` 端点封装、文案;`/me` 返回体补 `username` 以便前端展示非手机号身份;交付"注册成功"事件的**生产者**(注册事务内写 outbox、投递器发 MQ,至少一次),作为 WU2 new-api 开户充值 hook 的接缝。
|
||||
|
||||
**不做**:找回密码 / 改密 / 手机号与用户名互绑(内测不需要,留后续账号中心);图形验证码或滑块(与短信路一样,列为公网上线前的加固项,非内测门槛);真正的边缘网关(huijing-gateway / nginx)限流作为主方案——现实是产品端走单体直连 48080,网关不在链路(详见 §3.3 的口径澄清与权衡);`real_name` / `id_card_no` 实名字段(V11 已预留,提现前才收集);把 WU2 的 new-api 开户逻辑实现进来(本档交"注册成功"事件的生产者与至少一次接缝契约,**消费端**开户充值随 WU2;即"不做"的是消费者,不是连事件发射也不做)。
|
||||
|
||||
## 3 方案
|
||||
|
||||
### 3.1 最大决策:单表双身份路径如何共存
|
||||
|
||||
`game_player` 当前把手机号钉成了身份主键——`mobile VARCHAR(11) NOT NULL`,唯一键 `uk_mobile(mobile, deleted, tenant_id)`,没有任何用户名或密码的概念。要让"没有手机号"的用户也能落到同一张玩家表(这样 `id` 仍是 OAuth2 token 的 userId,下游一视同仁),必须动三处:加 `username`、加 `password`、把 `mobile` 从 NOT NULL 放宽为 NULL。
|
||||
|
||||
这里的关键是唯一键在 NULL 上的行为。MySQL 的 UNIQUE 索引允许索引列取 NULL 时存在多行——当 `mobile` 为 NULL,`uk_mobile` 不再对这些行施加唯一约束,于是任意多个"无手机号"的用户名用户可以共存,而手机号非空的用户之间仍然一号一人。反过来,新加的 `uk_username(username, deleted, tenant_id)` 对手机号用户(`username` 为 NULL)不设限,对用户名用户严格唯一。两条路径因此在同一张表里互不干扰:
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph 短信/邀请码用户
|
||||
A["mobile=13800000000<br/>username=NULL<br/>password=NULL"]
|
||||
end
|
||||
subgraph 用户名密码用户
|
||||
B["mobile=NULL<br/>username=seed_alice<br/>password=BCrypt(...)"]
|
||||
end
|
||||
A -.受 uk_mobile 约束.-> UKM["uk_mobile 唯一<br/>(NULL 行不参与)"]
|
||||
B -.受 uk_username 约束.-> UKU["uk_username 唯一<br/>(NULL 行不参与)"]
|
||||
```
|
||||
|
||||
**契约变更(DB 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["① 契约<br/>Flyway V31 + VO + 错误码 + PlayerDO/Mapper"] --> S2["② 服务层<br/>registerPlayer 扩参 + password 两方法 + BCrypt + IP 计数泛化"]
|
||||
S2 --> S3["③ 端点+配置<br/>两 @PermitAll 端点 + wanxiang.passport 开关阈值 + 回填 sms/invite IP 闸"]
|
||||
S3 --> S4["④ 前端<br/>Login.vue Tab + passport.ts + locales"]
|
||||
S4 --> S5["⑤ 真机验收<br/>注册→登录→限流→零回归 + hook 挂点冒烟"]
|
||||
```
|
||||
|
||||
| 阶段 | 交付物 | 验证 | 依赖 | 风险 |
|
||||
|---|---|---|---|---|
|
||||
| ① 契约 | V31 迁移(两处:契约源+执行副本)、`PasswordRegister/LoginReqVO`、`LoginRespVO` 复用、`PlayerMeRespVO` 加 username、三个错误码、`PlayerDO`+`PlayerMapper.selectByUsername` | Flyway 迁移在 dev 库应用无版本乱序/无同号;建表 DDL 校验通过 | 落地前 `ls .../db/migration \| sort -V \| tail` 硬复核执行副本最大号再定号 | mobile 放宽的存量兼容(放宽为松约束,低风险)|
|
||||
| ② 服务层 | `registerPlayer` 扩参、`passwordRegister/passwordLogin`(含查无用户名跑固定假 hash 对齐耗时)、BCrypt 编码校验、`PassportSmsIpCounterRedisDAO` 泛化 scene + Redis 异常 fail-open 告警、**注册事务内写 outbox(注册成功事件生产者)** | 服务层单测:注册占用、登录防枚举(错误码+耗时不可区分)、禁用拒登、并发撞名兜底、IP 越限抛 429、Redis 异常放行且告警、outbox 记录随建号原子落库 | ① | 泛化计数组件 key 改名致存量桶一次性重置(TTL 有界、内测无害,如实记账)|
|
||||
| ③ 端点+配置 | 两 `@PermitAll` 端点(`@ApiAccessLog(sanitizeKeys={"password"})` **且代码级绕过 `ApiAccessLogInterceptor` 的 stdout body 打印**)、Service 入层取 `request.getRemoteAddr()` 传入计数、`wanxiang.passport.*` 新键、回填 `sms-login`/`invite-register` 的服务层 IP 闸 | 端点级:Knife4j/curl 打通;回填不改短信/邀请码语义;冒烟 grep stdout 无明文密码 | ② | IP 取连接层 remote-addr(不采信 XFF);上线接反代后须切最右受信段 |
|
||||
| ④ 前端 | `Login.vue` 用户名密码 Tab、`passport.ts` 两函数+`PlayerMeResp.username`、locales | 构建通过;本地联调三 Tab 均可登录 | ③ | 与既有两 Tab 状态不串 |
|
||||
| ⑤ 真机验收 | dev 单点串行 e2e | §5 判据全绿 | ①-④ | — |
|
||||
|
||||
## 5 验证方式
|
||||
|
||||
全部可真机取证或机器验,判据与 §1 目标一一对应:
|
||||
|
||||
- **注册返 token**:对未占用用户名 POST `password-register`,返回体含非空 `accessToken`/`refreshToken`、`userId`>0;库内 `game_player` 出现 `username=该名, mobile=NULL, register_channel=password, password` 为 BCrypt 密文(`$2a$` 前缀、非明文)。
|
||||
- **登录成功且防枚举**:用同一对凭据 POST `password-login` 返回 token;密码错一位 → `1-002-090-005`;换一个不存在的用户名同样是 `1-002-090-005`(两者文案一致,不可区分);抽样比对"查无用户名"与"密码错误"两分支的响应时延处于同一量级(固定假 hash 已对齐 BCrypt 耗时),不构成存在性 oracle。
|
||||
- **占用可见、登录不可见**:重复注册同用户名 → `1-002-090-004 用户名已被占用`(这是有意暴露,配合限流)。
|
||||
- **IP 限流生效**:同 IP 连续注册/登录超过配置阈值 → `429 请求过于频繁`;日志出现限流 WARN 且含 IP 与计数;Redis 见 `passport_ip:register:*` / `:login:*` 计数桶。
|
||||
- **伪造 XFF 不能绕过**:携带每次变化的伪造 `X-Forwarded-For` 高频打注册/登录,仍按连接来源被 429(证明限流锚在 `getRemoteAddr()`、不采信客户端可写的 XFF)。
|
||||
- **回填生效**:同 IP 高频打 `sms-login` / `invite-register` 同样被 429(此前无此保护)。
|
||||
- **存量零回归**:短信验证码登录(含 `sms-mock` 8888)、自动注册、邀请码注册四条路真机各跑一遍,行为与改动前一致;OAuth2 token、`/me`、创作守卫不受影响。
|
||||
- **token 后续可用 + /me 空安全**:password 路拿到的 token 调 `/me` **不 NPE**、返回该用户身份(`username` 有值、`mobile` 为 null 时回空串不报错),并能通行一次需登录态的下游调用——证明与短信路 token 等价、且脱敏对 null 已封空安全。
|
||||
- **开户事件生产者冒烟**:注册成功后 outbox 表出现对应记录且被投递器发出 MQ(日志/MQ 可观测),验证生产者已交付;WU2 实现前此处只验事件被发出,不验开户结果。
|
||||
- **安全基线(机制保证,非配置压制)**:`system_api_access_log` 中 password 键被脱敏、不见明文;且冒烟里对 `password-register`/`password-login` 跑一遍后 grep 服务 stdout/控制台断言搜不到明文密码(覆盖 `ApiAccessLogInterceptor` 那条链路);服务日志只见 `userId`,无密码、无 token。
|
||||
|
||||
## 6 风险与回滚
|
||||
|
||||
- **客户端 IP 取法**:限流按 §3.3 采信连接层 `request.getRemoteAddr()`、不读客户端可伪造的 XFF,堵死「伪造 XFF 换桶绕过」这一最危险方向;§5 专门有一条「伪造 XFF 高频打注册/登录仍被 429」取证。反方向的残余前提是——将来公网上线在 48080 前置可信反代后,`getRemoteAddr()` 会塌成反代地址、限流退化为全局闸;届时必须切到「只采信反代 append 的最右侧受信段 + 可信代理白名单」,这是上线闸门的一项,不在内测范围内提前做。
|
||||
- **单账号多 IP 暴破**:本单防刷是纯 IP 单维度,没有按 `username` 的失败登录节流或锁定。配合弱口令下限(6 位、无复杂度、无图形验证码)与 login 默认 100 次/日/IP,攻击者用多 IP 轮转打同一个目标账号时,每个账号没有独立上限。内测阶段先记账接受该残余风险,触发处置是「一旦观测到针对单账号的高失败率,立即用 §3.3 的 scene 计数器加一个按 `username` 的失败桶做短时锁定」——组件现成(scene ∈ 增加 `login_fail`),越限临时锁定该账号;账号级失败锁定与图形验证码同列为公网上线闸门。
|
||||
- **密码策略偏弱**:内测取 `@Length(6,32)`、无复杂度要求、无图形验证码,靠 IP 限流兜防刷。这是"内测能用就行"的刻意取舍;公网上线前把图形验证码/滑块与密码复杂度一并补上(与 `sms-mock` 关闭、短信网关接入同属上线闸门)。
|
||||
- **用户名枚举**:注册占用错误不可避免地暴露用户名存在性,限流是唯一缓解。若内测发现被刷,先下调 `register-ip-limit`,再考虑提前上图形验证码。
|
||||
- **总开关误关**:`password-auth-enabled=false` 会让两端点直接拒服务;它是后端信任边界的一部分,前端仅据此隐藏 Tab,不能反向依赖前端。
|
||||
- **回滚**:功能级回滚 = `password-auth-enabled=false`(端点即时停用,无需改码)。schema 级回滚 = 补偿迁移 `V31.0.1`,`mobile` 收回 NOT NULL 前须先确认/清理无手机号用户,否则收回失败。前端回滚 = 隐藏用户名密码 Tab。三层可独立回退,互不阻塞。
|
||||
|
||||
## 附 图清单与状态
|
||||
|
||||
- 图0 一图看懂(§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 落代码物。
|
||||
370
docs/agent-specs/2026-07-07-内测-WU2-newapi额度接入-设计.md
Normal file
370
docs/agent-specs/2026-07-07-内测-WU2-newapi额度接入-设计.md
Normal file
@ -0,0 +1,370 @@
|
||||
---
|
||||
date: 2026-07-07
|
||||
topic: newapi-per-user-quota
|
||||
status: 草案
|
||||
sot-impact: 修订「生成引擎运行时」(§6.1 job 契约新增 userToken 位、§6.5 新增 quota_exhausted 失败因);修订「数据模型」(新增额度池表 newapi_quota_pool——离线预建 FREE 条目 + 注册 claim 绑定);关联「鉴权与权限」(不改注册路,仅消费 WU1 afterCommit 发出的「注册成功」事件从池 claim 一个条目绑定,运行时不调 new-api);修订「契约#6 dify-workflow-io.json」与「aigc.yaml FailureReason」(跨契约共享枚举同批新增 quota_exhausted,须与 WU3 协调)。刻意不改「变现端到端」——本设计对接 new-api 网关额度,不在 game-cloud 自建 credits 钱包。
|
||||
上级: docs/mvp/MVP作战清单.md
|
||||
依赖: docs/agent-specs/2026-07-07-内测-WU1-注册登录用户名密码-设计.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 扣该用户的额度。额度花完,网关直接拒,用户看到一句「生成额度已用完」。
|
||||
|
||||
<svg viewBox="0 0 880 486" xmlns="http://www.w3.org/2000/svg" font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif" font-size="13">
|
||||
<rect x="0" y="0" width="880" height="486" fill="#f8fafc"/>
|
||||
<text x="24" y="30" font-size="17" font-weight="700" fill="#0f172a">内测额度口径:账本在 new-api——离线预置池,运行时只 claim / 扣额</text>
|
||||
|
||||
<!-- 离线预置带 -->
|
||||
<rect x="16" y="44" width="848" height="98" rx="10" fill="#f1f5f9" stroke="#94a3b8" stroke-width="1.2" stroke-dasharray="5 4"/>
|
||||
<text x="28" y="62" fill="#475569" font-size="11" font-weight="600">离线预置(ops 脚本 · 一次性建 N 条 · 补池=重跑)</text>
|
||||
|
||||
<rect x="32" y="74" width="184" height="56" rx="9" fill="#ffffff" stroke="#0f172a" stroke-width="1.4"/>
|
||||
<text x="124" y="97" text-anchor="middle" fill="#0f172a">ops 预置脚本</text>
|
||||
<text x="124" y="115" text-anchor="middle" fill="#64748b" font-size="10.5">建户→DB写access_token→建token→设quota</text>
|
||||
|
||||
<rect x="342" y="74" width="196" height="56" rx="9" fill="#eef2ff" stroke="#4338ca" stroke-width="1.5"/>
|
||||
<text x="440" y="97" text-anchor="middle" fill="#312e81" font-weight="600">new-api 预建账户</text>
|
||||
<text x="440" y="115" text-anchor="middle" fill="#64748b" font-size="10.5">N×(user + token + ¥100)</text>
|
||||
|
||||
<rect x="636" y="74" width="210" height="56" rx="9" fill="#ffffff" stroke="#0f172a" stroke-width="1.4"/>
|
||||
<text x="741" y="97" text-anchor="middle" fill="#0f172a">game-cloud 额度池表 (D)</text>
|
||||
<text x="741" y="115" text-anchor="middle" fill="#64748b" font-size="10.5">N 条 status=FREE</text>
|
||||
|
||||
<line x1="216" y1="102" x2="340" y2="102" stroke="#0f172a" stroke-width="1.4" marker-end="url(#a)"/>
|
||||
<text x="278" y="95" text-anchor="middle" fill="#64748b" font-size="10">凭据A+postgres 脏活</text>
|
||||
<line x1="538" y1="102" x2="634" y2="102" stroke="#0f172a" stroke-width="1.4" marker-end="url(#a)"/>
|
||||
<text x="586" y="95" text-anchor="middle" fill="#64748b" font-size="10">脚本回写 FREE 条目</text>
|
||||
|
||||
<!-- 注册 claim 链 -->
|
||||
<rect x="24" y="168" width="148" height="50" rx="9" fill="#ffffff" stroke="#0f172a" stroke-width="1.4"/>
|
||||
<text x="98" y="190" text-anchor="middle" fill="#0f172a">用户注册</text>
|
||||
<text x="98" y="207" text-anchor="middle" fill="#64748b" font-size="11">registerPlayer</text>
|
||||
|
||||
<rect x="198" y="168" width="178" height="50" rx="9" fill="#ffffff" stroke="#0f172a" stroke-width="1.4"/>
|
||||
<text x="287" y="188" text-anchor="middle" fill="#0f172a">建 game_player(WU1)</text>
|
||||
<text x="287" y="205" text-anchor="middle" fill="#64748b" font-size="11">afterCommit 发注册成功事件</text>
|
||||
|
||||
<rect x="402" y="168" width="204" height="50" rx="9" fill="#ffffff" stroke="#7c3aed" stroke-width="1.6"/>
|
||||
<text x="504" y="188" text-anchor="middle" fill="#7c3aed" font-weight="600">WU2 消费事件 → 从池 claim</text>
|
||||
<text x="504" y="205" text-anchor="middle" fill="#64748b" font-size="11">CAS 绑定·幂等·渠道过滤</text>
|
||||
|
||||
<line x1="172" y1="193" x2="196" y2="193" stroke="#0f172a" stroke-width="1.4" marker-end="url(#a)"/>
|
||||
<line x1="376" y1="193" x2="400" y2="193" stroke="#0f172a" stroke-width="1.4" marker-end="url(#a)"/>
|
||||
<line x1="560" y1="168" x2="700" y2="132" stroke="#7c3aed" stroke-width="1.6" marker-end="url(#p)"/>
|
||||
<text x="656" y="158" text-anchor="middle" fill="#7c3aed" font-size="10.5">claim FREE→CLAIMED</text>
|
||||
|
||||
<!-- 生成链 -->
|
||||
<rect x="24" y="252" width="182" height="58" rx="9" fill="#ffffff" stroke="#0f172a" stroke-width="1.4"/>
|
||||
<text x="115" y="275" text-anchor="middle" fill="#0f172a">生成任务派发</text>
|
||||
<text x="115" y="293" text-anchor="middle" fill="#64748b" font-size="10.5">job 加 userToken 位(查池表 CLAIMED)</text>
|
||||
|
||||
<rect x="250" y="252" width="164" height="58" rx="9" fill="#ffffff" stroke="#0f172a" stroke-width="1.4"/>
|
||||
<text x="332" y="275" text-anchor="middle" fill="#0f172a">worker</text>
|
||||
<text x="332" y="293" text-anchor="middle" fill="#64748b" font-size="10.5">用 job.userToken 调网关</text>
|
||||
|
||||
<rect x="636" y="246" width="210" height="100" rx="10" fill="#eef2ff" stroke="#4338ca" stroke-width="1.6"/>
|
||||
<text x="741" y="270" text-anchor="middle" fill="#312e81" font-weight="700">new-api 网关(账本)</text>
|
||||
<text x="649" y="293" fill="#312e81" font-size="12">tokens.remain_quota 减</text>
|
||||
<text x="649" y="314" fill="#312e81" font-size="12">users.used_quota 增 = 权威消耗</text>
|
||||
<text x="649" y="335" fill="#64748b" font-size="11">100.64.0.8:3000</text>
|
||||
|
||||
<line x1="206" y1="281" x2="248" y2="281" stroke="#0f172a" stroke-width="1.4" marker-end="url(#a)"/>
|
||||
<line x1="414" y1="278" x2="634" y2="290" stroke="#0f172a" stroke-width="1.4" marker-end="url(#a)"/>
|
||||
<text x="524" y="272" text-anchor="middle" fill="#64748b" font-size="10.5">该 token → 扣该用户额度</text>
|
||||
|
||||
<!-- 拒绝 -->
|
||||
<rect x="250" y="372" width="356" height="44" rx="9" fill="#ffffff" stroke="#dc2626" stroke-width="1.5"/>
|
||||
<text x="428" y="399" text-anchor="middle" fill="#dc2626">额度耗尽 → 网关 402/403 → 干净失败「生成额度已用完」</text>
|
||||
<line x1="700" y1="346" x2="606" y2="392" stroke="#dc2626" stroke-width="1.4" stroke-dasharray="5 4" marker-end="url(#r)"/>
|
||||
|
||||
<text x="24" y="446" fill="#334155" font-size="12">核心思想:new-api per-user quota 当账本;provision 离线预置池,运行时只 claim 一个条目 + 生成扣额,零运行时 admin 耦合。</text>
|
||||
<text x="24" y="466" fill="#334155" font-size="12">边界:不建钱包 / 不自助充值续额 / 运行时不调 admin API。新失败面=池空(懒 claim + 水位告警 + 补池)。成功=真机五验。</text>
|
||||
|
||||
<defs>
|
||||
<marker id="a" markerWidth="9" markerHeight="9" refX="7" refY="4.5" orient="auto"><path d="M0,0 L9,4.5 L0,9 Z" fill="#0f172a"/></marker>
|
||||
<marker id="p" markerWidth="9" markerHeight="9" refX="7" refY="4.5" orient="auto"><path d="M0,0 L9,4.5 L0,9 Z" fill="#7c3aed"/></marker>
|
||||
<marker id="r" markerWidth="9" markerHeight="9" refX="7" refY="4.5" orient="auto"><path d="M0,0 L9,4.5 L0,9 Z" fill="#dc2626"/></marker>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
三句话:**核心思想**——把 new-api 的 per-user quota 直接当额度账本(权威余额=token `remain_quota`、权威消耗=user `used_quota`),额度由离线 ops 脚本预建成池、注册时消费 WU1「注册成功」事件从池 CAS claim 一个条目绑玩家、生成时随 job 带该 token 扣额、耗尽网关直接拒。**边界**——只接网关额度,不在 game-cloud 建 credits 钱包、不做用户自助充值也不做运营续额闭环、**运行时不调 new-api admin API**(provision 全离线)、权威 `logs.quota` 对账延后;per-user 门只对真实 member 在线创作生效,编排器/bake-off/系统触发旁路走全局 key。**怎么算成功**——真机五验:ops 脚本预建 N 条池(new-api `users`/`tokens` 新增、每 token `remain_quota=¥100` 折算值);注册后从池 claim 一条 FREE→CLAIMED 绑该玩家;一次真 gen 后该 user `used_quota` 增;余额耗尽后 gen 被网关干净拒且回传可读拒因;池空时注册仍成功、水位告警、首次生成懒 claim 兜。
|
||||
|
||||
## 1 意图与目标
|
||||
|
||||
内测要给种子创作者发生成额度,防止有人无限刷生成把网关额度烧穿,同时给「花了多少、还剩多少」一个权威口径。创始人已拍定这条口径不是在后端自建一套 credits 钱包,而是复用 new-api 网关本就有的 per-user 额度体系:每个内测用户 ¥100,消耗由网关权威计量。至于这批 per-user 账户怎么建,S0 实测坐实了一条硬约束——new-api 的 admin 令牌能建用户、设 quota,却**不能替他人建 token**(建 token 严格自认证、`access_token` 也不可经 API 设),任何 per-user token 都得直连它的 postgres 才能造出来。据此创始人拍定:provision 不进 game-cloud 运行时,而是离线由一个 ops 脚本一次性预建一批 (user+token+¥100) 成池,运行时注册只从池里 claim 一个绑给用户——零运行时 admin 调用、零 game-cloud↔new-api-postgres 耦合。
|
||||
|
||||
选这条路的理由是它把「计量」这件最容易算错、最需要和真实扣费对齐的事,交给了唯一真正扣费的那一层。网关调用一次 LLM、按 token 数和模型倍率扣一次 quota,`used_quota` 就是逐笔累加的真账;后端若自建钱包,就得把网关的扣费公式(含缓存折扣、模型倍率、分组倍率)在 Java 侧再实现一遍并保持同步,这正是 `wg1/gen-worker/worker/cost.py` 反复踩过的对账坑——它自己都在注释里写明「精确 `logs.quota` 仍需网关 access token 读 `/api/log/self`,sk- key 无管理权」。把账本收敛到网关,这条线只需做「离线预置 token、运行时把对的 token 递到对的调用上」,不碰计量。
|
||||
|
||||
目标锁三条,全部可真机取证:
|
||||
|
||||
- 每个注册用户从预置池 claim 到一个专属 user + 专属 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 管理凭据<br/>ops 脚本用·不进运行时"]
|
||||
B["B 离线 ops 预置脚本<br/>预建 N×(user+token+¥100)"]
|
||||
C["C 注册 claim 消费者<br/>从池 CAS 绑定·渠道过滤"]
|
||||
D["D 额度池表<br/>FREE/CLAIMED 条目"]
|
||||
E["E job 加 userToken 位<br/>§6.1 契约追加字段"]
|
||||
F["F worker 取 job token<br/>缺失回落全局 key"]
|
||||
end
|
||||
subgraph OUT["不做(越界,明确挡住)"]
|
||||
X["运行时调 new-api admin API / 运行时开户"]
|
||||
W["credits 钱包 / 扣费公式二次实现"]
|
||||
R["用户自助充值 / 支付 / 提现 / 续额"]
|
||||
L["权威 logs.quota 对账(延后)"]
|
||||
S["改 spike/bake-off 全局 key 路"]
|
||||
end
|
||||
IN -.->|边界| OUT
|
||||
```
|
||||
|
||||
## 3 方案
|
||||
|
||||
### 3.1 六个接线点如何串成一条数据流
|
||||
|
||||
额度供给分两段:**离线预置**(ops 脚本预建池,接线点 A·B·D)与**运行时**(注册 claim、生成扣额、耗尽拒,接线点 C·E·F)。下图是事实源,逐点的字段契约在其后展开。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph OFFLINE["离线预置(ops 脚本 · A·B·D)"]
|
||||
OPS["ops 预置脚本<br/>建户 API→DB 写 access_token<br/>→以该用户建 token API→设 quota API"]
|
||||
OPS -->|凭据 A + postgres 脏活| NAP["new-api:预建 N×(user+token+¥100)"]
|
||||
NAP -->|脚本回写条目 FREE| POOL["额度池表 newapi_quota_pool<br/>N 条 FREE (D)"]
|
||||
end
|
||||
|
||||
U[用户] -->|注册| REG["registerPlayer(WU1)<br/>注册事务提交"]
|
||||
REG -->|同事务| PL["game_player 落库"]
|
||||
REG -.->|afterCommit 发事件| EVT["注册成功事件/RocketMQ<br/>userId·registerChannel·anonId"]
|
||||
EVT -->|WU2 消费(C)· 渠道过滤| CLAIM["从池 CAS claim 一个 FREE<br/>绑 game_player·写 biz_no"]
|
||||
CLAIM -->|抢占 FREE→CLAIMED| POOL
|
||||
CLAIM -.->|池空| ALERT["不抛·水位告警<br/>懒 claim 兜(§3.6)"]
|
||||
|
||||
U -->|创作生成| GEN["dispatchGeneric<br/>组 §6.1 job (E)"]
|
||||
GEN -->|creatorUserId 是否为 member?| MEMB{有 game_player 行}
|
||||
MEMB -->|否 系统/编排/bake-off| GLOB["旁路:回落全局 key<br/>不懒 claim"]
|
||||
MEMB -->|是 真实 member| LOOK["查池表该玩家 CLAIMED 条目"]
|
||||
LOOK -->|命中| TOK["job.userToken=token_key"]
|
||||
LOOK -->|无 CLAIMED 条目| LZ["懒 claim 单 CAS 抢一次<br/>池空→干净失败+水位告警"]
|
||||
LZ -->|抢到| TOK
|
||||
TOK -->|job.userToken| WK["worker (F)"]
|
||||
WK -->|用 job.userToken 调网关| NA["new-api 网关"]
|
||||
NA -->|扣该 user 额度| LEDGER["token remain_quota 减<br/>user used_quota 增"]
|
||||
NA -.->|余额耗尽 402/403| FAIL["quota_exhausted 干净回传"]
|
||||
```
|
||||
|
||||
### 3.2 A · 管理凭据:S0 已实测坐实
|
||||
|
||||
现有那把共享 `NEWAPI_KEY`(`sk-84GA...`,见 `docs/内网凭据与端点.md` new-api 段)是一把普通调用 token,没有管理权——它能调 `/v1/chat/completions`,但建不了 user、改不了 quota。ops 预置脚本要建 user、设 quota,必须用 new-api 的管理凭据。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 <access_token>` **且** `New-Api-User: 1`(缺 `New-Api-User` 头即 401)。这是 New API 分支(build `fd3e95ac2902-20260527`,非 one-api)。
|
||||
- **端点(尾斜杠必须**,gin `RedirectTrailingSlash`,否则 307):`GET /api/user/?p=0&page_size=N` 列用户、`POST /api/user/` `{username,password,display_name}` 建用户、`PUT /api/user/` `{id,username,display_name,quota,group}` 设 quota、`GET /api/token/?p=0` 列 token(`tokens` 表有 `name` 列,可按 user+name 建/查 token)。
|
||||
- **折算**:`quota_per_unit=500000`、`usd_exchange_rate` 用默认 `7.3`(DB options 未覆盖),¥100 grant_quota = `round(100/7.3×500000) ≈ 6,849,315`(回读落值确认)。
|
||||
|
||||
这套管理凭据只给**离线 ops 预置脚本**用,**不进 game-cloud 运行时**(运行时零 admin API 调用);令牌**存进 `docs/内网凭据与端点.md`**(「new-api 管理令牌」节),不进 env-only(遵守协议 §9 内网密钥进项目文档)。
|
||||
|
||||
**关键约束(S0 坐实,正是它决定了 provision 走离线预置池)**:admin 令牌**不能替他人建 token**——`POST /api/token/` 严格自认证(`New-Api-User` 必须等于令牌属主,否则 401「与登录用户不匹配」;`body.user_id` 被忽略、token 落到 root 名下),且 `access_token` 不可经 `PUT /api/user/` 设(回读 empty)。因此任何 per-user token 都得直连 new-api 的 postgres(`infra-postgres:5432/new-api`)才能造出来,纯 HTTP API 做不到。这把「碰 postgres 的脏活」收进离线 ops 脚本,运行时便彻底不必接触 new-api 的内部 schema。
|
||||
|
||||
### 3.3 B · 离线 ops 预置脚本:一次性预建额度池
|
||||
|
||||
provision 不进 game-cloud 运行时,而是一个**离线脚本**,宿主 mini-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=<uid>`——这一步是 S0 坐实的关键,`access_token` 无法经 API 设、又是下一步「以该用户身份建 token」的前提。
|
||||
3. `POST /api/token/`(API,**用该用户的 access_token + `New-Api-User:<uid>`**,即以该用户身份)建 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_<gamePlayerId>' WHERE id=(SELECT id FROM (SELECT id FROM newapi_quota_pool WHERE status='FREE' ORDER BY id LIMIT 1) t) AND status='FREE'`(行级 CAS,只有把某条 `FREE→CLAIMED` 翻转成功的那一路生效)。并发多消费者 / 重投由 `status='FREE'` 条件 + `uk(claimed_by_game_player_id)` + `uk(biz_no)` 三重收敛成一条,**任何路径都不会给同一玩家占第二个条目、也不会两玩家抢到同一条**。
|
||||
- 抢到(affected=1)→ 该玩家已绑好 `token_key`,结束。
|
||||
|
||||
**注册不阻断**(WU1 已保证):事件发出即返回,claim 与注册解耦,池抖动不拖垮注册。
|
||||
|
||||
**新失败模式——池空(无 `FREE` 条目)**:这是 provision 从运行时移到离线后新引入的失败面,必须显式兜。处置:
|
||||
|
||||
- **claim 消费者遇池空不抛异常**:池空是非瞬时故障(补池是人工 / 离线动作,不在 MQ 重试窗口内可恢复),抛异常只会把消息灌进 DLQ 空转。改为记 WARN + 触发**池水位告警**(提示 ops 补池),该玩家暂不绑定,注册照常成功。
|
||||
- **持久兜底 = 首次生成懒 claim**(§3.6 E):该玩家真正要生成时,派发线做一次单 CAS 懒 claim——池已补(ops 重跑脚本)则绑上继续;仍空则当次干净失败给可读拒因(「额度账户准备中,请稍后重试」),玩家补池后重试即得。懒 claim 是一条 DB CAS(不是慢外呼),放在派发热路是安全的(与原方案的三步慢开户根本不同,见 §3.6)。
|
||||
- **可选的定时补绑腿**(best-effort,默认不做):补池后由 `@Scheduled` 扫「`password/invite` 渠道、注册已成功、但无 `CLAIMED` 池条目」的玩家批量补 claim,让额度在首次生成前就绪。它需按 `game_player` 反查未绑玩家(join 查询);因懒 claim 已覆盖真正需要额度的那一刻(生成时),此腿仅作体验前置增强、延后。
|
||||
|
||||
**池水位告警**:`FREE` 条目数低于阈值(如 <20 或 <种子用户日增量)即告警,提示 ops 重跑脚本补池。这是池模型的核心运维信号,进观测。
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> FREE: ops 脚本离线预建<br/>(user+token+¥100) 写池表
|
||||
FREE --> CLAIMED: 注册/懒 claim CAS 抢占<br/>绑 game_player·写 biz_no
|
||||
CLAIMED --> INVALID: 运行时发现 token 失效(401)<br/>隔离·告警 ops(§3.9)
|
||||
INVALID --> [*]: ops 排查/补池替换
|
||||
CLAIMED --> [*]: 玩家生命周期内长期持有
|
||||
FREE --> FREE: 池空→水位告警·懒 claim 兜(§3.6)
|
||||
```
|
||||
|
||||
### 3.5 D · 额度池表:新表 newapi_quota_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_<gamePlayerId>`;`FREE` 时为 null |
|
||||
| `remark` | varchar(255) | 隔离原因等 |
|
||||
|
||||
`newapi_token_key` 是密钥落库,内测阶段明文存但须视为敏感(3.8 讲传输与日志脱敏,生产化时应加密列——本阶段不做,记为红线待办)。前后兼容:纯新增表,不改任何既有表结构,无迁移风险;回滚 = drop 表 + 摘事件消费者。
|
||||
|
||||
**额度落点钉死(避免摇摆)**:¥100 grant 由 ops 脚本预建时写 new-api 两处,但权威口径唯一——**token 的 `remain_quota` 是权威余额闸**(预建 token 时 `unlimited_quota=false`、挂 `grant_quota`),`users.quota` 设同额只为账户自洽、不作限额裁决;**`users.used_quota` 是权威消耗**(网关逐笔累加)。触发耗尽 402 的是 token `remain_quota→0`(网关按这把 token 扣该 user)。据此三处验收对同一口径:预建后查 token `remain_quota=grant_quota`(§5.1)、一次 gen 后查 user `used_quota` 增量(§5.3)、把 token `remain_quota` 设近 0 触发耗尽(§5.4)。池表只存 `grant_quota` 审计快照,不在 game-cloud 侧记余额(余额始终以网关为准)。
|
||||
|
||||
### 3.6 E · job 加 userToken 位与 per-user 门的适用边界(§6.1 契约变更)
|
||||
|
||||
`AigcGenerateExecutor.dispatchGeneric`(658-681)组 §6.1 job 时追加一个 `userToken` 字段。但**per-user 额度门只对「真实 member 在线创作」生效**,这是一条必须钉死的边界,否则会误伤既有验证线。
|
||||
|
||||
**为什么必须分身份**:编排器批跑与 bake-off 是「伪装在线提交」——经 `submitGenerate → dispatchGeneric`(`AigcTaskServiceImpl` 202-204 注释确认),同样流经此处;`task_source`(V17)当前无 ReqVO 信号、全落 `online`,据 source 辨别不了。若在此一律按 member 查池、无 `CLAIMED` 条目即懒 claim 或干净失败,n=5 收敛环 / bake-off / 脚本路会被这道闸挡死;更糟的是 admin/system 触发身份属另一套 id 空间、**没有 `game_player` 行**,懒 claim 会替一个 admin/system id 占掉一个 ¥100 池条目(白白耗池,这些 id 也不是真实玩家)。
|
||||
|
||||
**据此的判据(今天就成立)**——门只认 `creatorUserId` 是否命中一行 `game_player`:
|
||||
|
||||
- **无 `game_player` 行**(系统 / 编排器 / bake-off / admin 触发)→ **旁路**:不查池、不懒 claim,`job.userToken` 留空,worker 回落全局 key(§3.7)。这条兜住越界回归。
|
||||
- **命中 `game_player` 行**(真实 member 在线创作)→ 走 per-user 门:来源链 `creatorUserId`(`AigcTaskDO` 38 行既有字段)→ 查 `newapi_quota_pool` 取该玩家 `CLAIMED` 条目的 `newapi_token_key` → 放进 `job.userToken`。将来编排器据契约显式声明 `source=orchestrator` 后,可把 source 叠加进旁路判据、更精确。
|
||||
|
||||
**该玩家无 `CLAIMED` 条目(存量 null / 注册时池空未绑)时——派发线做一次单 CAS 懒 claim**。这里有个相对原「运行时开户」方案的关键收益:claim 是纯 game-cloud 内部一条 `FREE→CLAIMED` 的 DB CAS,**不是三步慢外呼**——原方案那条「懒开户在有界消费池(`consumeThreadMax=15`)里串 `createUser→createToken→setUserQuota` 三次分钟级外呼、首次生成高峰叠加 new-api 抖动拖垮共享池」的稳定性红线,随 provision 离线化一并消失,派发热路里放一条 DB CAS 是安全的。处置:命中 `CLAIMED` 条目取 `newapi_token_key` 放进 `job.userToken`;无 `CLAIMED` 条目则懒 claim 抢一个 `FREE`——抢到就用;**池空抢不到**就**当次干净失败**给可读拒因(`quota_exhausted`,文案「额度账户准备中,请稍后重试」),同时触发**池水位告警**提示 ops 补池,玩家待补池后重试即得(§3.4 的池空处置)。无论如何**绝不派一个没 token 的 create 路 job 下去**。
|
||||
|
||||
**契约兼容声明**(须随契约通知相关方):`userToken` 是**追加字段**,老 worker 收到多余字段不炸——三个 worker 入口解析 job 均走 `job.get(...)` 逐键取值(`worker_service.py:142-146` 即此模式),未知键被忽略,不做严格 schema 校验。所以这是一次向后兼容的加法:新后端 + 老 worker = worker 忽略 `userToken` 走全局 key(行为同今天);新后端 + 新 worker = 走 per-user token。无需 worker 与后端同步上线。
|
||||
|
||||
### 3.7 F · worker 取 job token 与回落策略
|
||||
|
||||
三个入口从「读全局 env `NEWAPI_KEY`」改成「优先读 `job.userToken`」:`cheap-worker/cheap_service_driver.py:28-44`(`_resolve_key` 现从 `client.get_api_key()` 读 env)、`cheap-worker/worker_service.py` 的 job 消费边界、`wg1/gen-worker/worker/_client.py:47`(`get_api_key` 现读 env `NEWAPI_KEY`)。
|
||||
|
||||
**回落策略(须给判断)**:`job.userToken` 存在则用它;**缺失则回落全局 env key**。判断依据在 dispatch 层已划清(§3.6)——真实 member 的 create 路由 E 保证必带 token(拿不到就在后端 fail-fast 干净失败,worker 见不到无 token 的 member create job);而系统 / 编排器 / bake-off / spike / 脚本路本就没有 `game_player` 成员身份,E 已按「无成员身份」旁路、不给它们塞 token,它们缺 `userToken` 是**正常且预期**的,回落全局 key 才能让这些既有验证路继续跑。若在 worker 这层对缺失一律硬拒,会误伤这些路。所以额度口子不在 worker 层堵,而在后端 dispatch 层按身份堵死(member create 路无 token 即失败、系统路旁路)——worker 只做「有则用、无则回落」,并在回落时打一条 WARN(便于发现本该带 token 的 member create job 异常走了共享计量)。这样既不破坏既有验证路,又不给 member create 路留静默烧共享额度的洞。
|
||||
|
||||
### 3.8 per-user token 在内网传输的安全面
|
||||
|
||||
`userToken` 是一把 new-api 调用凭据,现在要随 job 从后端流到 worker(内网 HTTP dispatch)。它的暴露面:job 载荷若被完整打进日志、token 出现在 worker 结果回传里、dispatch 通道本身是否鉴权。处置:
|
||||
|
||||
- **日志脱敏**:后端与 worker 两侧都不整条打 `userToken`,仿 `DesensitizedUtil` 只留前后各 4 位;池表与 job 载荷不进 INFO 级全量日志。
|
||||
- **传输面同现状**:job dispatch 与 worker 回调都在内网(Tailscale 100.64.x / localhost),与现有全局 `NEWAPI_KEY` 已经躺在 worker env 里是同一信任边界——本变更没有把凭据带出内网,只是从「一把共享」变成「每人一把」,粒度更细、单把泄漏的爆炸半径反而更小(只影响单个用户 ¥100)。
|
||||
- **与回调 HMAC 收口同源**:回调方向(worker→后端)已有 `aigc.executor.callback-secret` 的 HMAC-SHA256 验签(`AdminAigcTaskController` 内网回调 3b-B)。派发方向(后端→worker)当前是内网可达即信任;`userToken` 进入载荷把「保护 dispatch 通道」的优先级抬高了一档——内测阶段维持内网可达 + 日志脱敏可接受,生产化时应给 dispatch 通道同等的服务间鉴权(记为红线待办,随 facade 回调 HMAC 收口一并规划,不在本内测闭环内实现)。
|
||||
|
||||
### 3.9 额度耗尽如何干净失败(§6.5 失败因)
|
||||
|
||||
用户 token `remain_quota` 耗尽时,网关对 `/v1/chat/completions` 返非 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_<id>'`、`claimed_at` 非空;池 `FREE` 计数 -1。
|
||||
3. **生成扣减**:记录一次真 gen 前后该 claim 到的 user `SELECT used_quota FROM users WHERE id=<newapi_user_id>`,`Δused_quota > 0`;且该 token `remain_quota` 相应减少(权威余额闸)。
|
||||
4. **耗尽干净拒**:`PUT` / SQL 把该 token `remain_quota` 设近 0 → 触发一次 gen → 观察网关返 402/403 且后端任务终态 `failure_reason=quota_exhausted`(而非 `llm_error`),前端拿到可读拒因。
|
||||
5. **claim 幂等**:对同一 `game_player_id` 重放注册成功事件(或手工重投消费),池不新增第二条 `CLAIMED`、该玩家仍绑同一条(`uk_claimed_by` + 单 CAS 拦住);并发重投时验只有一条路径把某条 `FREE→CLAIMED`。
|
||||
6. **池空降级**:把池 `FREE` 清零后注册一个用户,注册(WU1)成功可登录、该玩家无 `CLAIMED` 条目、水位告警触发;ops 补池(重跑脚本)后对该玩家首次生成触发懒 claim 绑上(或定时补绑腿)。
|
||||
7. **越界不误伤**:驱动一次 bake-off / n=5 收敛环批跑(系统/编排触发,无 `game_player` 成员身份),验其 job 不被 per-user 门挡、`userToken` 留空、worker 回落全局 key 正常出图(证 §3.6 身份分流成立)。
|
||||
8. **存量 null 懒 claim**:对一个无 `CLAIMED` 条目的存量 member 触发生成,验单 CAS 懒 claim 就地绑上(池有货)或干净失败给可读拒因(池空),派发线不被阻塞。
|
||||
|
||||
可观测信号:**池水位(`FREE` 计数)与 claim 成功/池空计数**、池条目 `status` 流转(`FREE→CLAIMED`、`CLAIMED→INVALID` 隔离)、任务终态 `failure_reason` 分布(`quota_exhausted` 与 `llm_error` 可区分)、worker→网关外呼日志(脱敏 token;运行时不存在 admin 外呼日志,佐证零运行时 admin 调用)。
|
||||
|
||||
## 6 风险与回滚
|
||||
|
||||
- **provision 已离线,运行时零 new-api admin 耦合**(本方案相对 on-demand 的关键收益):管理凭据与 postgres 直写只活在离线 ops 脚本里,game-cloud 运行时零 admin API 调用、零 new-api-postgres 连接。原「运行时开户外呼进 DB 事务 / 三步慢外呼拖垮派发池 / CAS+PROVISIONING 串行三补偿腿」那一整套原子性与稳定性风险,随 provision 离线化一并消失。
|
||||
- **新失败面:池空**:provision 离线化换来的代价是池会耗尽。三层兜——注册时池空**不抛异常**(记 WARN + 水位告警、不绑、注册照常成功)、首次生成**单 CAS 懒 claim**(池已补则绑上、仍空则当次干净失败给可读拒因)、ops **重跑脚本补池**(按 `neice_<序号>` 幂等去重、提高目标 N)。**池水位告警(`FREE` <阈值)是核心运维信号**,进观测;缺了它池会静默见底、所有新用户拿不到额度。
|
||||
- **claim 幂等**:一玩家至多一条 `CLAIMED`(`uk_claimed_by`)+ `biz_no` 唯一键 + 单条 `FREE→CLAIMED` CAS,重投 / 并发收敛成一条——不会给同一玩家占第二个条目、也不会两玩家抢到同一条。这比原方案简单:claim 是纯 DB 操作,没有「HTTP 副作用无法回滚」的问题。
|
||||
- **越界不误伤既有验证线**:per-user 门只对真实 member(`creatorUserId` 命中 `game_player`)生效;编排器批跑/bake-off/n=5 收敛环「伪装在线提交」经 `submitGenerate→dispatchGeneric`(`AigcTaskServiceImpl` 202-204)同样流经派发点,无成员身份即旁路回落全局 key、绝不懒 claim(否则替一个 admin/system id 白占一个 ¥100 池条目)。堵点在 dispatch 层按身份分流,不在 worker 层。
|
||||
- **账本口径唯一**:权威余额 = token `remain_quota`、权威消耗 = user `used_quota`;`users.quota` 只作账户自洽不作限额裁决。¥100 由 ops 脚本预建时一次性设好,**运行时绝不改 quota(无运行时 `setUserQuota`)**,更严禁对已 claim/已消耗条目重设额度(绝对赋值会刷回满额=免费续杯);不做运营续额闭环,续额若需为 delta-aware 增量或独立 grant 流水表,属变现端到端、不在本闭环。
|
||||
- **契约一致性(contract-first,会被门拦)**:`quota_exhausted` 是跨契约共享枚举(`FailureReasonEnum` 头注禁单边扩展),必须同批落 Java 枚举 + `aigc.yaml:296` + `dify-workflow-io.json:50` 三处、与 WU3 协调;前端未知值容错须逐处核实非断言。worker 须分辨 402/403 额度耗尽(→`quota_exhausted`)与 401 凭据失效(→隔离该条目 `INVALID` + re-claim 另一 `FREE` + 可重试失败),池条目 token `expired_time` 由 ops 脚本设不过期避免自伤。
|
||||
- **凭据面**:`newapi_token_key` 明文落池表、随 job 内网传输,内测可接受但须日志脱敏;claim 按 `registerChannel` 过滤——`sms-mock` 8888 后门与一次性手机号自动注册不 claim,避免白占 ¥100 池条目。**管理令牌(system access token)是皇冠明珠**(泄漏可建任意用户/设任意额度/读全部 channel key,爆炸半径远大于任何 per-user token):现只被离线 ops 脚本使用,只存 `docs/内网凭据与端点.md`、最小权限、可轮换、标注泄漏应急,单列一条红线;**new-api postgres 直写凭据同属敏感**(ops 脚本用,泄漏可乱改 new-api 账本),一并列红线。生产化的列加密与 dispatch 通道服务间鉴权随 facade HMAC 收口规划,不在本闭环。
|
||||
- **脏活耦合 new-api schema**:postgres 直写 `users.access_token` 耦合 new-api 内部 schema(升级可能改列名)——但仅限**离线 ops 脚本**承担,New API 升级时只需改脚本一处、不波及 game-cloud 运行时。这正是选池模型(相对让 game-cloud 加 new-api postgres JDBC 的 on-demand 混合方案)的关键收益。
|
||||
- **版本号并行仲裁**:WU1 与 WU2 同批新增迁移——WU1 取 V31、WU2 顺延 **V32**,落地前 `ls .../db/migration | sort -V | tail` 硬复核,避免 Flyway 撞号。
|
||||
- **对 WU1 的依赖面**:WU2 骑在 WU1 的 `mobile` 放宽可空之上;`mobile` 可空的跨模块回归(trade/aigc/遥测等假定非空的消费方、脱敏空安全)与防枚举验证码闸门**归 WU1 验收**(主线 WU 归属),WU2 不重复挑,仅登记此依赖:WU1 未证前 WU2 的 member 身份查询不假定 `mobile` 非空。
|
||||
- **回滚**:本设计全为加法(离线 ops 脚本 / 新池表 / 事件消费者 claim / job 追加字段 / 新枚举值),回滚 = 摘事件消费者 + worker 恢复读全局 key + drop `newapi_quota_pool` + 撤 `userToken` 字段 + 回撤三处枚举;ops 脚本是离线工具,不用则不跑、删除即可;老全局 `NEWAPI_KEY` 路径始终保留,回滚即退回今天的共享计量行为,零数据损伤。
|
||||
|
||||
## 附 图清单与状态
|
||||
|
||||
| 图 | 媒介 | 位置 | 状态 |
|
||||
|---|---|---|---|
|
||||
| 图0 一图看懂 | inline SVG | §0 | 随文(house style:浅底/实线现行/虚线待办/红线拒绝路) |
|
||||
| 图1 六接线点数据流 | mermaid | §3.1 | 随文(事实源) |
|
||||
| 图2 池条目 FREE→CLAIMED claim 状态机 | mermaid | §3.4 | 随文(事实源) |
|
||||
| 图3 边界 in/out | mermaid | §2 | 随文(事实源) |
|
||||
|
||||
> 关联 commit:`cb9f135d`。收口时按 frontmatter `sot-impact` 把可存留结论折进「生成引擎运行时」(§6.1/§6.5) 与「数据模型」两个 canonical SoT,并把 `quota_exhausted` 同步进 `contracts/api-schemas/aigc.yaml` 与 `contracts/dify-workflow-io.json` 两处共享枚举(与 WU3 协调),本设计档降为留痕。S0 已实测坐实(管理端点/鉴权头/折算/「admin 不能替他人建 token」均已验证),本档 3.2/3.3 的原「待实测」管理面假设项已消解,provision 据此定为离线预置池 + 注册 claim;§3.9 的耗尽 / 凭据失效错误码 S0 未覆盖,仍待 S4 真机确认。
|
||||
246
docs/agent-specs/2026-07-07-内测-WU3-dify游戏开发节点-设计.md
Normal file
246
docs/agent-specs/2026-07-07-内测-WU3-dify游戏开发节点-设计.md
Normal file
@ -0,0 +1,246 @@
|
||||
---
|
||||
date: 2026-07-07
|
||||
topic: 内测-dify游戏开发节点-形态A
|
||||
status: 草案
|
||||
sot-impact: 无 canonical 修订——本设计不改 §6.1 job/result-out 契约、不改生产生成 runtime、不改契约#6(dify-workflow-io.json,反而澄清其历史身份)。仅两处收口回写:① 实施落地后回写《内网凭据与端点》(canonical)补 dify(nginx :18080/:18443、plugin_daemon :15003)与便宜档 cheap-worker(:9501)、cheap Service(:8300)的 dev 端点及 dify 专用 new-api token;② 若该内测通道日后转常设入口,再回写《生成引擎运行时》(agentic运行时架构图说,canonical)补一条「dify workflow 旁路 ingress」注记。本档阶段只申报影响面,不动 canonical。
|
||||
上级: docs/mvp/MVP作战清单.md
|
||||
关联: |
|
||||
cheap-worker/worker_service.py(:34 DEFAULT_PORT=9501、:192-204 try_enqueue 按 job_id/traceId 在 _seen 去重、:514-547 /generate 入口、:82-105 HMAC 签名、:229-256 落盘与回调 status=succeeded/failed、:570 main 硬编码 host=0.0.0.0 无 --host) ·
|
||||
cheap-worker/cheap_service_driver.py(:29-32 NEWAPI_KEY 从进程 env 取、非 per-job token、:200-276 驱动 cheap Service /chat @127.0.0.1:8300) ·
|
||||
cheap-worker/cheap_budget.py(:18-37 单局 ¥10 软停/¥15 硬地板、rmb_hard_limit/soft_budget override 被 pop、调用方不可改) ·
|
||||
cheap-worker/cheap_run.py(:42-44 game_dir=games/amgen-<id>/、:220-234 scaffold 先克隆 _template/src 7 文件) ·
|
||||
game-cloud/.../executor/AigcGenerateExecutor.java(:658-673 §6.1 job 组装) ·
|
||||
game-cloud/.../controller/admin/task/AdminAigcTaskController.java(:113-169 /dify/callback-internal) ·
|
||||
game-cloud/.../service/executor/CallbackSignatureVerifier.java(:63-99 enabled/verify) ·
|
||||
game-cloud/.../service/callback/DifyCallbackTxService.java(:131-135 selectByTraceId→AIGC_TASK_NOT_EXISTS) ·
|
||||
contracts/dify-workflow-io.json(契约#6,历史方向,本档不改) ·
|
||||
commit cb9f135d
|
||||
图清单: [图1 拓扑总览, 图2 回流落点三选项决策, 图3 触发-生成-回流时序, 图4 做与不做边界]
|
||||
---
|
||||
|
||||
# 内测 · dify 游戏开发节点直连 agentscope 生成线 —— 形态 A 落地设计
|
||||
|
||||
## 0 一图看懂
|
||||
|
||||
内测要交付的能力只有一句话:在 mini-infra 上那套已经部署好的 dify 里搭一条最小 workflow,其中一个「游戏开发节点」(一个 HTTP Request 节点)把用户的一句话创意组成后端生成线早已定义的 §6.1 job,直接 POST 给便宜档的 cheap-worker `:9501/generate`,由现成的 agentscope 生成线跑出一款小游戏。后端近零改动——不新增接口、不改生成主线、不碰契约。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph MI["mini-infra 100.64.0.8"]
|
||||
D["dify 1.15.0<br/>nginx :18080/:18443"]
|
||||
DN["游戏开发节点<br/>(HTTP Request)"]
|
||||
NA["new-api :3000<br/>(LLM 网关)"]
|
||||
D --> DN
|
||||
end
|
||||
subgraph GEN["便宜档生成栈 (dev,如 mini-desktop)"]
|
||||
W["cheap-worker :9501<br/>/generate 有界队列"]
|
||||
S["cheap Service :8300<br/>/chat agentscope 生成"]
|
||||
DIR[("game_dir 落盘<br/>src/ 多文件源工程")]
|
||||
W --> S
|
||||
S --> DIR
|
||||
end
|
||||
DN -->|"§6.1 job POST<br/>(Tailscale)"| W
|
||||
S -->|"OpenAI 兼容调模型"| NA
|
||||
W -.->|"回流三选项<br/>见 图2"| OUT{{"产物落点"}}
|
||||
DIR --> OUT
|
||||
```
|
||||
|
||||
**核心思想**:dify 只是又一个「组 §6.1 job 的调用方」,替换掉后端 `AigcGenerateExecutor` 的角色;job 契约与 worker 生成核心一字不改,复用现货把「agentscope 能被 dify workflow 驱动」这件事跑通。
|
||||
|
||||
**边界**:内测形态 A 是一条**独立生成通道**——产物落在 worker 的 `game_dir` 与 dify 自己手里,**不进 game-cloud 的发布/feed**,**不扣任何用户的 new-api 个人额度**。要让 dify 产物进 feed 属于形态 B(需要一个能建 task 记录的服务间免登录触发/回调面),工作量与攻击面都上一个台阶,内测不做。
|
||||
|
||||
**怎么算成功**:从 dify(`:18080`)触发一次 workflow → cheap-worker `:9501` 收到 job 并回 202 → agentscope 真跑一局 → worker 收口日志报 `status=succeeded`、`game_dir` 落定产物。注意成功判据是那行收口日志,**不是**「`src/` 非空」——scaffold 会在真生成前先把 `_template/src`(7 个文件)克隆进 `game_dir`,一次 scaffold 成功但生成失败的 run 同样「src/ 非空」,单看它区分不出成功与失败(详见 §5)。这一条端到端链路走通,即「内测前支持 dify workflow」成立。
|
||||
|
||||
> §0 的门面 SVG 概览图随收口由图层子代理据本节文字与图1 派生补上;当前草案以 Mermaid 为事实源。
|
||||
|
||||
## 1 意图与目标
|
||||
|
||||
内测要对内证明一件事:平台的游戏生成能力不只能被自家后端的执行器驱动,也能被一个通用的 workflow 编排器(dify)当作一个节点挂进去调用。这关系到后续「B 端定制」与「把生成能力对外暴露成可编排的一步」这条叙事——如果 agentscope 生成线只能被 game-cloud 内部那条异步队列唤起,它就还只是一个封闭子系统;一旦它能被 dify 这类外部编排面以标准 HTTP 调起,它才具备「作为一个可复用生成算子被别的流程组合」的形态。
|
||||
|
||||
之所以现在能几乎零成本做成,是因为生成线早就把「投递面」设计成了纯 HTTP + 一份稳定的 job 契约。后端的 `AigcGenerateExecutor` 组一份 §6.1 job(`job_id`/`tier`/`kind`/`brief`/`model`/`budget`/`gameId`/`templateId`/`renderCtx`/`idempotency_key`/`deadline_ms`/`callback`/`traceId`),POST 给 cheap-worker 的 `/generate`,worker 入队后返 202、在后台线程跑 agentscope、完成后按 `callback.target` 把结果 HMAC 签名发回。这套投递面对调用方是谁并不关心——它只认 job 的形状。dify 的一个 HTTP Request 节点完全可以充当这个「组 job 的人」。
|
||||
|
||||
目标就落在这条最短路径上:
|
||||
|
||||
- 在 dify 里建一条最小 workflow,含一个把 §6.1 job 打给 cheap-worker `:9501/generate` 的 HTTP「游戏开发节点」;
|
||||
- 把 dify 的输入(用户一句话 brief)映射进 job 字段,鉴权与超时按内网现实配好;
|
||||
- 明确 dify 生成的游戏走哪条回流、落在哪里,并把「不进 feed、不扣个人额度」这两条边界钉死,避免和后端生产链路、和 WU2 的计费门互相污染;
|
||||
- 交付一次可真机取证的端到端 demo 作为验收。
|
||||
|
||||
不做的是任何后端生成主线的改动,以及契约 #6 里「后端主动调 Dify」那个历史方向。
|
||||
|
||||
## 2 边界
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph IN["做 (in scope)"]
|
||||
I1["dify 最小 workflow + 一个 HTTP 游戏开发节点"]
|
||||
I2["dify 输入 → §6.1 job 字段映射(复用现有契约,不改)"]
|
||||
I3["回流落点决策 + 边界钉死(不进 feed)"]
|
||||
I4["额度接缝:dify 专用 token,不扣个人额度"]
|
||||
I5["安全:内网 + HMAC 两端非空约束(若用回调)"]
|
||||
I6["一次真机取证 demo 作验收"]
|
||||
end
|
||||
subgraph OUT["不做 (out of scope)"]
|
||||
O1["契约#6「后端主动调 Dify」方向"]
|
||||
O2["改后端生成主线 / §6.1 job 契约 / 生产 runtime"]
|
||||
O3["dify 产物进 game-cloud 发布/feed(=形态B)"]
|
||||
O4["把 dify 产物计入创作者账户/个人额度"]
|
||||
O5["dify workflow 内自建安全/意图/质量多节点(契约#6 旧六节点形态)"]
|
||||
end
|
||||
```
|
||||
|
||||
在范围内的是 dify 侧的 workflow 与节点、字段映射、回流与额度的边界判断、以及安全与验收。这些改动几乎全部落在 dify 里(一份 workflow 定义)和运维配置里(端点、token、密钥),后端代码近零改。
|
||||
|
||||
明确排除四类。其一,契约 #6(`contracts/dify-workflow-io.json`)描述的是「Java 壳主动调 Dify workflow、Dify 内跑六个节点再把结果回给 Java 壳」这个反方向,工作量最大、且依赖一个从未部署过的 Dify 生成能力,内测不碰。其二,后端生成主线、§6.1 job 契约、生产 runtime 一律不动——形态 A 的全部价值恰恰建立在「什么都不改就能被驱动」上,一旦要改后端就说明选错了形态。其三,把 dify 产物送进 game-cloud 的发布与 feed,这需要一个能在后端建 task 记录的服务间触发面,属于形态 B,见 §3.3 的论证。其四,把 dify 的试验性生成消耗计入任何真实创作者的额度账,见 §3.4。
|
||||
|
||||
## 3 方案
|
||||
|
||||
### 3.1 拓扑:dify 顶替执行器的「组 job」角色
|
||||
|
||||
现行生产链路里,一次便宜档生成是这样流动的:用户在 studio 提交创意 → 后端建 `game_aigc_task` 记录、进异步队列 → `AigcGenerateExecutor` 认领任务、组 §6.1 job → 投给 cheap-worker `:9501/generate` → worker 入队返 202 → 后台线程调 cheap Service `:8300/chat` 跑 agentscope → 产物落 `game_dir`、并把 result-out 按 `callback.target` HMAC 回发 `/dify/callback-internal` → 后端三表事务落包、进发布与 feed。
|
||||
|
||||
形态 A 只替换这条链的**最前一段**:把「后端建 task + 执行器组 job」换成「dify 的 HTTP 节点组 job」。从 worker 往后(入队、agentscope 生成、落盘)一字不动。差别只在两处,而这两处正是本设计要解决的真问题:dify 组的 job 没有对应的后端 task 记录(决定了回流落点,§3.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-<workflow_run_id>` | worker 用作 `game_dir` 目录名与 OTLP conversation.id;形态 A 下无后端含义 |
|
||||
| `traceId` | 与 `job_id` 同值,建议 `dify-<uuid>` | worker 以 `traceId`(缺则 `job_id`)为幂等/追踪键;**必须加 `dify-` 前缀**做命名空间隔离,见 §3.5 |
|
||||
| `job_id` | 同 `traceId` | 贯穿 job↔result 的键 |
|
||||
| `templateId` | `"generic"` | worker `result_out.py` 缺省即 `generic`,显式给以稳形 |
|
||||
| `tier` | `"L1"` | 便宜档裸路,与执行器同值 |
|
||||
| `kind` | `"generate"` | 单局生成 |
|
||||
| `model` | dify 侧配的便宜档模型名(如 `MiniMax-M3`) | worker 侧 cheap Service 实际按自身配置取 key/model;此字段透传,建议与生成栈配置一致 |
|
||||
| `budget` | `{"maxYuan":0.15,"maxLlmCalls":8}` | 契约字段,透传即可;**它不承载封顶**——单局真实成本上界由服务端 `cheap_budget` 固定的 ¥10 软停 / ¥15 硬地板决定(`build_cheap_breaker` 把 `rmb_hard_limit`/`soft_budget` 的调用方 override 直接 pop 掉),job 里写 0.15 是死字段,别据它产生「单局最多 ¥0.15」的错觉,见 §3.4 |
|
||||
| `renderCtx` | `{"template_schema":"","banned_list":"","findings":""}` 或整体省略 | **便宜档生成路径不消费**;给空壳或不给都不影响生成 |
|
||||
| `idempotency_key` | 同 `traceId` | 契约字段,透传 |
|
||||
| `deadline_ms` | 任务级墙钟(如 `600000`) | 与 watchdog 协同;给一个合理上限即可 |
|
||||
| `callback` | 见 §3.3 决策 | 形态 A 默认**不设**(a-min);若走 a-dify 则指 dify 侧捕获入口 |
|
||||
|
||||
**鉴权与超时**:worker 的 `/generate` 当前对入站不做鉴权(信任边界靠内网不可外达,见 §3.5)。超时要分清两段——`/generate` 是**异步投递面**,worker 入队后**立即返 202**(`{accepted,job_id,traceId,queued}`),真正的 agentscope 生成在后台线程跑、可能几十秒到几分钟。所以 dify HTTP 节点的超时设成**握手级**(约 10 秒足够,只等 202/503),**绝不能**把它设成等生成完成的长超时——生成结果不会从这个 HTTP 响应回来,它经回调或落盘回流。队满时 worker 返 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 生成完<br/>product 已落 game_dir"] --> Q{"callback.target?"}
|
||||
Q -->|"省略 (a-min · 推荐)"| A["worker 跳过回调<br/>产物留在 game_dir 磁盘<br/>= 落点"]
|
||||
Q -->|"指向 dify 捕获入口 (a-dify)"| B["第二条 dify workflow<br/>接住 result-out<br/>= 产物回 dify 自己消费"]
|
||||
Q -->|"指向后端 /dify/callback-internal (a-backend)"| C["handleCallbackTx<br/>selectByTraceId 未命中<br/>→ AIGC_TASK_NOT_EXISTS 拒<br/>= 不进 feed,仅能验 HMAC 接线"]
|
||||
style A fill:#dcfce7,stroke:#16a34a
|
||||
style C fill:#fee2e2,stroke:#dc2626
|
||||
```
|
||||
|
||||
**推荐内测默认 = (a-min):省略 `callback.target`,产物落在 worker 的 `game_dir`。** 依据同样是读码坐实的:worker 的 `process_job` 里,生成核心 `run_fn(job)` 返回 `(summary, game_dir)`——产物**无论是否有回调,都已经写进 `game_dir` 这个磁盘目录**(`worker_service.py:229`);而当 `callback.target` 缺省时,worker 明确「job 无 callback.target,跳过回调」(`worker_service.py:251-256`)。所以省略回调不会丢产物,只是不往外发。这条路零额外部件、零后端触碰,落点就是 worker 输出目录 `games/amgen-<gameId>/` 里那份 `src/` 源工程,最适合内测证明「agentscope 能被 dify 驱动」这唯一命题。但「落点可见」不等于「生成成功」——真成功判据是 worker 收口那行 `status=succeeded`(§5 有专门取证点),别把「`ls src/` 非空」当成功证据(scaffold 会先克隆 `_template`)。
|
||||
|
||||
**可选更完整形态 = (a-dify):`callback.target` 指向 dify 侧的一个捕获入口**(dify 里再建一条以 HTTP 触发的小 workflow,专门接住 result-out 存进 dify 的变量/日志)。这才字面兑现了「产物回 dify 自己消费」。它比 a-min 多一处 dify 接线,但仍然零后端改动。若创始人希望「闭环留在 dify 内」,走这条;否则 a-min 更省。走 a-dify 时,因为要用回调,HMAC 密钥两端非空的约束就必须落实(§3.5)。
|
||||
|
||||
**明确否决 = (a-backend):把 `callback.target` 指向后端 `/dify/callback-internal`。** 结局已如上——`selectByTraceId` 未命中、`1-101-000-000` 拒、产物不落包、不进 feed。它唯一的用处是烟测「HMAC 接线是否打通」(看后端返 401 还是走到 `AIGC_TASK_NOT_EXISTS` 的业务码,能区分「签名对不对」与「task 在不在」),但它**不是一条回流路径**,不能拿它当 demo 的落点。
|
||||
|
||||
**要真进 feed 怎么办(形态 B,内测不做)**:得补一个「服务间免登录、且能建 task 记录」的触发面——要么让 dify 先调一个后端的「代 dify 建 task」接口拿到一条真 `game_aigc_task`(含 `creatorUserId` 等前置)再触发生成,要么把回调面扩成「无 task 时按 dify 来源新建 task 再落包」。两条都在打开新的服务间信任面(免登录建记录 = 攻击面),且要处理创作者归属、租户、审核等一连串前置,工作量与风险都显著上升,接近形态 B。内测的价值命题不需要它,故钉死在范围外。
|
||||
|
||||
**边界(写死)**:形态 A 的 dify 产物**不进 game-cloud 的发布与 feed**,**不产生 `game_aigc_task` 记录**,**不产生创作者归属**。它是一条自证「生成线可被 dify 驱动」的独立通道,产物消费者是 dify 与运维本人。
|
||||
|
||||
### 3.4 额度接缝:不扣个人额度,用 dify 专用 token
|
||||
|
||||
形态 A 绕过了后端的任务表与计费门,也就绕过了 WU2 那套「new-api 按用户扣额度」的入口。这既是它的便宜之处,也是必须显式管住的一条缝:**dify 路的模型消耗默认不计入任何用户的个人额度账**。
|
||||
|
||||
worker 往后的生成实际调的是 new-api(`100.64.0.8:3000`)来跑 LLM,这笔钱一定会花。问题只是记在谁头上。形态 A 下没有「用户」这个主体,所以:
|
||||
|
||||
- **隔离靠的是「给 dify 起一套专属的 cheap-worker/cheap Service 实例、其进程 `NEWAPI_KEY` 环境变量 = dify 专用 token」,不是靠 job 自带 token。** 这一条必须写死,因为读码坐实:driver 的 key 从进程级 env 取(`cheap_service_driver._resolve_key()` → `client.get_api_key()` 读 `NEWAPI_KEY`),job 里根本没有任何 token/key 位。所以**切勿试图从 dify 节点透传 token**(做不到),也别以为「同一个共享 worker 能按 token 自动区分 dify 与真实用户」(它只有一把进程 env key,认不了 job 级来源)。dev 内测本就是「mini-desktop 单一 dev 生成栈」,那台栈本就 dify 专用,所以天然满足;但机制要讲清,否则会误导实现者去 job 里加 token。
|
||||
- **与 WU2 的硬冲突要点破**:WU2 拟给 job 补 `userToken` 位、让 worker 按用户取 key 做 per-user 计费。这与「dify 专用进程 env key」在**同一栈上**是互斥的——同一个进程只有一把 env `NEWAPI_KEY`,dify 路和真实 create 路一旦共栈就会共用同一把 key,隔离失效。所以二选一并写死:要么为 dify 起**独立**的 cheap Service 实例(自带专用 env key,且不承接 create 路),要么等 WU2 的 `job.userToken` 落地后由 worker 按 job 取 token(届时 dify 路给一个专用 userToken)。内测按前者(dev 单一 dify 栈天然独立),生产转常设入口时再对齐 WU2。
|
||||
- **成本上界要按真实值讲,别被 job.budget 的 0.15 骗**:单局真实成本上界 = 服务端固定的 ¥15 硬地板(软停 ¥10、硬地板 ¥15,`cheap_budget` fail-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-<uuid>` 同值,所以**dify 侧对同一个 job 配 HTTP 重试是安全的**——重试复用同一 `job_id`,会被内置去重折叠成单次生成,不会重复烧 token。真正的 gotcha 是反向的两点,要在 demo 记录里标注:其一,`_seen` 是进程级、**永不淘汰、worker 重启即清空**;其二,正因去重,一次**真正的重新生成必须换一个新的 `job_id`/`traceId`**,否则被静默去重(照返 202、但不产新产物)——所以实现者别为省事让每次重试新造 uuid(那才会造出「重复生成」),也别以为「同 job_id 再 POST 一次能拿到一份新产物」。
|
||||
|
||||
**worker → cheap Service `/chat`(`:8300`)**:这段是既有生成路径,worker 内部已有处置——生成异常会走兜底发 `failed` result-out(`worker_service.py` 的异常兜底路径),避免任务挂在 RUNNING。cheap Service 不可达或生成失败 → worker 产 `failed` 结果(a-min 下体现为 game_dir 里没有成功产物 + worker 日志报失败)。这段无需形态 A 额外设计,沿用现货。
|
||||
|
||||
**worker → new-api(`:3000`)**:由 cheap Service 内部承载超时与失败,key 失效/额度耗尽 → 生成失败 → failed result-out。形态 A 的额度接缝(§3.4)已把这条的账隔离到 dify 专用 token。
|
||||
|
||||
**worker → 回流(仅 a-dify/a-backend)**:`post_callback` 是发一次、失败记日志不重试(fire-once)。a-min 无此面。a-dify 下若 dify 捕获入口不可达,产物仍在 game_dir(落盘先于回调),只是 dify 侧没接住——降级为 a-min 的可见性,不丢产物。
|
||||
|
||||
**失败可见性(a-min 的固有盲区,必须显式声明)**:a-min(推荐默认、无回调)下,dify 的 HTTP 节点在 worker 入队那一刻就拿到 202 返回并把 workflow 判为成功,之后 agentscope 的真实结果**完全不回流给 dify**。所以一旦 new-api 额度耗尽 / key 失效 / cheap Service 生成失败,worker 只会打一行 `status=failed` 的收口日志、产物为空,而 **dify 工作流对此彻底不可见、没有任何用户可读的拒因**。换句话说 **a-min 下「dify 节点返 202」= 已受理,绝不等于「生成成功」**;失败只能靠人去 `ls game_dir` / `tail` worker 日志发现。这是 a-min 换来的极简所付的代价,内测取证时人肉盯日志可接受。若要让失败在触发点可见(比如把 WU2 的 `quota_exhausted` 这类干净拒因透传给 dify),必须走 a-dify——把回调指向 dify 侧捕获入口,让 result-out 的 `status`/拒因回到 dify 能读的地方;届时可把 a-dify 提为 demo 取证的必选,而非仅可选。
|
||||
|
||||
**补偿语义**:形态 A 不引入跨系统事务,产物落盘是幂等的(同 game_dir 覆盖写)。唯一需要人管的补偿是「dify 被误触发多次」时清理多余 game_dir,属运维动作,非自动补偿。
|
||||
|
||||
## 4 步骤计划
|
||||
|
||||
形态 A 的落地几乎全在 dify 侧配置与一次运维接线,代码物随阶段 1(实施),本档只到设计。分三步。
|
||||
|
||||
**第一步 · 生成栈就位并可被内网直连**。在 dev 生成机(如 mini-desktop)拉起 **dify 专属的** cheap-worker(`:9501`)与 cheap Service(`:8300`,其进程 `NEWAPI_KEY`=dify 专用 token,与真实 create 路隔离,见 §3.4),并确认该机无公网可达面(`:9501` 硬编码绑 `0.0.0.0`、无 `--host`,只能靠主机 Tailscale-only/NAT/防火墙收口,见 §3.5)。交付物 = 两个进程在跑、`:9501/generate` 与 `:8300/chat` 仅 Tailscale 内网可达。验证 = 从 dify 所在的 mini-infra 上 `curl --noproxy` 打一个最小 job 到 `:9501` 拿到 202;并实测非 Tailscale 来源打 `:9501` 不可达。依赖 = 生成栈现货、new-api 专用 token。风险 = 系统代理劫持内网 IP(用 `NO_PROXY`/`--noproxy` 绕过)。
|
||||
|
||||
**第二步 · dify 建最小 workflow + 游戏开发节点**。在 dify(`:18080`)里建一条 workflow:一个开始节点收 `brief` 输入 → 一个 HTTP Request「游戏开发节点」按 §3.2 映射组 §6.1 job、POST 到 `:9501/generate`(超时握手级、`traceId` 带 `dify-` 前缀、`callback` 按 §3.3 决策默认省略)。交付物 = 一条可运行的 dify workflow,其定义 DSL 导出留痕(建议存 `spikes/` 或本档 assets,供可复现)。验证 = 在 dify 里手动跑一次,节点拿到 202。依赖 = 第一步就位。风险 = dify sandbox 出网未绕代理(同第一步)。
|
||||
|
||||
**第三步 · 端到端取证 + 边界与凭据回写**。跑通 dify → worker → agentscope → game_dir 全链,取证(§5)。收口把 dify/worker/Service 端点与 dify 专用 token 回写《内网凭据与端点》,把 dify workflow DSL 留痕。交付物 = 一份真机取证记录 + 凭据回写。验证 = §5 验收门全绿。依赖 = 前两步。风险 = 无回调时忘了看 game_dir(落点提醒)。
|
||||
|
||||
## 5 验证方式
|
||||
|
||||
内测最小可行判据只有一条链:**从 dify workflow 触发一次 → cheap-worker 跑 agentscope → 拿到生成产物**。它可真机取证,取证点逐条可机器验或肉眼可查:
|
||||
|
||||
1. **触发**:在 dify(`http://100.64.0.8:18080`)运行 workflow,输入一句 brief。dify 侧显示游戏开发节点返回 202、body 含 `{"accepted":true,"job_id":"dify-...","traceId":"dify-...","queued":N}`。**注意 202 只证明「已受理」,不证明「生成成功」**——它甚至可能是同 `job_id` 重投被去重返的 202(此时 `queued` 不增、无新生成,§3.7);真成功证据在第 4 步。所以每次要真生成必须换新 `job_id`/`traceId`。
|
||||
2. **收单**:cheap-worker 日志出现 `收到 job trace_id=dify-..., templateId=generic, gameId=dify-... → 受理(202)`(`worker_service.py:544` 那行)。这是「dify 的 job 确实到了 worker」的直接证据。
|
||||
3. **真跑 agentscope**:worker 日志显示后台线程驱动 cheap Service 生成(`:8300` 各 POST),生成过程有 LLM 调用痕迹;cheap Service 侧日志对应产出。
|
||||
4. **生成成功(不是「产物可见」,别踩假绿)**:成功判据用 worker 的真状态信号,**不看 `src/` 是否非空**——因为 scaffold 会在真生成前先把 `_template/src`(实测 7 个文件:`assets/core/game-logic/game/host-config/main/render.js`)克隆进 `game_dir`,一次 scaffold 成功但生成失败的 run 同样「`src/` 非空、多文件」,单看它区分不出成败。可用的成功证据有三,任一即可:①grep worker 收口日志到 `status=succeeded`(`process_job` 在 `worker_service.py:254/256` 那两行本就打 `status={succeeded|failed}`);②核 `_wg1-gen/<gameId>/evidence/verdict.json` 九门 pass;③核 `game_dir/src/game-logic.js` 已偏离 `_template` 基线(agent 真改过游戏本体)。产物落点目录是 **`game-runtime/games/amgen-<gameId>/src/`**(`cheap_run.game_dir` 返 `games/amgen-<id>/`,dify 令 `gameId=dify-<run_id>` 时实际目录是 `amgen-dify-<run_id>/`,`gameId` 已含 `dify-` 前缀,别按字面去找 `dify-<gameId>`)。a-dify 下,额外在 dify 捕获入口看到 result-out 被接住、其 `status` 可读。
|
||||
5. **边界反证(可选但推荐)**:确认后端**没有**新增 `game_aigc_task` 记录、feed 里**没有**这款 dify 产物——反证「不进 feed」这条边界成立。若走了 a-backend 烟测,确认后端返 `1-101-000-000`(task 不存在)而非 401,反证「HMAC 接线通、但产物如设计般被拒于 feed 外」。
|
||||
|
||||
这条链任一环断了都能定位到具体环节(202 没拿到 = dify→worker 网络/代理;收单没日志 = worker 没跑/端口错;`status=failed` 或产物只剩 `_template` = cheap Service/new-api 失败)——但要认清:a-min 下这套定位**全靠运维在生成栈这侧看 worker 日志/ls 目录,dify 那侧对失败是盲的**(§3.7 失败可见性)。走通即「内测前支持 dify workflow」成立。
|
||||
|
||||
## 6 风险与回滚
|
||||
|
||||
**回滚**:形态 A 是纯增量、后端零改动,回滚只需在 dify 里停用/删除那条 workflow,生成栈照常服务生产链路,无任何后端状态需要还原。这是形态 A 相对形态 B 最大的安全优势。
|
||||
|
||||
**主要风险**:其一,内网代理劫持——`100.64.x` 被系统代理 fake-ip 劫走导致连不上,处置是 dify sandbox 与运维 curl 一律 `NO_PROXY`/`--noproxy`。其二,回调面若误配(a-backend 或 a-dify 密钥单端空)——处置是钉死「不指后端口 + HMAC 两端非空 + traceId 带 `dify-` 前缀」,并把两端非空做成启动断言。其三,入站面无鉴权 + `:9501` 硬编码绑 `0.0.0.0`——处置是靠主机层网络隔离(Tailscale-only/NAT/防火墙)兜住,验收实测非 Tailscale 来源打 `:9501` 不可达(§3.5)。其四,dify 反复触发烧 new-api 专用 token——量级要按真实值算:单局成本上界是服务端固定的 ¥15(不是 job.budget 的 0.15),唯一真实兜底是给该专用 token 配 new-api 侧用量配额(如 ¥150/日 ≈ 最坏 10 局触顶);注意同一次 POST 的 HTTP 重试会被 worker 按 `job_id` 去重折叠、不会重复烧钱,真正的多次生成只发生在「每次换新 id」时。其五,读者误把「Dify」类名当现行依赖——处置见 §3.6,本档已把命名血缘讲清。
|
||||
|
||||
**已知限制**:worker 的去重是**进程级 `_seen`、永不淘汰、重启即清空**——同 `job_id`/`traceId` 重投会被静默去重(返 202 但不产新产物),一次真正的重新生成必须换新 id(§3.7)。这不是「会重复生成」的限制,恰恰相反是「同 id 只产一次」,实现 dify 重试时据此复用同一 `job_id` 即可,无需在形态 A 里对生成主线动任何去重逻辑。
|
||||
|
||||
## 附 图清单与状态
|
||||
|
||||
| 图 | 位置 | 角色 | 状态 |
|
||||
|---|---|---|---|
|
||||
| 图1 拓扑总览 | §0 | Mermaid 事实源 | 随文本 |
|
||||
| 图2 回流落点三选项决策 | §3.3 | Mermaid 事实源 | 随文本 |
|
||||
| 图3 触发-生成-回流(隐含于图1+§3.7 文字) | §3.7 | 文字为主 | 随文本 |
|
||||
| 图4 做与不做边界 | §2 | Mermaid 事实源 | 随文本 |
|
||||
| §0 门面 SVG 概览 | §0 | 派生视觉(给人看) | 待收口图层子代理据图1 派生 |
|
||||
|
||||
> 图文冲突以文字为准;SVG 收口时由图层子代理据 §0/图1 派生,house style 照 architecture-diagram-atlas。本档收口需过 Codex + Opus 双评审(Codex 不可用回落 Opus 单评),并按 frontmatter `sot-impact` 在实施落地后回写《内网凭据与端点》。
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
}
|
||||
@ -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)
|
||||
*
|
||||
* <p><b>跨租户存取铁律</b>:池是系统级资源、注册消费/派发线均无租户上下文——调用方({@code NewapiQuotaServiceImpl})
|
||||
* 必须用 {@code TenantUtils.executeIgnore(...)} 包裹本 Mapper 的每次调用,否则多租户插件会按空租户上下文过滤致查不到条目。
|
||||
*
|
||||
* <p><b>claim 幂等靠 CAS + 唯一键</b>:{@link #claimOneFree} 是单条 {@code FREE→CLAIMED} 行级 CAS;并发/重投由
|
||||
* {@code status='FREE'} 条件 + uk_claimed_by + uk_biz_no 三重收敛,任何路径不给同一玩家占第二条、不让两玩家抢同一条。
|
||||
*
|
||||
* @author 绘境AI
|
||||
*/
|
||||
@Mapper
|
||||
public interface NewapiQuotaPoolMapper extends BaseMapperX<NewapiQuotaPoolDO> {
|
||||
|
||||
/**
|
||||
* 按已绑玩家取其 CLAIMED 条目(懒 claim 前查 / 派发取 token;uk_claimed_by 保证至多一条)。
|
||||
*
|
||||
* @param gamePlayerId game_player.id
|
||||
* @return CLAIMED 条目;未绑返回 null
|
||||
*/
|
||||
default NewapiQuotaPoolDO selectClaimedByPlayer(Long gamePlayerId) {
|
||||
return selectOne(new LambdaQueryWrapperX<NewapiQuotaPoolDO>()
|
||||
.eq(NewapiQuotaPoolDO::getClaimedByGamePlayerId, gamePlayerId)
|
||||
.eq(NewapiQuotaPoolDO::getStatus, NewapiQuotaStatusEnum.CLAIMED.getStatus()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 池水位:FREE 条目计数(低于阈值触发补池告警,§3.4)。
|
||||
*
|
||||
* @return 当前 FREE 条目数
|
||||
*/
|
||||
default Long countFree() {
|
||||
return selectCount(new LambdaQueryWrapperX<NewapiQuotaPoolDO>()
|
||||
.eq(NewapiQuotaPoolDO::getStatus, NewapiQuotaStatusEnum.FREE.getStatus()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 原子 CAS:抢一个 FREE 条目翻转 CLAIMED 并绑玩家(§3.4)。
|
||||
*
|
||||
* <p>双层派生表 {@code (SELECT id FROM (SELECT id ... LIMIT 1) t)} 是 MySQL「不能 UPDATE 又在 FROM 子查询引用同表」
|
||||
* 的标准物化绕法;{@code AND status='FREE'} 再做一次行级 CAS 兜底并发。返回:1=抢到、0=池空(子查询 NULL 无命中)。
|
||||
* 显式带 {@code deleted=0}(raw @Update 不经多租户/逻辑删自动改写,调用方 executeIgnore 已免租户过滤)。
|
||||
*
|
||||
* @param gamePlayerId 绑定的 game_player.id
|
||||
* @param bizNo 幂等键 claim_<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);
|
||||
|
||||
}
|
||||
@ -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;
|
||||
|
||||
}
|
||||
@ -10,6 +10,7 @@ import com.wanxiang.huijing.game.module.aigc.service.executor.GameConfigSchemaVa
|
||||
import com.wanxiang.huijing.game.module.aigc.service.executor.PromptResourceLoader;
|
||||
import com.wanxiang.huijing.game.module.aigc.service.executor.WorkerClassifyClient;
|
||||
import com.wanxiang.huijing.game.module.aigc.mq.GenTaskProducer;
|
||||
import com.wanxiang.huijing.game.module.aigc.service.quota.NewapiQuotaService;
|
||||
import com.wanxiang.huijing.game.module.aigc.saa.SaaGraphDispatcher;
|
||||
import com.wanxiang.huijing.game.module.aigc.service.executor.GenerationDispatcher;
|
||||
import com.wanxiang.huijing.game.module.aigc.service.executor.WorkerDispatchClient;
|
||||
@ -178,7 +179,8 @@ public class AigcExecutorConfiguration {
|
||||
WorkerDispatchClient workerDispatchClient,
|
||||
SaaGraphDispatcher saaGraphDispatcher,
|
||||
ObjectProvider<SourceProjectApi> sourceProjectApiProvider,
|
||||
ObjectProvider<GenTaskProducer> genTaskProducerProvider) {
|
||||
ObjectProvider<GenTaskProducer> genTaskProducerProvider,
|
||||
ObjectProvider<NewapiQuotaService> newapiQuotaServiceProvider) {
|
||||
// A11 M1②:软取 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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,65 @@
|
||||
package com.wanxiang.huijing.game.module.aigc.mq;
|
||||
|
||||
import com.wanxiang.huijing.game.module.aigc.mq.message.PassportRegisterEvent;
|
||||
import com.wanxiang.huijing.game.module.aigc.service.quota.NewapiQuotaService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
|
||||
import org.apache.rocketmq.spring.core.RocketMQListener;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* new-api 额度注册 claim 消费者(WU2 §3.4)——消费 WU1「注册成功」事件,从预置池 claim 一个 FREE 绑玩家。
|
||||
*
|
||||
* <p><b>接缝</b>:订阅 WU1 outbox 投递器的 topic {@code passport-register}(生产侧常量
|
||||
* {@code PassportRegisterOutboxDeliverer.TOPIC};跨模块不依赖 system-server,故此处以字面量对齐 wire 契约)。
|
||||
* 消息载荷是 JSON,反序列化进本模块镜像体 {@link PassportRegisterEvent}(字段与生产侧 {@code PassportRegisterMessage} 同名)。
|
||||
*
|
||||
* <p><b>装配开关</b>:随 {@code aigc.newapi-quota.enabled=true} 装配——关闭(默认)时不起消费者、不连 NameServer,
|
||||
* WU2 整体未启用时零副作用(与 {@code NewapiQuotaServiceImpl} 主开关同源)。
|
||||
*
|
||||
* <p><b>幂等 + 不外抛</b>:claim 幂等由 service 层 uk + 单 CAS 保证(重投/并发收敛成一条);池空/异常已在 service 内吞
|
||||
* (池空是离线补池才能恢复、非 MQ 重试窗口内可恢复,抛异常只灌 DLQ 空转)。此处防御性兜底 catch,正常返回=ack,不无限重投。
|
||||
* 消费模型 = 默认 CLUSTERING(每条消息集群内单实例消费)。
|
||||
*
|
||||
* @author 绘境AI
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@ConditionalOnProperty(prefix = "aigc.newapi-quota", name = "enabled", havingValue = "true")
|
||||
@RocketMQMessageListener(
|
||||
topic = "passport-register", // = PassportRegisterOutboxDeliverer.TOPIC(wire 契约,跨模块不引 -server)
|
||||
consumerGroup = "newapi_quota_claim_consumer"
|
||||
)
|
||||
public class NewapiQuotaClaimConsumer implements RocketMQListener<PassportRegisterEvent> {
|
||||
|
||||
/** 额度池服务(claim 落地:渠道过滤 + CAS 抢占 + 池空处置)。 */
|
||||
private final NewapiQuotaService quotaService;
|
||||
|
||||
public NewapiQuotaClaimConsumer(NewapiQuotaService quotaService) {
|
||||
this.quotaService = quotaService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 消费一条「注册成功」事件 → 交 service 做渠道过滤 + 幂等 CAS claim。
|
||||
*
|
||||
* @param event 注册成功事件镜像体(userId=game_player.id / registerChannel 归因)
|
||||
*/
|
||||
@Override
|
||||
public void onMessage(PassportRegisterEvent event) {
|
||||
if (event == null || event.getUserId() == null) {
|
||||
// 毒消息(体空/无 userId):重投无益,丢弃不重投,只告警留痕。
|
||||
log.error("[newapi-quota-mq] 注册事件体空或缺 userId,丢弃不重投 event={}", event);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
quotaService.claimForRegister(event.getUserId(), event.getRegisterChannel());
|
||||
log.info("[newapi-quota-mq] 消费注册成功事件已处置 userId={}, channel={}", event.getUserId(), event.getRegisterChannel());
|
||||
} catch (Exception e) {
|
||||
// 防御性兜底:service 内已吞池空/常规失败,此处只兜意外异常——正常返回=ack,不让 MQ 无限重投风暴。
|
||||
log.error("[newapi-quota-mq] 消费注册事件异常(吞掉不重投,待补池后懒 claim 兜)userId={}, channel={}",
|
||||
event.getUserId(), event.getRegisterChannel(), e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
package com.wanxiang.huijing.game.module.aigc.mq.message;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 「注册成功」事件消费侧镜像体(WU2 §3.4)
|
||||
*
|
||||
* <p>WU1 的生产侧消息类 {@code PassportRegisterMessage} 落在 huijing-module-system-server(-server,非 -api),
|
||||
* 跨模块不依赖对方 -server(守门④)。故本模块按同名字段建一个消费侧镜像 DTO——RocketMQ 以 JSON 传输载荷,
|
||||
* 消费端 {@code RocketMQListener<PassportRegisterEvent>} 按字段名反序列化即得,与生产侧解耦(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;
|
||||
|
||||
}
|
||||
@ -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,<b>可为 null</b>):真实 member 在线创作派发时取其 claim 到的 token 放进
|
||||
* §6.1 job 的 {@code userToken} 位;系统/编排/bake-off(无 game_player 行)旁路留空、worker 回落全局 key。
|
||||
* <p><b>null 时(旧构造/单测态)</b>:不注入 userToken = 现行行为(worker 全局 key)。生产经 AigcExecutorConfiguration
|
||||
* 软注入非空;其内部主开关 {@code aigc.newapi-quota.enabled}(默认 false)关时对所有创作者返 null,故即便在席也零行为变更、opt-in 生效。
|
||||
*/
|
||||
private final NewapiQuotaService newapiQuotaService;
|
||||
|
||||
/** 自禁用连续 tick 计数(自检恢复即清零;告警节流判据) */
|
||||
private int selfCheckFailTicks = 0;
|
||||
@ -200,6 +209,24 @@ public class AigcGenerateExecutor {
|
||||
GameConfigSchemaValidator schemaValidator, ExecutorLlmClient llmClient,
|
||||
WorkerDispatchClient workerDispatchClient, GenerationDispatcher saaDispatcher,
|
||||
SourceProjectApi sourceProjectApi, Clock clock, GenTaskProducer genTaskProducer) {
|
||||
// 委托 12 参构造,newapiQuotaService 传 null → per-user 额度门不接线(现行行为:worker 全局 key,现行单测沿用此签名)。
|
||||
this(properties, aigcTaskMapper, difyCallbackService, promptResourceLoader, schemaValidator,
|
||||
llmClient, workerDispatchClient, saaDispatcher, sourceProjectApi, clock, genTaskProducer, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 12 参构造(WU2 §3.6 收口;经 AigcExecutorConfiguration @Bean 调用):在 11 参基础上接 new-api per-user 额度门
|
||||
* {@link NewapiQuotaService}——真实 member 在线创作派发时取其 claim 到的 token 放进 §6.1 job 的 {@code userToken} 位;
|
||||
* 系统/编排(无 game_player 行)旁路留空、worker 回落全局 key。可 null(旧构造/单测态)=不接线,现行行为字节零变。
|
||||
*
|
||||
* @param newapiQuotaService per-user 额度门 seam(可 null=不接线;其主开关 aigc.newapi-quota.enabled 默认 false 时对所有创作者返 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 路。
|
||||
|
||||
@ -0,0 +1,49 @@
|
||||
package com.wanxiang.huijing.game.module.aigc.service.quota;
|
||||
|
||||
/**
|
||||
* new-api per-user 额度池服务(WU2 §3.4 注册 claim + §3.6 派发期 per-user 门)
|
||||
*
|
||||
* <p>账本在 new-api 网关,本服务只做两件纯 game-cloud 内部 DB 操作、零 new-api 外呼:
|
||||
* <ol>
|
||||
* <li><b>注册 claim</b>({@link #claimForRegister}):消费 WU1「注册成功」事件,从预置池 CAS 抢一个 FREE 绑玩家;</li>
|
||||
* <li><b>派发取 token</b>({@link #resolveUserTokenForDispatch}):真实 member 在线创作时取其 claim 到的 token 随 job 下发。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p><b>主开关</b>:{@code aigc.newapi-quota.enabled}(默认 false)——关时两个入口全旁路(现行行为,worker 回落全局 key),
|
||||
* 开时注册 claim 消费者装配 + per-user 门生效。这样 WU2 可整体 opt-in,池未预置前不误伤既有生成/批跑验证路。
|
||||
*
|
||||
* @author 绘境AI
|
||||
*/
|
||||
public interface NewapiQuotaService {
|
||||
|
||||
/**
|
||||
* 注册 claim(§3.4):消费「注册成功」事件,对种子渠道从池 CAS 抢一个 FREE 绑该玩家(幂等)。
|
||||
*
|
||||
* <p>渠道过滤:只对 {@code password/invite} claim;{@code sms} 自动注册与 {@code sms-mock} 后门跳过
|
||||
* (避免一次性手机号每次首登占掉一个 ¥100 池条目,这类若真去生成由懒 claim 兜)。
|
||||
* <p>幂等:uk_claimed_by + uk_biz_no + 单条 CAS 三重收敛,重投/并发不给同一玩家占第二条。
|
||||
* <p>池空:不抛异常(补池是离线人工动作、非 MQ 重试窗口内可恢复,抛异常只灌 DLQ 空转),记 WARN + 触发水位告警,
|
||||
* 该玩家暂不绑,首次生成由懒 claim 兜(§3.6)。
|
||||
*
|
||||
* @param gamePlayerId 新注册玩家编号(game_player.id)
|
||||
* @param registerChannel 注册通道(sms/invite/password)
|
||||
*/
|
||||
void claimForRegister(Long gamePlayerId, String registerChannel);
|
||||
|
||||
/**
|
||||
* 派发期解析 per-user token(§3.6)——per-user 门只对真实 member 在线创作生效。
|
||||
*
|
||||
* <ul>
|
||||
* <li>主开关关 / creatorUserId 空 → 返 null(旁路,worker 回落全局 key);</li>
|
||||
* <li><b>无 game_player 行</b>(系统/编排/bake-off/admin 触发)→ 返 null(旁路,不查池不懒 claim);</li>
|
||||
* <li><b>命中 game_player 行</b>(真实 member)→ 取其 CLAIMED 条目的 token_key;无 CLAIMED 则单 CAS 懒 claim 抢一个 FREE:
|
||||
* 抢到返 token_key,池空抢不到 → 抛 {@link QuotaPoolExhaustedException}(当次干净失败给可读拒因 + 水位告警)。</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param creatorUserId 任务创作者({@code AigcTaskDO.creatorUserId})
|
||||
* @return per-user token_key(member 命中/懒 claim 成功);null(旁路:系统触发或主开关关)
|
||||
* @throws QuotaPoolExhaustedException member 需要 token 但池空、懒 claim 抢不到
|
||||
*/
|
||||
String resolveUserTokenForDispatch(Long creatorUserId);
|
||||
|
||||
}
|
||||
@ -0,0 +1,181 @@
|
||||
package com.wanxiang.huijing.game.module.aigc.service.quota;
|
||||
|
||||
import com.wanxiang.huijing.game.module.aigc.dal.dataobject.quota.NewapiQuotaPoolDO;
|
||||
import com.wanxiang.huijing.game.module.aigc.dal.mysql.quota.NewapiQuotaPoolMapper;
|
||||
import com.wanxiang.huijing.framework.tenant.core.util.TenantUtils;
|
||||
import com.wanxiang.huijing.module.system.api.passport.PlayerApi;
|
||||
import com.wanxiang.huijing.module.system.api.passport.dto.PlayerRespDTO;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import jakarta.annotation.Resource;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* new-api per-user 额度池服务实现(WU2 §3.4/§3.6)
|
||||
*
|
||||
* <p>claim 与派发均是纯 game-cloud 内部 DB 操作、零 new-api 外呼;池是系统级资源,故每次 Mapper 调用都经
|
||||
* {@link TenantUtils#executeIgnore} 跨租户(注册消费/派发线均无租户上下文)。主开关 {@code aigc.newapi-quota.enabled}
|
||||
* 默认 false,关时两入口全旁路 = 现行行为(不误伤既有生成/批跑验证路)。
|
||||
*
|
||||
* @author 绘境AI
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class NewapiQuotaServiceImpl implements NewapiQuotaService {
|
||||
|
||||
/** 种子渠道:只对这两类 claim(一次性手机号/后门跳过,避免白占 ¥100 池条目,§3.4)。 */
|
||||
private static final String CHANNEL_PASSWORD = "password";
|
||||
private static final String CHANNEL_INVITE = "invite";
|
||||
|
||||
/**
|
||||
* 主开关(默认 false):关时 claim/派发全旁路。同 {@code NewapiQuotaClaimConsumer} 的 @ConditionalOnProperty,
|
||||
* 开则消费者装配 + per-user 门生效。
|
||||
*/
|
||||
@Value("${aigc.newapi-quota.enabled:false}")
|
||||
private boolean enabled;
|
||||
|
||||
/** 池水位告警阈值:FREE 计数低于此值即 WARN 提示 ops 补池(§3.4,默认 20)。 */
|
||||
@Value("${aigc.newapi-quota.water-level-threshold:20}")
|
||||
private long waterLevelThreshold;
|
||||
|
||||
@Resource
|
||||
private NewapiQuotaPoolMapper poolMapper;
|
||||
|
||||
/**
|
||||
* 玩家身份 seam(跨模块只依赖 system 的 -api):判 creatorUserId 是否命中一行 game_player。
|
||||
* 单体内由 system 的 PlayerApiImpl(@Primary) 就地解析;getPlayer 对无此行返 data=null(不抛)。
|
||||
*/
|
||||
@Resource
|
||||
private PlayerApi playerApi;
|
||||
|
||||
// ================== §3.4 注册 claim ==================
|
||||
|
||||
@Override
|
||||
public void claimForRegister(Long gamePlayerId, String registerChannel) {
|
||||
if (!enabled) {
|
||||
// 主开关关:WU2 未启用,注册 claim 全旁路(现行行为,玩家生成走全局 key)。
|
||||
return;
|
||||
}
|
||||
if (gamePlayerId == null) {
|
||||
log.warn("[newapi-quota] 注册 claim 收到空 gamePlayerId,跳过 channel={}", registerChannel);
|
||||
return;
|
||||
}
|
||||
// 渠道过滤:只对种子渠道 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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
package com.wanxiang.huijing.game.module.aigc.enums;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* {@link FailureReasonEnum} 的 WU2 §3.9 新增值断言——把守 {@code quota_exhausted} 跨契约共享枚举存在且取值稳定。
|
||||
*
|
||||
* <p>该值须与 contracts/api-schemas/aigc.yaml 的 FailureReason.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());
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,69 @@
|
||||
package com.wanxiang.huijing.game.module.aigc.mq;
|
||||
|
||||
import com.wanxiang.huijing.game.module.aigc.mq.message.PassportRegisterEvent;
|
||||
import com.wanxiang.huijing.game.module.aigc.service.quota.NewapiQuotaService;
|
||||
import com.wanxiang.huijing.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
/**
|
||||
* {@link NewapiQuotaClaimConsumer} 单元测试(纯 Mockito)——把守「委托 service 做渠道过滤+claim」与「不外抛」契约。
|
||||
*
|
||||
* <p>覆盖:正常事件透传 userId+channel 给 service;毒消息(体空/无 userId)丢弃不委托不抛;service 异常被兜底吞(不无限重投)。
|
||||
*
|
||||
* @author 绘境AI
|
||||
*/
|
||||
class NewapiQuotaClaimConsumerTest extends BaseMockitoUnitTest {
|
||||
|
||||
@Mock
|
||||
private NewapiQuotaService quotaService;
|
||||
|
||||
@InjectMocks
|
||||
private NewapiQuotaClaimConsumer consumer;
|
||||
|
||||
private PassportRegisterEvent event(Long userId, String channel) {
|
||||
PassportRegisterEvent e = new PassportRegisterEvent();
|
||||
e.setUserId(userId);
|
||||
e.setRegisterChannel(channel);
|
||||
e.setIdempotentKey(userId == null ? null : "register:" + userId);
|
||||
return e;
|
||||
}
|
||||
|
||||
/** 正常事件:透传 userId + channel 给 service(渠道过滤在 service 内做)。 */
|
||||
@Test
|
||||
void testOnMessage_delegatesToService() {
|
||||
consumer.onMessage(event(5L, "password"));
|
||||
verify(quotaService).claimForRegister(eq(5L), eq("password"));
|
||||
}
|
||||
|
||||
/** 毒消息:event 为 null → 不委托、不抛。 */
|
||||
@Test
|
||||
void testOnMessage_nullEvent_discardNoThrow() {
|
||||
assertDoesNotThrow(() -> consumer.onMessage(null));
|
||||
verify(quotaService, never()).claimForRegister(any(), anyString());
|
||||
}
|
||||
|
||||
/** 毒消息:userId 为空 → 不委托、不抛。 */
|
||||
@Test
|
||||
void testOnMessage_nullUserId_discardNoThrow() {
|
||||
assertDoesNotThrow(() -> consumer.onMessage(event(null, "password")));
|
||||
verify(quotaService, never()).claimForRegister(any(), anyString());
|
||||
}
|
||||
|
||||
/** service 异常:兜底吞掉不外抛(正常返回=ack,不让 MQ 无限重投)。 */
|
||||
@Test
|
||||
void testOnMessage_serviceThrows_swallowedNoThrow() {
|
||||
doThrow(new RuntimeException("db down")).when(quotaService).claimForRegister(eq(5L), eq("password"));
|
||||
assertDoesNotThrow(() -> consumer.onMessage(event(5L, "password")));
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,194 @@
|
||||
package com.wanxiang.huijing.game.module.aigc.service.quota;
|
||||
|
||||
import com.wanxiang.huijing.game.module.aigc.dal.dataobject.quota.NewapiQuotaPoolDO;
|
||||
import com.wanxiang.huijing.game.module.aigc.dal.mysql.quota.NewapiQuotaPoolMapper;
|
||||
import com.wanxiang.huijing.framework.common.pojo.CommonResult;
|
||||
import com.wanxiang.huijing.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import com.wanxiang.huijing.module.system.api.passport.PlayerApi;
|
||||
import com.wanxiang.huijing.module.system.api.passport.dto.PlayerRespDTO;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* {@link NewapiQuotaServiceImpl} 单元测试(纯 Mockito,不触真 DB)——把守 WU2 §3.4 注册 claim 与 §3.6 派发 per-user 门契约。
|
||||
*
|
||||
* <p>覆盖:claim CAS 幂等(既有复用不二次占池 / 并发 dup 收敛)、渠道过滤(sms 跳过)、池空不抛、主开关旁路、
|
||||
* per-user 门身份分流(member 取 token / 系统旁路 userToken 空)、懒 claim fail-fast(池空抛 QuotaPoolExhaustedException)。
|
||||
*
|
||||
* @author 绘境AI
|
||||
*/
|
||||
class NewapiQuotaServiceImplTest extends BaseMockitoUnitTest {
|
||||
|
||||
@InjectMocks
|
||||
private NewapiQuotaServiceImpl service;
|
||||
|
||||
@Mock
|
||||
private NewapiQuotaPoolMapper poolMapper;
|
||||
@Mock
|
||||
private PlayerApi playerApi;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// @Value 字段 Mockito 不注入,反射置默认:主开关开、水位阈值 20。
|
||||
ReflectionTestUtils.setField(service, "enabled", true);
|
||||
ReflectionTestUtils.setField(service, "waterLevelThreshold", 20L);
|
||||
}
|
||||
|
||||
private PlayerRespDTO member(long userId) {
|
||||
PlayerRespDTO dto = new PlayerRespDTO();
|
||||
dto.setUserId(userId);
|
||||
dto.setStatus(0);
|
||||
dto.setCreatorFlag(1);
|
||||
return dto;
|
||||
}
|
||||
|
||||
private NewapiQuotaPoolDO claimedEntry(long playerId, String tokenKey) {
|
||||
NewapiQuotaPoolDO entry = new NewapiQuotaPoolDO();
|
||||
entry.setId(9001L);
|
||||
entry.setClaimedByGamePlayerId(playerId);
|
||||
entry.setNewapiTokenKey(tokenKey);
|
||||
entry.setStatus("CLAIMED");
|
||||
return entry;
|
||||
}
|
||||
|
||||
// ===================== §3.4 注册 claim =====================
|
||||
|
||||
/** password 渠道 + 无既有条目 → 执行一次 CAS 抢占,bizNo=claim_<id>。 */
|
||||
@Test
|
||||
void testClaimForRegister_password_casOnce() {
|
||||
when(poolMapper.selectClaimedByPlayer(5L)).thenReturn(null);
|
||||
when(poolMapper.claimOneFree(eq(5L), eq("claim_5"), any(LocalDateTime.class))).thenReturn(1);
|
||||
|
||||
service.claimForRegister(5L, "password");
|
||||
|
||||
verify(poolMapper).claimOneFree(eq(5L), eq("claim_5"), any(LocalDateTime.class));
|
||||
}
|
||||
|
||||
/** 幂等:该玩家已有 CLAIMED 条目 → no-op 复用,不再 CAS(重投不二次占池)。 */
|
||||
@Test
|
||||
void testClaimForRegister_idempotent_existingClaimed_noCas() {
|
||||
when(poolMapper.selectClaimedByPlayer(5L)).thenReturn(claimedEntry(5L, "sk-EXIST"));
|
||||
|
||||
service.claimForRegister(5L, "invite");
|
||||
|
||||
verify(poolMapper, never()).claimOneFree(anyLong(), anyString(), any(LocalDateTime.class));
|
||||
}
|
||||
|
||||
/** 渠道过滤:sms 自动注册跳过 claim,池 mapper 全程不碰。 */
|
||||
@Test
|
||||
void testClaimForRegister_smsChannel_skip() {
|
||||
service.claimForRegister(5L, "sms");
|
||||
verifyNoInteractions(poolMapper);
|
||||
}
|
||||
|
||||
/** 渠道过滤:sms-mock 后门跳过。 */
|
||||
@Test
|
||||
void testClaimForRegister_smsMockChannel_skip() {
|
||||
service.claimForRegister(5L, "sms-mock");
|
||||
verifyNoInteractions(poolMapper);
|
||||
}
|
||||
|
||||
/** 池空:CAS 返 0 → 不抛异常、不绑(记 WARN + 水位告警,注册照常成功)。 */
|
||||
@Test
|
||||
void testClaimForRegister_poolEmpty_noThrow() {
|
||||
when(poolMapper.selectClaimedByPlayer(5L)).thenReturn(null);
|
||||
when(poolMapper.claimOneFree(eq(5L), eq("claim_5"), any(LocalDateTime.class))).thenReturn(0);
|
||||
|
||||
assertDoesNotThrow(() -> service.claimForRegister(5L, "password"));
|
||||
}
|
||||
|
||||
/** 并发同玩家 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));
|
||||
}
|
||||
|
||||
}
|
||||
@ -33,6 +33,18 @@ public class ApiAccessLogInterceptor implements HandlerInterceptor {
|
||||
|
||||
private static final String ATTRIBUTE_STOP_WATCH = "ApiAccessLogInterceptor.StopWatch";
|
||||
|
||||
/**
|
||||
* 敏感 URI 后缀白名单(2026-07-07 WU1 §3.2):命中的请求体不打印到 stdout/控制台。
|
||||
* <p>
|
||||
* 背景:本 Interceptor 在非 prod 环境用 log.info 把**原始 requestBody** 直接打到控制台,不受 {@code @ApiAccessLog(sanitizeKeys)}
|
||||
* 约束(那条只脱敏持久化日志 system_api_access_log)。密码是可跨站复用的持久凭据,任何环境抬高日志级别或聚合 stdout
|
||||
* 即明文外泄——不能只靠 staging 的 logging.level 兜。故对这两个 passport 密码端点做**代码级**保证:不读、不打印其 body。
|
||||
*/
|
||||
private static final String[] SENSITIVE_BODY_URI_SUFFIXES = {
|
||||
"/passport/password-register",
|
||||
"/passport/password-login"
|
||||
};
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
|
||||
// 记录 HandlerMethod,提供给 ApiAccessLogFilter 使用
|
||||
@ -44,8 +56,12 @@ public class ApiAccessLogInterceptor implements HandlerInterceptor {
|
||||
// 打印 request 日志
|
||||
if (!SpringUtils.isProd()) {
|
||||
Map<String, String> queryString = ServletUtils.getParamMap(request);
|
||||
String requestBody = ServletUtils.getBody(request);
|
||||
if (CollUtil.isEmpty(queryString) && StrUtil.isEmpty(requestBody)) {
|
||||
// WU1 §3.2:敏感 URI(密码端点)不读、不打印请求体,代码级杜绝明文密码进 stdout(不受 sanitizeKeys 约束的第二条链路)
|
||||
boolean sensitiveBody = isSensitiveBodyUri(request.getRequestURI());
|
||||
String requestBody = sensitiveBody ? null : ServletUtils.getBody(request);
|
||||
if (sensitiveBody) {
|
||||
log.info("[preHandle][开始请求 URL({}) 参数(已屏蔽敏感请求体)]", request.getRequestURI());
|
||||
} else if (CollUtil.isEmpty(queryString) && StrUtil.isEmpty(requestBody)) {
|
||||
log.info("[preHandle][开始请求 URL({}) 无参数]", request.getRequestURI());
|
||||
} else {
|
||||
log.info("[preHandle][开始请求 URL({}) 参数({})]", request.getRequestURI(),
|
||||
@ -72,6 +88,21 @@ public class ApiAccessLogInterceptor implements HandlerInterceptor {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断请求 URI 是否属于敏感请求体白名单(命中则不打印 body,见 {@link #SENSITIVE_BODY_URI_SUFFIXES})
|
||||
*/
|
||||
private boolean isSensitiveBodyUri(String uri) {
|
||||
if (StrUtil.isEmpty(uri)) {
|
||||
return false;
|
||||
}
|
||||
for (String suffix : SENSITIVE_BODY_URI_SUFFIXES) {
|
||||
if (uri.endsWith(suffix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打印 Controller 方法路径
|
||||
*/
|
||||
|
||||
@ -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, "邀请码无效"); // 码不存在(与已停用/已过期/已耗尽区分,便于运营定位)
|
||||
|
||||
@ -97,6 +97,13 @@
|
||||
<artifactId>huijing-spring-boot-starter-mq</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- RocketMQ(2026-07-07 WU1 §3.6:passport 注册成功事件 outbox 投递器 RocketMQTemplate):
|
||||
starter-mq 以 optional=true 声明本 starter(不传递),故本模块须显式直依赖;版本由 huijing 依赖管理统一控制。 -->
|
||||
<dependency>
|
||||
<groupId>org.apache.rocketmq</groupId>
|
||||
<artifactId>rocketmq-spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 服务保障相关:passport 发码端点 @RateLimiter(IP 维度限频,R6 §7.2)需此 starter,2026-06-10 鉴权件取消注释 -->
|
||||
<dependency>
|
||||
<groupId>com.wanxiang</groupId>
|
||||
|
||||
@ -8,6 +8,8 @@ import com.wanxiang.huijing.framework.ratelimiter.core.keyresolver.impl.ClientIp
|
||||
import com.wanxiang.huijing.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import com.wanxiang.huijing.module.system.controller.app.passport.vo.InviteRegisterReqVO;
|
||||
import com.wanxiang.huijing.module.system.controller.app.passport.vo.LoginRespVO;
|
||||
import com.wanxiang.huijing.module.system.controller.app.passport.vo.PasswordLoginReqVO;
|
||||
import com.wanxiang.huijing.module.system.controller.app.passport.vo.PasswordRegisterReqVO;
|
||||
import com.wanxiang.huijing.module.system.controller.app.passport.vo.PlayerMeRespVO;
|
||||
import com.wanxiang.huijing.module.system.controller.app.passport.vo.SendSmsCodeReqVO;
|
||||
import com.wanxiang.huijing.module.system.controller.app.passport.vo.SmsLoginReqVO;
|
||||
@ -16,6 +18,7 @@ import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.annotation.security.PermitAll;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
@ -80,6 +83,29 @@ public class AppPassportController {
|
||||
return success(passportService.inviteRegister(reqVO, ServletUtils.getClientIP()));
|
||||
}
|
||||
|
||||
@PostMapping("/password-register")
|
||||
@PermitAll // 免登:内测种子用户用户名密码注册(受总开关 wanxiang.passport.password-auth-enabled 管控)
|
||||
// WU1 §3.2 安全实证:脱敏持久化访问日志的 password 键(禁明文落 system_api_access_log);
|
||||
// ⚠ 控制台 stdout 明文由 ApiAccessLogInterceptor 代码级屏蔽本 URI 的 body 打印(不只靠 logging.level,§3.2)。
|
||||
@ApiAccessLog(sanitizeKeys = {"password"})
|
||||
@Operation(summary = "用户名密码注册", description = "内测种子用户;register_channel=password,返回体与短信路 token 一字不差")
|
||||
public CommonResult<LoginRespVO> passwordRegister(@Valid @RequestBody PasswordRegisterReqVO reqVO,
|
||||
HttpServletRequest request) {
|
||||
// WU1 §3.3 命门:IP 一律采信连接层 getRemoteAddr(),不读客户端可伪造的 XFF(不走 ServletUtils.getClientIP)
|
||||
return success(passportService.passwordRegister(reqVO, request.getRemoteAddr()));
|
||||
}
|
||||
|
||||
@PostMapping("/password-login")
|
||||
@PermitAll // 免登:内测种子用户用户名密码登录
|
||||
// WU1 §3.2 安全实证:同 password-register,脱敏持久化日志 password 键 + 代码级屏蔽 stdout body 打印。
|
||||
@ApiAccessLog(sanitizeKeys = {"password"})
|
||||
@Operation(summary = "用户名密码登录", description = "防枚举:查无用户名与密码错误统一错误码 + 假 hash 对齐耗时")
|
||||
public CommonResult<LoginRespVO> passwordLogin(@Valid @RequestBody PasswordLoginReqVO reqVO,
|
||||
HttpServletRequest request) {
|
||||
// WU1 §3.3 命门:取连接层 getRemoteAddr(),不采信 XFF(伪造 XFF 换桶绕过的堵漏)
|
||||
return success(passportService.passwordLogin(reqVO, request.getRemoteAddr()));
|
||||
}
|
||||
|
||||
@GetMapping("/me")
|
||||
@Operation(summary = "当前登录玩家信息", description = "登录态必需(无 @PermitAll,未登录 401);手机号脱敏返回")
|
||||
public CommonResult<PlayerMeRespVO> getPlayerMe() {
|
||||
|
||||
@ -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;
|
||||
|
||||
}
|
||||
@ -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;
|
||||
|
||||
}
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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 事务上,杜绝「注册成功但事件从未入队」黑洞。
|
||||
* <p>
|
||||
* 多租户:本表为机制表,标 {@link TenantIgnore}(表不含 tenant_id 列)——投递器 @Scheduled 无租户上下文,
|
||||
* 若走租户拦截器会因 getRequiredTenantId() 抛错;忽略后可全表扫未投递行。
|
||||
*
|
||||
* @author 绘境AI
|
||||
*/
|
||||
@TableName("game_passport_register_outbox")
|
||||
@TenantIgnore // 机制表,@Scheduled 投递器无租户上下文,忽略多租户过滤(表无 tenant_id 列)
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class PassportRegisterOutboxDO extends BaseDO {
|
||||
|
||||
/** 投递状态:未投递(待投递器扫描发 MQ)。 */
|
||||
public static final int STATUS_PENDING = 0;
|
||||
/** 投递状态:已投递(投递器发 MQ 成功后置位)。 */
|
||||
public static final int STATUS_DELIVERED = 1;
|
||||
|
||||
/**
|
||||
* 编号
|
||||
*/
|
||||
@TableId
|
||||
private Long id;
|
||||
/**
|
||||
* 新注册玩家编号(game_player.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;
|
||||
|
||||
}
|
||||
@ -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;
|
||||
/**
|
||||
* 昵称(注册默认生成,可改)
|
||||
*/
|
||||
|
||||
@ -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<PassportRegisterOutboxDO> {
|
||||
|
||||
/**
|
||||
* 扫描未投递事件(status=0),按 id 升序取前 limit 条(投递器批量投递用)
|
||||
*
|
||||
* @param limit 单批上限(有界扫描,避免一次拉全表)
|
||||
* @return 未投递 outbox 列表
|
||||
*/
|
||||
default List<PassportRegisterOutboxDO> selectPendingList(int limit) {
|
||||
LambdaQueryWrapper<PassportRegisterOutboxDO> query = new LambdaQueryWrapper<PassportRegisterOutboxDO>()
|
||||
.eq(PassportRegisterOutboxDO::getStatus, PassportRegisterOutboxDO.STATUS_PENDING)
|
||||
.orderByAsc(PassportRegisterOutboxDO::getId)
|
||||
.last("LIMIT " + limit);
|
||||
return selectList(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* 条件更新置已投递(CAS:仅当当前仍为未投递态才更新,返回 1 表示本次投递赢得该行;0 表示已被其他投递器/重入抢先)
|
||||
*
|
||||
* @param id outbox 行编号
|
||||
* @param deliveredTime 投递成功时间
|
||||
* @return 实际更新行数(1=本次置位成功;0=已被抢先,避免重复 MQ)
|
||||
*/
|
||||
default int markDelivered(@Param("id") Long id, LocalDateTime deliveredTime) {
|
||||
LambdaUpdateWrapper<PassportRegisterOutboxDO> update = new LambdaUpdateWrapper<PassportRegisterOutboxDO>()
|
||||
.set(PassportRegisterOutboxDO::getStatus, PassportRegisterOutboxDO.STATUS_DELIVERED)
|
||||
.set(PassportRegisterOutboxDO::getDeliveredTime, deliveredTime)
|
||||
.eq(PassportRegisterOutboxDO::getId, id)
|
||||
.eq(PassportRegisterOutboxDO::getStatus, PassportRegisterOutboxDO.STATUS_PENDING);
|
||||
return update(null, update);
|
||||
}
|
||||
|
||||
}
|
||||
@ -29,6 +29,16 @@ public interface PlayerMapper extends BaseMapperX<PlayerDO> {
|
||||
return selectOne(PlayerDO::getMobile, mobile);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按用户名查玩家(命中 uk_username;内测用户名密码路登录主键查询,2026-07-07 WU1)
|
||||
*
|
||||
* @param username 用户名
|
||||
* @return 玩家 DO;不存在返回 null
|
||||
*/
|
||||
default PlayerDO selectByUsername(String username) {
|
||||
return selectOne(PlayerDO::getUsername, username);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创作者白名单置位(拍板4 A2 名单运营动作):按 userId 更新 creator_flag
|
||||
*
|
||||
|
||||
@ -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 邀请码注册}。
|
||||
* <p>
|
||||
* KEY 格式:passport_ip:{scene}:hour:{ip}:{yyyyMMddHH} / passport_ip:{scene}:day:{ip}:{yyyyMMdd}
|
||||
* VALUE:String 计数;TTL 在首次自增时设置(日桶 1 天、小时桶 1 小时),到期自动清。
|
||||
* <p>
|
||||
* ⚠ 一次性重置(§3.3 如实记账):上线本改动那一刻,旧前缀 passport_sms_ip_* 的存量计数桶全部失效,
|
||||
* 等于对已在计数的 IP 一次性重置限流窗口;桶 TTL 有界、只重置一次,内测无害,观测同步改名即可。
|
||||
* <p>
|
||||
* <b>频控后端故障姿态(§3.3 定死):fail-open + WARN 告警</b>。Redis 抖动/超时时捕获异常、记一条含 IP+scene 的
|
||||
* 告警日志、返回 0(视为未超限放行)——内测阶段认证可用性优先于把防刷做到严丝合缝,绝不让 Redis 单点静默拖垮登录;
|
||||
* 代价(Redis 挂时防刷短暂失效)记账接受,Redis 恢复后限流自动复位。
|
||||
*
|
||||
* @author 绘境AI
|
||||
*/
|
||||
@Slf4j
|
||||
@Repository
|
||||
public class PassportSmsIpCounterRedisDAO {
|
||||
|
||||
/** 发码场景(send-sms-code 回填此值,与原行为计数语义等价)。 */
|
||||
public static final String SCENE_SMS = "sms";
|
||||
/** 用户名密码注册场景。 */
|
||||
public static final String SCENE_REGISTER = "register";
|
||||
/** 登录场景(sms-login 回填 + password-login 共用)。 */
|
||||
public static final String SCENE_LOGIN = "login";
|
||||
/** 邀请码注册场景。 */
|
||||
public static final String SCENE_INVITE = "invite";
|
||||
|
||||
/**
|
||||
* 日维度计数 KEY 前缀。
|
||||
* 日维度计数 KEY 模板:passport_ip:{scene}:day:{ip}:{yyyyMMdd}。
|
||||
*/
|
||||
private static final String KEY_DAY = "passport_sms_ip_day:%s:%s";
|
||||
private static final String KEY_DAY = "passport_ip:%s:day:%s:%s";
|
||||
/**
|
||||
* 小时维度计数 KEY 前缀。
|
||||
* 小时维度计数 KEY 模板:passport_ip:{scene}:hour:{ip}:{yyyyMMddHH}。
|
||||
*/
|
||||
private static final String KEY_HOUR = "passport_sms_ip_hour:%s:%s";
|
||||
private static final String KEY_HOUR = "passport_ip:%s:hour:%s:%s";
|
||||
|
||||
private static final DateTimeFormatter DAY_FMT = DateTimeFormatter.ofPattern("yyyyMMdd");
|
||||
private static final DateTimeFormatter HOUR_FMT = DateTimeFormatter.ofPattern("yyyyMMddHH");
|
||||
@ -38,35 +58,48 @@ public class PassportSmsIpCounterRedisDAO {
|
||||
private StringRedisTemplate stringRedisTemplate;
|
||||
|
||||
/**
|
||||
* 自增当日计数并返回自增后的值(首次自增时设 1 天 TTL)
|
||||
* 自增指定场景当日计数并返回自增后的值(首次自增时设 1 天 TTL)
|
||||
*
|
||||
* @param ip 请求 IP
|
||||
* @return 自增后的当日计数
|
||||
* @param scene 场景({@link #SCENE_SMS} 等)
|
||||
* @param ip 请求 IP
|
||||
* @return 自增后的当日计数;Redis 异常时 fail-open 返回 0(放行)
|
||||
*/
|
||||
public long incrDayAndGet(String ip) {
|
||||
String key = String.format(KEY_DAY, ip, LocalDate.now().format(DAY_FMT));
|
||||
Long val = stringRedisTemplate.opsForValue().increment(key);
|
||||
if (val != null && val == 1L) {
|
||||
// 首次计数:设过期,避免 key 永久堆积(日桶 1 天)
|
||||
stringRedisTemplate.expire(key, 1, TimeUnit.DAYS);
|
||||
public long incrDayAndGet(String scene, String ip) {
|
||||
String key = String.format(KEY_DAY, scene, ip, LocalDate.now().format(DAY_FMT));
|
||||
try {
|
||||
Long val = stringRedisTemplate.opsForValue().increment(key);
|
||||
if (val != null && val == 1L) {
|
||||
// 首次计数:设过期,避免 key 永久堆积(日桶 1 天)
|
||||
stringRedisTemplate.expire(key, 1, TimeUnit.DAYS);
|
||||
}
|
||||
return val == null ? 0L : val;
|
||||
} catch (Exception e) {
|
||||
// 频控后端故障 fail-open(§3.3):记含 IP+scene 的告警、放行本次,绝不让 Redis 单点拖垮认证
|
||||
log.warn("[incrDayAndGet] ⚠ 频控 Redis 异常,fail-open 放行(防刷本次失效,记账接受)scene={}, ip={}", scene, ip, e);
|
||||
return 0L;
|
||||
}
|
||||
return val == null ? 0L : val;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自增当前小时计数并返回自增后的值(首次自增时设 1 小时 TTL)
|
||||
* 自增指定场景当前小时计数并返回自增后的值(首次自增时设 1 小时 TTL)
|
||||
*
|
||||
* @param ip 请求 IP
|
||||
* @return 自增后的当前小时计数
|
||||
* @param scene 场景
|
||||
* @param ip 请求 IP
|
||||
* @return 自增后的当前小时计数;Redis 异常时 fail-open 返回 0(放行)
|
||||
*/
|
||||
public long incrHourAndGet(String ip) {
|
||||
String key = String.format(KEY_HOUR, ip, LocalDateTime.now().format(HOUR_FMT));
|
||||
Long val = stringRedisTemplate.opsForValue().increment(key);
|
||||
if (val != null && val == 1L) {
|
||||
// 首次计数:设过期(小时桶 1 小时)
|
||||
stringRedisTemplate.expire(key, 1, TimeUnit.HOURS);
|
||||
public long incrHourAndGet(String scene, String ip) {
|
||||
String key = String.format(KEY_HOUR, scene, ip, LocalDateTime.now().format(HOUR_FMT));
|
||||
try {
|
||||
Long val = stringRedisTemplate.opsForValue().increment(key);
|
||||
if (val != null && val == 1L) {
|
||||
// 首次计数:设过期(小时桶 1 小时)
|
||||
stringRedisTemplate.expire(key, 1, TimeUnit.HOURS);
|
||||
}
|
||||
return val == null ? 0L : val;
|
||||
} catch (Exception e) {
|
||||
log.warn("[incrHourAndGet] ⚠ 频控 Redis 异常,fail-open 放行(防刷本次失效,记账接受)scene={}, ip={}", scene, ip, e);
|
||||
return 0L;
|
||||
}
|
||||
return val == null ? 0L : val;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
}
|
||||
@ -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 抖动/超时时该行保留未投递态下轮重投。
|
||||
* <p>
|
||||
* 外部交互纪律(创始人铁律):syncSend 带发送超时;发送失败/非 SEND_OK <b>不置已投递</b>(下轮重投,有界批扫兜底);
|
||||
* RocketMQTemplate 经 {@link ObjectProvider} 取——name-server 未装配(如部分本地/测试 profile)时优雅缺席、
|
||||
* 本轮跳过并 WARN,绝不因 MQ 不可达抛穿定时线程。幂等由消费侧据 userId 去重(至少一次必带重复投递)。
|
||||
*
|
||||
* @author 绘境AI
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class PassportRegisterOutboxDeliverer {
|
||||
|
||||
/** 「注册成功」事件主题(WU2 new-api 开户充值消费端订阅)。 */
|
||||
public static final String TOPIC = "passport-register";
|
||||
|
||||
/** 发送超时(毫秒)——外部交互必设超时,避免定时线程被 MQ 抖动无限拖住。 */
|
||||
private static final long SEND_TIMEOUT_MS = 3000L;
|
||||
|
||||
/** 单轮扫描上限(有界批扫,避免一次拉全表)。 */
|
||||
private static final int BATCH_LIMIT = 200;
|
||||
|
||||
@Resource
|
||||
private PassportRegisterOutboxMapper outboxMapper;
|
||||
|
||||
/**
|
||||
* RocketMQ 模板(rocketmq-spring 自动装配;ObjectProvider 兜底 name-server 未配时缺席,不阻断上下文/定时线程)。
|
||||
*/
|
||||
@Resource
|
||||
private ObjectProvider<RocketMQTemplate> rocketMQTemplateProvider;
|
||||
|
||||
/**
|
||||
* 周期扫描未投递 outbox 并投递(fixedDelay 15s:上轮结束后再隔 15s 起下轮,避免堆叠)。
|
||||
* initialDelay 30s 让应用先启动完成再首扫。
|
||||
*/
|
||||
@Scheduled(fixedDelayString = "${wanxiang.passport.register-outbox.deliver-interval-ms:15000}", initialDelay = 30_000L)
|
||||
public void deliverPendingBatch() {
|
||||
RocketMQTemplate rocketMQTemplate = rocketMQTemplateProvider.getIfAvailable();
|
||||
if (rocketMQTemplate == null) {
|
||||
// name-server 未装配(部分本地/测试 profile):本轮跳过,不阻断——待 MQ 就绪后下轮自然投出(至少一次不丢)
|
||||
log.warn("[passport-outbox] RocketMQTemplate 缺席(rocketmq name-server 未装配),本轮跳过投递,outbox 保留未投递态待后续");
|
||||
return;
|
||||
}
|
||||
List<PassportRegisterOutboxDO> pending = outboxMapper.selectPendingList(BATCH_LIMIT);
|
||||
if (pending.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
int ok = 0;
|
||||
for (PassportRegisterOutboxDO row : pending) {
|
||||
if (deliverOne(rocketMQTemplate, row)) {
|
||||
ok++;
|
||||
}
|
||||
}
|
||||
log.info("[passport-outbox] 本轮投递完成 total={}, delivered={}", pending.size(), ok);
|
||||
}
|
||||
|
||||
/**
|
||||
* 投递单条:syncSend 成功且 SEND_OK → CAS 置已投递;否则保留未投递态下轮重投。
|
||||
*
|
||||
* @return 是否本次投递并置位成功
|
||||
*/
|
||||
private boolean deliverOne(RocketMQTemplate rocketMQTemplate, PassportRegisterOutboxDO row) {
|
||||
try {
|
||||
PassportRegisterMessage payload = new PassportRegisterMessage();
|
||||
payload.setUserId(row.getUserId());
|
||||
payload.setRegisterChannel(row.getRegisterChannel());
|
||||
payload.setAnonId(row.getAnonId());
|
||||
payload.setIdempotentKey(row.getIdempotentKey());
|
||||
// keys 置幂等键,便于全链路排障对账(消费侧幂等真身同为 idempotentKey/userId)
|
||||
Message<PassportRegisterMessage> message = MessageBuilder.withPayload(payload)
|
||||
.setHeader(RocketMQHeaders.KEYS, row.getIdempotentKey())
|
||||
.build();
|
||||
|
||||
SendResult result = rocketMQTemplate.syncSend(TOPIC, message, SEND_TIMEOUT_MS);
|
||||
boolean sendOk = result != null && result.getSendStatus() == SendStatus.SEND_OK;
|
||||
if (!sendOk) {
|
||||
// 非 SEND_OK(刷盘/主从超时等):不置位,保留未投递态下轮重投
|
||||
log.warn("[passport-outbox] 投递非 SEND_OK,保留未投递态待重投 outboxId={}, userId={}, status={}",
|
||||
row.getId(), row.getUserId(), result == null ? null : result.getSendStatus());
|
||||
return false;
|
||||
}
|
||||
// CAS 置已投递(防并发/重入双投):affected=0 说明已被抢先,本次不重复计
|
||||
int affected = outboxMapper.markDelivered(row.getId(), LocalDateTime.now());
|
||||
if (affected == 0) {
|
||||
log.warn("[passport-outbox] 置已投递 CAS 未命中(已被抢先),outboxId={}, userId={}", row.getId(), row.getUserId());
|
||||
return false;
|
||||
}
|
||||
log.info("[passport-outbox] 注册成功事件已投递 topic={}, outboxId={}, userId={}, channel={}, msgId={}",
|
||||
TOPIC, row.getId(), row.getUserId(), row.getRegisterChannel(), result.getMsgId());
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
// 外部交互错误路径必须留痕:MQ 抖动/超时→不外抛(不拖垮整批),保留未投递态下轮重投(有界重试)
|
||||
log.warn("[passport-outbox] 投递失败(不外抛,保留未投递态待重投)outboxId={}, userId={}", row.getId(), row.getUserId(), e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -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 端点,登录态);手机号脱敏返回
|
||||
*
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -0,0 +1,73 @@
|
||||
package com.wanxiang.huijing.module.system.dal.redis.passport;
|
||||
|
||||
import com.wanxiang.huijing.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.springframework.data.redis.RedisConnectionFailureException;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.ValueOperations;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* {@link PassportSmsIpCounterRedisDAO} 单元测试(纯 Mockito,不依赖真实 Redis)
|
||||
*
|
||||
* 重点验证 §3.3 定死的「频控后端故障 fail-open」姿态:Redis 抖动/超时时不外抛、返回 0(放行),
|
||||
* 绝不让 Redis 单点静默拖垮认证;正常路径返回自增值并对首次计数设 TTL。
|
||||
*
|
||||
* @author 绘境AI
|
||||
*/
|
||||
class PassportSmsIpCounterRedisDAOTest extends BaseMockitoUnitTest {
|
||||
|
||||
@InjectMocks
|
||||
private PassportSmsIpCounterRedisDAO counterDAO;
|
||||
|
||||
@Mock
|
||||
private StringRedisTemplate stringRedisTemplate;
|
||||
@Mock
|
||||
private ValueOperations<String, String> valueOperations;
|
||||
|
||||
@Test
|
||||
@DisplayName("小时计数:Redis 异常 → fail-open 返回 0(放行,不外抛)")
|
||||
void testIncrHourAndGet_redisFailOpen() {
|
||||
when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations);
|
||||
// 模拟 Redis 抖动/超时:increment 抛连接异常
|
||||
when(valueOperations.increment(anyString()))
|
||||
.thenThrow(new RedisConnectionFailureException("redis down"));
|
||||
|
||||
long[] result = new long[1];
|
||||
assertDoesNotThrow(() -> result[0] = counterDAO.incrHourAndGet(
|
||||
PassportSmsIpCounterRedisDAO.SCENE_LOGIN, "1.2.3.4"));
|
||||
// fail-open:返回 0,服务层据此视为未超限放行
|
||||
assertEquals(0L, result[0]);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("日计数:Redis 异常 → fail-open 返回 0(放行,不外抛)")
|
||||
void testIncrDayAndGet_redisFailOpen() {
|
||||
when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations);
|
||||
when(valueOperations.increment(anyString()))
|
||||
.thenThrow(new RedisConnectionFailureException("redis down"));
|
||||
|
||||
long[] result = new long[1];
|
||||
assertDoesNotThrow(() -> result[0] = counterDAO.incrDayAndGet(
|
||||
PassportSmsIpCounterRedisDAO.SCENE_REGISTER, "1.2.3.4"));
|
||||
assertEquals(0L, result[0]);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("正常路径:increment 返回自增值,DAO 原样透传")
|
||||
void testIncrHourAndGet_normal() {
|
||||
when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations);
|
||||
when(valueOperations.increment(anyString())).thenReturn(5L); // 非首次(不设 TTL)
|
||||
|
||||
long count = counterDAO.incrHourAndGet(PassportSmsIpCounterRedisDAO.SCENE_SMS, "1.2.3.4");
|
||||
assertEquals(5L, count);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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<PlayerDO> pc = ArgumentCaptor.forClass(PlayerDO.class);
|
||||
verify(playerMapper).insert(pc.capture());
|
||||
assertEquals("password", pc.getValue().getRegisterChannel());
|
||||
assertEquals("seedalice", pc.getValue().getUsername());
|
||||
assertEquals("$2a$10$encodedhash", pc.getValue().getPassword());
|
||||
assertNull(pc.getValue().getMobile());
|
||||
assertEquals("seedalice", pc.getValue().getNickname()); // 无 nickname → 用户名路取 username
|
||||
assertEquals("anon-p", pc.getValue().getFirstAnonId());
|
||||
assertEquals("0", pc.getValue().getCreator());
|
||||
// §3.6:注册成功事件与建号原子写 outbox(幂等键 register:{userId},未投递态)
|
||||
ArgumentCaptor<PassportRegisterOutboxDO> oc = ArgumentCaptor.forClass(PassportRegisterOutboxDO.class);
|
||||
verify(registerOutboxMapper).insert(oc.capture());
|
||||
assertEquals(2048L, oc.getValue().getUserId());
|
||||
assertEquals("password", oc.getValue().getRegisterChannel());
|
||||
assertEquals("register:2048", oc.getValue().getIdempotentKey());
|
||||
assertEquals(PassportRegisterOutboxDO.STATUS_PENDING, oc.getValue().getStatus());
|
||||
// 发真 token(与短信路一字不差)
|
||||
assertEquals(2048L, resp.getUserId());
|
||||
assertEquals("happy-token", resp.getAccessToken());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("用户名注册:并发撞 uk_username → 报 PLAYER_USERNAME_ALREADY_REGISTERED(不转登录、不写 outbox)")
|
||||
void testPasswordRegister_usernameAlreadyRegistered() {
|
||||
PasswordRegisterReqVO reqVO = new PasswordRegisterReqVO();
|
||||
reqVO.setUsername("seedalice");
|
||||
reqVO.setPassword("secret6");
|
||||
when(passwordEncoder.encode("secret6")).thenReturn("$2a$10$encodedhash");
|
||||
// 模拟 uk_username 并发冲突
|
||||
doThrow(new org.springframework.dao.DuplicateKeyException("uk_username"))
|
||||
.when(playerMapper).insert(any(PlayerDO.class));
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> passportService.passwordRegister(reqVO, "9.9.9.9"));
|
||||
assertEquals(PLAYER_USERNAME_ALREADY_REGISTERED.getCode(), ex.getCode());
|
||||
// 注册态撞名不静默转登录:不回查手机号、不发 token、不写 outbox
|
||||
verify(playerMapper, never()).selectByMobile(any());
|
||||
verify(oauth2TokenService, never()).createAccessToken(anyLong(), any(), any(), any());
|
||||
verify(registerOutboxMapper, never()).insert(any(PassportRegisterOutboxDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("用户名注册:总开关关闭 → 抛 PASSWORD_AUTH_DISABLED(信任边界=后端,连 IP 都不计)")
|
||||
void testPasswordRegister_authDisabled() {
|
||||
ReflectionTestUtils.setField(passportService, "passwordAuthEnabled", false);
|
||||
PasswordRegisterReqVO reqVO = new PasswordRegisterReqVO();
|
||||
reqVO.setUsername("seedalice");
|
||||
reqVO.setPassword("secret6");
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> passportService.passwordRegister(reqVO, "9.9.9.9"));
|
||||
assertEquals(PASSWORD_AUTH_DISABLED.getCode(), ex.getCode());
|
||||
verify(playerMapper, never()).insert(any(PlayerDO.class));
|
||||
verify(smsIpCounterRedisDAO, never()).incrHourAndGet(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("用户名注册:同 IP 越注册档小时阈值 → 抛 429 且不建号")
|
||||
void testPasswordRegister_ipLimitExceeded() {
|
||||
PasswordRegisterReqVO reqVO = new PasswordRegisterReqVO();
|
||||
reqVO.setUsername("seedalice");
|
||||
reqVO.setPassword("secret6");
|
||||
when(smsIpCounterRedisDAO.incrHourAndGet(SCENE_REGISTER, "9.9.9.9")).thenReturn(21L); // > 20
|
||||
when(smsIpCounterRedisDAO.incrDayAndGet(SCENE_REGISTER, "9.9.9.9")).thenReturn(21L);
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> passportService.passwordRegister(reqVO, "9.9.9.9"));
|
||||
assertEquals(TOO_MANY_REQUESTS.getCode(), ex.getCode());
|
||||
verify(playerMapper, never()).insert(any(PlayerDO.class));
|
||||
verify(registerOutboxMapper, never()).insert(any(PassportRegisterOutboxDO.class));
|
||||
}
|
||||
|
||||
// ============================== 内测用户名密码登录(WU1 §3.2 防枚举)==============================
|
||||
|
||||
@Test
|
||||
@DisplayName("用户名登录:用户名+密码正确 → 刷新登录时间 + 发 token")
|
||||
void testPasswordLogin_success() {
|
||||
PasswordLoginReqVO reqVO = new PasswordLoginReqVO();
|
||||
reqVO.setUsername("seedalice");
|
||||
reqVO.setPassword("secret6");
|
||||
PlayerDO exist = player(2048L, 0, 0);
|
||||
exist.setPassword("$2a$10$stored");
|
||||
when(playerMapper.selectByUsername("seedalice")).thenReturn(exist);
|
||||
when(passwordEncoder.matches("secret6", "$2a$10$stored")).thenReturn(true);
|
||||
stubToken(2048L);
|
||||
|
||||
LoginRespVO resp = passportService.passwordLogin(reqVO, "9.9.9.9");
|
||||
|
||||
verify(playerMapper).updateLoginDate(eq(2048L), any(LocalDateTime.class));
|
||||
assertEquals(2048L, resp.getUserId());
|
||||
assertEquals("happy-token", resp.getAccessToken());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("用户名登录·防枚举:查无用户名 → 跑固定假 hash 对齐耗时 + 统一 PLAYER_USERNAME_OR_PASSWORD_ERROR")
|
||||
void testPasswordLogin_usernameNotFound_antiEnumeration() {
|
||||
PasswordLoginReqVO reqVO = new PasswordLoginReqVO();
|
||||
reqVO.setUsername("ghost");
|
||||
reqVO.setPassword("secret6");
|
||||
when(playerMapper.selectByUsername("ghost")).thenReturn(null);
|
||||
when(passwordEncoder.encode(anyString())).thenReturn("$2a$10$fakehash"); // getFakeMatchHash 惰性编码
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> passportService.passwordLogin(reqVO, "9.9.9.9"));
|
||||
assertEquals(PLAYER_USERNAME_OR_PASSWORD_ERROR.getCode(), ex.getCode());
|
||||
// 时序对齐:查无用户名时也对固定假 hash 跑一次 matches(消除存在性 oracle)
|
||||
verify(passwordEncoder).matches("secret6", "$2a$10$fakehash");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("用户名登录·防枚举:密码错误与查无用户名返回同一错误码(不可区分)")
|
||||
void testPasswordLogin_wrongPassword_sameErrorCode() {
|
||||
PasswordLoginReqVO reqVO = new PasswordLoginReqVO();
|
||||
reqVO.setUsername("seedalice");
|
||||
reqVO.setPassword("wrongpwd");
|
||||
PlayerDO exist = player(2048L, 0, 0);
|
||||
exist.setPassword("$2a$10$stored");
|
||||
when(playerMapper.selectByUsername("seedalice")).thenReturn(exist);
|
||||
when(passwordEncoder.matches("wrongpwd", "$2a$10$stored")).thenReturn(false);
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> passportService.passwordLogin(reqVO, "9.9.9.9"));
|
||||
// 与「查无用户名」同码,不给区分(防枚举)
|
||||
assertEquals(PLAYER_USERNAME_OR_PASSWORD_ERROR.getCode(), ex.getCode());
|
||||
verify(oauth2TokenService, never()).createAccessToken(anyLong(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("用户名登录:账号被禁用(密码正确)→ 抛 PLAYER_IS_DISABLED")
|
||||
void testPasswordLogin_disabledRejected() {
|
||||
PasswordLoginReqVO reqVO = new PasswordLoginReqVO();
|
||||
reqVO.setUsername("seedalice");
|
||||
reqVO.setPassword("secret6");
|
||||
PlayerDO exist = player(2048L, 1, 0); // status=1 禁用
|
||||
exist.setPassword("$2a$10$stored");
|
||||
when(playerMapper.selectByUsername("seedalice")).thenReturn(exist);
|
||||
when(passwordEncoder.matches("secret6", "$2a$10$stored")).thenReturn(true);
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> passportService.passwordLogin(reqVO, "9.9.9.9"));
|
||||
assertEquals(PLAYER_IS_DISABLED.getCode(), ex.getCode());
|
||||
verify(oauth2TokenService, never()).createAccessToken(anyLong(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("用户名登录:总开关关闭 → 抛 PASSWORD_AUTH_DISABLED(连查库都不做)")
|
||||
void testPasswordLogin_authDisabled() {
|
||||
ReflectionTestUtils.setField(passportService, "passwordAuthEnabled", false);
|
||||
PasswordLoginReqVO reqVO = new PasswordLoginReqVO();
|
||||
reqVO.setUsername("seedalice");
|
||||
reqVO.setPassword("secret6");
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> passportService.passwordLogin(reqVO, "9.9.9.9"));
|
||||
assertEquals(PASSWORD_AUTH_DISABLED.getCode(), ex.getCode());
|
||||
verify(playerMapper, never()).selectByUsername(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("用户名登录:同 IP 越登录档小时阈值 → 抛 429 且不查库")
|
||||
void testPasswordLogin_ipLimitExceeded() {
|
||||
PasswordLoginReqVO reqVO = new PasswordLoginReqVO();
|
||||
reqVO.setUsername("seedalice");
|
||||
reqVO.setPassword("secret6");
|
||||
when(smsIpCounterRedisDAO.incrHourAndGet(SCENE_LOGIN, "9.9.9.9")).thenReturn(31L); // > 30
|
||||
when(smsIpCounterRedisDAO.incrDayAndGet(SCENE_LOGIN, "9.9.9.9")).thenReturn(31L);
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> passportService.passwordLogin(reqVO, "9.9.9.9"));
|
||||
assertEquals(TOO_MANY_REQUESTS.getCode(), ex.getCode());
|
||||
verify(playerMapper, never()).selectByUsername(any());
|
||||
}
|
||||
|
||||
// ============================== /me 空安全(WU1 §3.2 用户名用户 mobile=null)==============================
|
||||
|
||||
@Test
|
||||
@DisplayName("/me:用户名用户 mobile 为 null → 脱敏短路回空串(不 NPE)+ 返回 username")
|
||||
void testGetPlayerMe_usernameUserNullMobileSafe() {
|
||||
PlayerDO player = new PlayerDO();
|
||||
player.setId(2048L);
|
||||
player.setMobile(null); // 用户名用户无手机号
|
||||
player.setUsername("seedalice");
|
||||
player.setNickname("seedalice");
|
||||
player.setCreatorFlag(0);
|
||||
when(playerMapper.selectById(2048L)).thenReturn(player);
|
||||
|
||||
var resp = assertDoesNotThrow(() -> passportService.getPlayerMe(2048L));
|
||||
assertEquals("", resp.getMobile()); // null 短路回空串,不把 null 塞进脱敏函数
|
||||
assertEquals("seedalice", resp.getUsername());
|
||||
}
|
||||
|
||||
// ============================== 测试夹具 ==============================
|
||||
|
||||
/** stub 发 token 返回固定 access/refresh/过期时间 */
|
||||
|
||||
@ -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 每日上限(推断初值)
|
||||
|
||||
@ -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)';
|
||||
@ -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_<gamePlayerId>(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_<gamePlayerId>;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)';
|
||||
421
game-runtime/tools/newapi_pool_provision.py
Normal file
421
game-runtime/tools/newapi_pool_provision.py
Normal file
@ -0,0 +1,421 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""newapi_pool_provision.py -- 内测 new-api per-user ¥100 额度池离线预置脚本(WU2 · 接线点 B)
|
||||
|
||||
权威设计:docs/agent-specs/2026-07-07-内测-WU2-newapi额度接入-设计.md §3.3(离线 ops 预置脚本)
|
||||
+ §3.5(池表 newapi_quota_pool 契约)。
|
||||
|
||||
它做什么
|
||||
--------
|
||||
在 mini-infra 上一次性预建 N 个专属 (new-api user + token + ¥100 额度) 账户,
|
||||
把每个条目 emit 成可导入 game-cloud 池表 newapi_quota_pool 的 JSON + SQL,供 WU2 落地后灌数。
|
||||
provision 全部离线做(S0 实测坐实:new-api admin 令牌不能替他人建 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 "<empty>"
|
||||
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 <token> + New-Api-User: <uid>(缺后者即 401)。
|
||||
"""
|
||||
url = NEWAPI_BASE.rstrip("/") + path
|
||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"New-Api-User": str(newapi_user),
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
last_err: Exception | None = None
|
||||
for attempt in range(HTTP_RETRIES + 1):
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with _OPENER.open(req, timeout=HTTP_READ_TIMEOUT) as resp:
|
||||
raw = resp.read().decode("utf-8")
|
||||
payload = json.loads(raw) if raw else {}
|
||||
# new-api 标准返回体 {success,message,data};success=false 视为业务错
|
||||
if isinstance(payload, dict) and payload.get("success") is False:
|
||||
raise RuntimeError(f"{method} {path} 业务失败: {payload.get('message')}")
|
||||
return payload
|
||||
except urllib.error.HTTPError as e:
|
||||
body_txt = e.read().decode("utf-8", "ignore")[:200]
|
||||
last_err = RuntimeError(f"{method} {path} HTTP {e.code}: {body_txt}")
|
||||
if 500 <= e.code < 600 and attempt < HTTP_RETRIES:
|
||||
time.sleep(0.5 * (2 ** attempt))
|
||||
log.warning("[http] %s %s 5xx 重试 %d/%d", method, path, attempt + 1, HTTP_RETRIES)
|
||||
continue
|
||||
raise last_err # 4xx 不重试
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
||||
last_err = RuntimeError(f"{method} {path} 网络错误: {e}")
|
||||
if attempt < HTTP_RETRIES:
|
||||
time.sleep(0.5 * (2 ** attempt))
|
||||
log.warning("[http] %s %s 网络错重试 %d/%d: %s", method, path, attempt + 1, HTTP_RETRIES, e)
|
||||
continue
|
||||
raise last_err
|
||||
raise last_err or RuntimeError("unreachable")
|
||||
|
||||
|
||||
# ─── ¥ ↔ quota 折算(口径与 cost.py 同源,读网关而非硬编码) ─────────────────
|
||||
def fetch_conversion() -> tuple[int, float]:
|
||||
"""GET /api/status 拿 quota_per_unit / usd_exchange_rate(无需鉴权)。缺 usd 用 7.3 默认。"""
|
||||
url = NEWAPI_BASE.rstrip("/") + "/api/status"
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
with _OPENER.open(req, timeout=HTTP_READ_TIMEOUT) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8")).get("data", {})
|
||||
qpu = int(data.get("quota_per_unit") or 500000)
|
||||
usd = data.get("usd_exchange_rate")
|
||||
usd = float(usd) if usd else 7.3 # DB options 未覆盖时默认 7.3
|
||||
return qpu, usd
|
||||
|
||||
|
||||
def compute_grant(yuan: float, qpu: int, usd: float) -> int:
|
||||
"""¥ → 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())
|
||||
@ -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<LoginResp> {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户名密码注册(建号即登录,@PermitAll 免登;受总开关 password-auth-enabled 管控,WU1 §3.2)。
|
||||
* POST /app-api/passport/password-register
|
||||
* @param dto 注册请求体(username + password + 可选 nickname/anonId)
|
||||
* @returns 真 OAuth2 token 与玩家身份摘要(与短信路 token 一字不差)
|
||||
*/
|
||||
export function passwordRegister(dto: PasswordRegisterReq): Promise<LoginResp> {
|
||||
return request<LoginResp>({
|
||||
url: '/app-api/passport/password-register',
|
||||
method: 'post',
|
||||
data: dto,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户名密码登录(@PermitAll 免登;防枚举:查无用户名与密码错误统一 1-002-090-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<LoginResp> {
|
||||
return request<LoginResp>({
|
||||
url: '/app-api/passport/password-login',
|
||||
method: 'post',
|
||||
data: dto,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前登录玩家信息(登录态必需;未登录返回 401,由 request.ts 401 拦截兜底)。
|
||||
* GET /app-api/passport/me
|
||||
|
||||
@ -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 ——
|
||||
|
||||
@ -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)——
|
||||
|
||||
@ -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<string>('')
|
||||
/** 短信验证码(sms Tab) */
|
||||
const code = ref<string>('')
|
||||
/** 邀请码(invite Tab) */
|
||||
const inviteCode = ref<string>('')
|
||||
/** 用户名(password Tab) */
|
||||
const username = ref<string>('')
|
||||
/** 密码(password Tab;不 trim,允许含空格,绝不落日志/遥测) */
|
||||
const password = ref<string>('')
|
||||
/** 用户名密码 Tab 子模式:'login'=登录(默认)/ 'register'=注册(建号即登录) */
|
||||
const passwordMode = ref<'login' | 'register'>('login')
|
||||
/** 隐私政策/用户协议勾选(占位;未勾选不可提交) */
|
||||
const agreed = ref<boolean>(false)
|
||||
|
||||
@ -60,6 +76,21 @@ const MOBILE_RE = /^1[3-9]\d{9}$/
|
||||
/** 手机号是否合法 */
|
||||
const mobileValid = computed(() => MOBILE_RE.test(mobile.value.trim()))
|
||||
|
||||
/** 用户名校验:与后端 PasswordReqVO 一致 ^[a-zA-Z0-9]{4,30}$(前端仅提示,信任边界在后端) */
|
||||
const USERNAME_RE = /^[a-zA-Z0-9]{4,30}$/
|
||||
/** 用户名是否合法 */
|
||||
const usernameValid = computed(() => USERNAME_RE.test(username.value.trim()))
|
||||
/**
|
||||
* 密码是否合法(与后端一致的长度预校验,仅提示):
|
||||
* - 注册:6-32 位(后端 @Length(6,32));
|
||||
* - 登录:仅非空(后端登录不重复长度校验,避免给出策略线索)。
|
||||
* 密码不 trim(前导/尾随空格可能是密码一部分)。
|
||||
*/
|
||||
const passwordValid = computed(() => {
|
||||
const len = password.value.length
|
||||
return passwordMode.value === 'register' ? len >= 6 && len <= 32 : len > 0
|
||||
})
|
||||
|
||||
/**
|
||||
* 解析回跳地址(?redirect=)。
|
||||
* 仅接受站内相对路径(以 / 开头且非 //),防开放重定向;缺省回 /feed。
|
||||
@ -78,11 +109,26 @@ const canSend = computed(() => mobileValid.value && !sending.value && countdown.
|
||||
|
||||
/** 提交按钮是否可点(按当前 Tab 校验必填项 + 已勾选协议 + 不在提交中) */
|
||||
const canSubmit = computed(() => {
|
||||
if (!agreed.value || submitting.value || !mobileValid.value) return false
|
||||
if (!agreed.value || submitting.value) return false
|
||||
if (activeTab.value === 'password') {
|
||||
// 用户名密码 Tab:不依赖手机号,按 username 正则 + 密码长度(子模式相关)预校验
|
||||
return usernameValid.value && passwordValid.value
|
||||
}
|
||||
// sms / invite 两 Tab 共用手机号校验
|
||||
if (!mobileValid.value) return false
|
||||
if (activeTab.value === 'sms') return code.value.trim().length > 0
|
||||
return inviteCode.value.trim().length > 0
|
||||
})
|
||||
|
||||
/** 提交按钮文案(按当前 Tab;用户名密码 Tab 再按登录/注册子模式二态) */
|
||||
const submitLabel = computed(() => {
|
||||
if (activeTab.value === 'sms') return t('login.submitSms')
|
||||
if (activeTab.value === 'invite') return t('login.submitInvite')
|
||||
return passwordMode.value === 'register'
|
||||
? t('login.submitPasswordRegister')
|
||||
: t('login.submitPasswordLogin')
|
||||
})
|
||||
|
||||
/** 启动 60s 发码倒计时 */
|
||||
function startCountdown(): void {
|
||||
countdown.value = 60
|
||||
@ -138,12 +184,21 @@ function afterLoginSuccess(resp: LoginResp): void {
|
||||
/**
|
||||
* 提交(按当前 Tab 分派):
|
||||
* - sms:smsLogin(已注册登录 / 未注册自动注册一体);
|
||||
* - invite:inviteRegister(手机号+邀请码受限激活)。
|
||||
* 两路均携带 anonId(身份衔接,可选);失败由拦截器 Toast 错误码,仅解锁。
|
||||
* - invite:inviteRegister(手机号+邀请码受限激活);
|
||||
* - password:登录子模式 passwordLogin / 注册子模式 passwordRegister(建号即登录)。
|
||||
* 三路均携带 anonId(身份衔接,可选);失败由拦截器 Toast 错误码(1-002-090/091),仅解锁。
|
||||
*/
|
||||
async function onSubmit(): Promise<void> {
|
||||
if (!canSubmit.value) {
|
||||
if (!agreed.value) showToast({ message: t('login.needAgree'), type: 'fail' })
|
||||
if (!agreed.value) {
|
||||
showToast({ message: t('login.needAgree'), type: 'fail' })
|
||||
return
|
||||
}
|
||||
// 用户名密码 Tab 的预校验提示(仅本地提示,后端仍会二次校验)
|
||||
if (activeTab.value === 'password') {
|
||||
if (!usernameValid.value) showToast({ message: t('login.invalidUsername'), type: 'fail' })
|
||||
else if (!passwordValid.value) showToast({ message: t('login.invalidPassword'), type: 'fail' })
|
||||
}
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
@ -152,16 +207,23 @@ async function onSubmit(): Promise<void> {
|
||||
let resp: LoginResp
|
||||
if (activeTab.value === 'sms') {
|
||||
resp = await smsLogin({ mobile: mobile.value.trim(), code: code.value.trim(), anonId })
|
||||
} else {
|
||||
} else if (activeTab.value === 'invite') {
|
||||
resp = await inviteRegister({
|
||||
mobile: mobile.value.trim(),
|
||||
inviteCode: inviteCode.value.trim(),
|
||||
anonId,
|
||||
})
|
||||
} else {
|
||||
// 用户名密码路:username 去空白后提交;password 原样(不 trim);子模式决定注册/登录端点
|
||||
const uname = username.value.trim()
|
||||
resp =
|
||||
passwordMode.value === 'register'
|
||||
? await passwordRegister({ username: uname, password: password.value, anonId })
|
||||
: await passwordLogin({ username: uname, password: password.value, anonId })
|
||||
}
|
||||
afterLoginSuccess(resp)
|
||||
} catch {
|
||||
// 错误码(验证码错误 / 邀请码无效·耗尽·过期 / 已注册等 1-002-090/091)已由拦截器 Toast;解锁停留本页
|
||||
// 错误码(验证码错误 / 邀请码无效·耗尽·过期 / 用户名已占用·用户名或密码错误 1-002-090/091)已由拦截器 Toast;解锁停留本页
|
||||
submitting.value = false
|
||||
}
|
||||
// 成功后 router.replace 跳转,本组件卸载,无需复位 submitting
|
||||
@ -176,7 +238,7 @@ async function onSubmit(): Promise<void> {
|
||||
<p class="login-sub">{{ t('login.subtitle') }}</p>
|
||||
</header>
|
||||
|
||||
<!-- Tab 切换:验证码登录 / 邀请码注册 -->
|
||||
<!-- Tab 切换:验证码登录 / 邀请码注册 / 用户名密码 -->
|
||||
<div class="login-tabs">
|
||||
<button
|
||||
class="login-tab"
|
||||
@ -194,13 +256,21 @@ async function onSubmit(): Promise<void> {
|
||||
>
|
||||
{{ t('login.tabInvite') }}
|
||||
</button>
|
||||
<button
|
||||
class="login-tab"
|
||||
:class="{ 'is-active': activeTab === 'password' }"
|
||||
type="button"
|
||||
@click="activeTab = 'password'"
|
||||
>
|
||||
{{ t('login.tabPassword') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 表单区 -->
|
||||
<section class="login-form">
|
||||
<!-- 手机号(两 Tab 共用)。保留原生 input:maxlength/inputmode/type 属输入校验 UX,
|
||||
与倒计时/校验逻辑相关,不替成 Field(Field 不透传 maxlength/inputmode),仅令牌化样式 + i18n 占位。 -->
|
||||
<div class="field">
|
||||
<!-- 手机号(sms/invite 两 Tab 共用;password Tab 无手机号,不渲染)。保留原生 input:
|
||||
maxlength/inputmode/type 属输入校验 UX,与倒计时/校验逻辑相关,不替成 Field,仅令牌化样式 + i18n 占位。 -->
|
||||
<div v-if="activeTab !== 'password'" class="field">
|
||||
<input
|
||||
v-model="mobile"
|
||||
class="field-input"
|
||||
@ -230,7 +300,7 @@ async function onSubmit(): Promise<void> {
|
||||
</template>
|
||||
|
||||
<!-- 邀请码注册:邀请码(受限激活期陌生玩家入口) -->
|
||||
<template v-else>
|
||||
<template v-else-if="activeTab === 'invite'">
|
||||
<div class="field">
|
||||
<input
|
||||
v-model="inviteCode"
|
||||
@ -243,6 +313,51 @@ async function onSubmit(): Promise<void> {
|
||||
<p class="login-hint">{{ t('login.inviteHint') }}</p>
|
||||
</template>
|
||||
|
||||
<!-- 用户名密码:子模式(登录/注册)切换 + 用户名 + 密码(内测种子用户,WU1 §3.5) -->
|
||||
<template v-else>
|
||||
<!-- 登录/注册子模式切换(注册走 password-register 建号即登录,登录走 password-login) -->
|
||||
<div class="pwd-mode">
|
||||
<button
|
||||
class="pwd-mode-btn"
|
||||
:class="{ 'is-active': passwordMode === 'login' }"
|
||||
type="button"
|
||||
@click="passwordMode = 'login'"
|
||||
>
|
||||
{{ t('login.modeLogin') }}
|
||||
</button>
|
||||
<button
|
||||
class="pwd-mode-btn"
|
||||
:class="{ 'is-active': passwordMode === 'register' }"
|
||||
type="button"
|
||||
@click="passwordMode = 'register'"
|
||||
>
|
||||
{{ t('login.modeRegister') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="field">
|
||||
<input
|
||||
v-model="username"
|
||||
class="field-input"
|
||||
type="text"
|
||||
inputmode="text"
|
||||
autocomplete="username"
|
||||
maxlength="30"
|
||||
:placeholder="t('login.usernamePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<input
|
||||
v-model="password"
|
||||
class="field-input"
|
||||
type="password"
|
||||
:autocomplete="passwordMode === 'register' ? 'new-password' : 'current-password'"
|
||||
maxlength="32"
|
||||
:placeholder="t('login.passwordPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<p class="login-hint">{{ t('login.passwordHint') }}</p>
|
||||
</template>
|
||||
|
||||
<!-- 隐私政策/用户协议占位勾选(未勾选不可提交)。协议/隐私链接名 + 占位说明均抽 key。 -->
|
||||
<label class="agree">
|
||||
<input v-model="agreed" type="checkbox" class="agree-box" />
|
||||
@ -255,9 +370,9 @@ async function onSubmit(): Promise<void> {
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<!-- 提交(按当前 Tab 文案二态) -->
|
||||
<!-- 提交(按当前 Tab / password 子模式文案多态) -->
|
||||
<AppButton block size="large" :loading="submitting" :disabled="!canSubmit" @click="onSubmit">
|
||||
{{ activeTab === 'sms' ? t('login.submitSms') : t('login.submitInvite') }}
|
||||
{{ submitLabel }}
|
||||
</AppButton>
|
||||
</section>
|
||||
</div>
|
||||
@ -365,6 +480,31 @@ async function onSubmit(): Promise<void> {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* —— 用户名密码:登录/注册子模式切换(比主 Tab 更轻,作段控) —— */
|
||||
.pwd-mode {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 3px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.pwd-mode-btn {
|
||||
flex: 1;
|
||||
padding: 8px 0;
|
||||
border: 0;
|
||||
border-radius: calc(var(--radius) - 3px);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.pwd-mode-btn.is-active {
|
||||
background: color-mix(in srgb, var(--accent) 16%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* —— 提示文案 —— */
|
||||
.login-hint {
|
||||
margin: -6px 2px 0;
|
||||
|
||||
@ -54,12 +54,13 @@ const meLoading = ref(true)
|
||||
|
||||
/**
|
||||
* 用 userStore 现有字段构造兜底账号信息(getMe 失败/mock 模式时用)。
|
||||
* 手机号 store 无持久化,兜底留空(passport.me 才回脱敏手机号)。
|
||||
* 手机号/用户名 store 均无持久化,兜底留空(passport.me 才回脱敏手机号与用户名)。
|
||||
*/
|
||||
function fallbackMe(): PlayerMeResp {
|
||||
return {
|
||||
userId: Number(userStore.userId) || 0,
|
||||
mobile: '',
|
||||
username: '',
|
||||
nickname: userStore.nickname,
|
||||
avatar: userStore.avatar,
|
||||
creatorFlag: userStore.creatorFlag,
|
||||
|
||||
@ -43,18 +43,96 @@ for _k in ("NO_PROXY", "no_proxy"):
|
||||
|
||||
import openai # noqa: E402 —— 必须在代理旁路之后导入
|
||||
|
||||
# WU2 §3.7:回落全局 env key 时打 WARN;但 get_api_key 每次 chat 都会被调,故一进程只 WARN 一次防刷屏。
|
||||
_ENV_FALLBACK_WARNED = False
|
||||
|
||||
def get_api_key():
|
||||
"""读 NEWAPI_KEY(缺失即抛,密钥不入库、复现前先设 .env 或 export)。"""
|
||||
|
||||
class NewapiQuotaExhaustedError(RuntimeError):
|
||||
"""new-api 网关按 per-user token 判额度耗尽(WU2 §3.9)。
|
||||
|
||||
据 one-api 惯例:HTTP 402/403 或错误体含额度耗尽关键词。属**不可重试**干净失败——worker 据此回调
|
||||
failure_reason=quota_exhausted(而非含糊 llm_error);凭据失效(401)不塌成本错误、维持可重试 llm_error。
|
||||
【阶段2 待验】402/403 与确切错误体 message S0 只坐实管理面、未测 /v1/chat/completions 耗尽错误体,
|
||||
确切匹配待 S4 真机耗尽/凭据失效测试;现按 one-api 惯例初值分辨(状态码优先、关键词兜底)。
|
||||
"""
|
||||
|
||||
|
||||
def _mask_token(tok) -> str:
|
||||
"""token 日志脱敏(WU2 §3.8):前后各留 4 位、中间省略;过短/空则整体隐藏。绝不整条打凭据。"""
|
||||
if not tok:
|
||||
return "<none>"
|
||||
s = str(tok)
|
||||
return "****" if len(s) <= 8 else f"{s[:4]}…{s[-4:]}"
|
||||
|
||||
|
||||
def _newapi_error_status_text(exc):
|
||||
"""从 new-api 调用异常尽力抽 (http_status, 错误体文本);抽不到 status 返 None,文本恒兜底 str(exc)。
|
||||
|
||||
兼容 openai SDK 的 APIStatusError(.status_code / .response.status_code / .body / .message)与被上层
|
||||
框架包装后的普通异常(只剩 str)。供 classify_newapi_failure_reason 分辨。
|
||||
"""
|
||||
status = getattr(exc, "status_code", None)
|
||||
if status is None:
|
||||
status = getattr(exc, "code", None)
|
||||
if status is None:
|
||||
resp = getattr(exc, "response", None)
|
||||
status = getattr(resp, "status_code", None) if resp is not None else None
|
||||
parts = []
|
||||
for attr in ("message", "body"):
|
||||
v = getattr(exc, attr, None)
|
||||
if v:
|
||||
parts.append(str(v))
|
||||
parts.append(str(exc))
|
||||
return status, " ".join(parts)
|
||||
|
||||
|
||||
def classify_newapi_failure_reason(status=None, text: str = "") -> str:
|
||||
"""据 one-api 惯例把网关非 2xx 分辨成失败因(WU2 §3.9)。
|
||||
|
||||
返回 'quota_exhausted'(额度耗尽,不可重试)或 'llm_error'(凭据失效 401/其他,可重试、不塌成额度耗尽)。
|
||||
判据:HTTP 402/403 → 额度耗尽;401 → 凭据失效;状态码取不到时按错误体关键词兜底。
|
||||
【阶段2 待验】确切 message/code 需 S4 真机耗尽/凭据失效测试坐实;此处状态码优先、关键词兜底为初值。
|
||||
"""
|
||||
try:
|
||||
s = int(status) if status is not None else None
|
||||
except (TypeError, ValueError):
|
||||
s = None
|
||||
if s in (402, 403):
|
||||
return "quota_exhausted"
|
||||
if s == 401:
|
||||
return "llm_error" # 凭据失效:维持可重试,不塌成 quota_exhausted(§3.9 ②)
|
||||
t = (text or "").lower()
|
||||
quota_markers = ("insufficient user quota", "insufficient quota", "余额不足", "额度不足", "quota not enough")
|
||||
if any(m in t for m in quota_markers):
|
||||
return "quota_exhausted"
|
||||
return "llm_error"
|
||||
|
||||
|
||||
def get_api_key(user_token=None):
|
||||
"""解析本次调用的 new-api 凭据(WU2 §3.7 F)。
|
||||
|
||||
优先用传入的 per-user token(user_token,随 §6.1 job 派来);缺失回落全局 env NEWAPI_KEY(缺失即抛,
|
||||
密钥不入库、复现前先设 .env 或 export)。回落是系统/编排/bake-off/spike 触发的正常预期(无 game_player
|
||||
成员身份、后端 dispatch 已按身份旁路不塞 token);额度门在后端 dispatch 层按身份堵,worker 层只做「有则用、
|
||||
无则回落」。回落时打一条 WARN(一进程一次,防每次 chat 刷屏)便于发现本该带 token 的 member create job 异常。
|
||||
"""
|
||||
if user_token:
|
||||
return user_token
|
||||
key = os.environ.get("NEWAPI_KEY")
|
||||
if not key:
|
||||
raise RuntimeError("NEWAPI_KEY 未设:请在 wg1/gen-worker/.env 写入或 export NEWAPI_KEY=...")
|
||||
global _ENV_FALLBACK_WARNED
|
||||
if not _ENV_FALLBACK_WARNED:
|
||||
_ENV_FALLBACK_WARNED = True
|
||||
print(f"[wg1-worker] 无 per-user userToken → 回落全局 env NEWAPI_KEY({_mask_token(key)})。"
|
||||
f"系统/编排/bake-off/spike 为正常预期;若为真实 member create job 则异常走共享计量(§3.7)。",
|
||||
flush=True)
|
||||
return key
|
||||
|
||||
|
||||
def get_client():
|
||||
"""构建指向 new-api 的裸 OpenAI 兼容客户端。"""
|
||||
return openai.OpenAI(api_key=get_api_key(), base_url=BASE_URL, max_retries=2, timeout=180.0)
|
||||
def get_client(user_token=None):
|
||||
"""构建指向 new-api 的裸 OpenAI 兼容客户端(WU2 §3.7:凭据优先 per-user token、缺失回落 env)。"""
|
||||
return openai.OpenAI(api_key=get_api_key(user_token), base_url=BASE_URL, max_retries=2, timeout=180.0)
|
||||
|
||||
|
||||
def _extra_body(model, enable_thinking):
|
||||
@ -87,14 +165,18 @@ def _cache_hit_from_usage(usage):
|
||||
return 0
|
||||
|
||||
|
||||
def chat(model, system, user, max_tokens=16000, temperature=0.0, tries=3, retry_delay=2.0, enable_thinking=False):
|
||||
def chat(model, system, user, max_tokens=16000, temperature=0.0, tries=3, retry_delay=2.0,
|
||||
enable_thinking=False, user_token=None):
|
||||
"""单次裸调用 chat.completions。确定性产出 temperature=0;瞬时网关错误(502 burst)重试。
|
||||
|
||||
返回 dict:{content, prompt_tokens, completion_tokens, prompt_cache_hit_tokens, total_tokens, model, wall_s, id, finish_reason}。
|
||||
usage 字段用 openai 2.41.1 口径:prompt_tokens / completion_tokens(供成本折算与 token 取证);
|
||||
prompt_cache_hit_tokens(U4/B9 新增,additive 键)= 命中缓存的 prompt token(两套字段取 max;缺则 0),供 cost.py 折缓存价。
|
||||
WU2 §3.7:user_token 给定则用它调网关(per-user 额度)、缺失回落全局 env key。
|
||||
WU2 §3.9:网关非 2xx 按 one-api 惯例分辨——额度耗尽(402/403)立即抛 NewapiQuotaExhaustedError 不再重试;
|
||||
凭据失效(401)/瞬时错(502)维持原重试语义(不塌成额度耗尽)。
|
||||
"""
|
||||
client = get_client()
|
||||
client = get_client(user_token)
|
||||
last_err = None
|
||||
for attempt in range(tries):
|
||||
try:
|
||||
@ -130,6 +212,11 @@ def chat(model, system, user, max_tokens=16000, temperature=0.0, tries=3, retry_
|
||||
"id": getattr(resp, "id", None),
|
||||
}
|
||||
except Exception as e: # 瞬时网关错误重试(spike 实测 new-api 偶发 502 burst)
|
||||
# WU2 §3.9:额度耗尽(402/403)重试无意义,立即抛典型错误让 worker 回调 quota_exhausted;
|
||||
# 凭据失效(401)/瞬时错(502)不塌成额度耗尽,维持下方重试语义(可重试 llm_error)。
|
||||
if classify_newapi_failure_reason(*_newapi_error_status_text(e)) == "quota_exhausted":
|
||||
raise NewapiQuotaExhaustedError(
|
||||
f"new-api 额度耗尽(按 §3.9 分辨,阶段2 待精确 message):{type(e).__name__}: {e}") from e
|
||||
last_err = e
|
||||
if attempt < tries - 1:
|
||||
time.sleep(retry_delay)
|
||||
|
||||
@ -54,6 +54,7 @@ if str(WORKER_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(WORKER_DIR))
|
||||
|
||||
import run # noqa: E402 worker/run.py(GEN_DIR/REPO_ROOT 常量 + scaffold/build/play)
|
||||
import _client # noqa: E402 裸 new-api 客户端(WU2 §3.9:NewapiQuotaExhaustedError 分辨额度耗尽)
|
||||
from agent_loop.studio import run_studio # noqa: E402 L2 agentic 闭环(design 展一句话→九门)
|
||||
from agent_loop import config # noqa: E402 models.yaml 阶段(默认 default_stage)
|
||||
|
||||
@ -333,6 +334,13 @@ def handle_job(state, job):
|
||||
log(f"❌ pass 但 bundle 文件缺失:{bundle_path}")
|
||||
else:
|
||||
failure_reason = "nine_gate_failed"
|
||||
except _client.NewapiQuotaExhaustedError as e:
|
||||
# WU2 §3.9:new-api 按 per-user token 判额度耗尽(402/403)→ 干净失败 quota_exhausted(而非含糊 llm_error),
|
||||
# 后端据此把任务终态落 quota_exhausted、前端展示「生成额度已用完」。凭据失效(401)不走此路、维持可重试。
|
||||
# 注:wg1 主生成经 AgentScope 模型封装调网关(config.py credential),402 埋在框架内、此典型错误仅从
|
||||
# _client.chat 直连路(如 run.py 旧路)propagate;主生成路的额度耗尽分辨待阶段2 拦 AgentScope 模型封装。
|
||||
failure_reason = "quota_exhausted"
|
||||
log(f"❌ new-api 额度耗尽 trace_id={trace_id}(§3.9 quota_exhausted): {type(e).__name__}: {e}")
|
||||
except Exception as e:
|
||||
failure_reason = f"{type(e).__name__}: {e}"
|
||||
log(f"❌ 真生成异常 trace_id={trace_id}: {failure_reason}")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user