From b87511b967dc8916bf3583e66d46dcf3db2c4a37 Mon Sep 17 00:00:00 2001 From: zizi Date: Wed, 10 Jun 2026 01:05:55 +0000 Subject: [PATCH] =?UTF-8?q?feat(data-loop):=20=E6=95=B0=E6=8D=AE=E5=9B=9E?= =?UTF-8?q?=E8=B7=AF=E4=B8=89=E4=BF=AE=20+=20postcheck=E7=BF=BB=E9=A1=B5?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20+=20M-b/=E5=B0=8F=E6=B3=A2=E5=8F=8Cspec=20?= =?UTF-8?q?+=20=E9=89=B4=E6=9D=83=E8=AF=84=E5=AE=A1=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix1 曝光中性: 零增量事件不写聚合不建全零行, 仅 engagement 事件触发 quality 重算(修曝光清零 bug) - fix2 原子累加: GameStatMapper.insertOrAccumulate 单语句 ON DUPLICATE KEY UPDATE 替换读改写丢增量 - fix3 下架编排: reviewProject UNLIST 委托 unlist 单事务(项目4→5+feed_rank 全分区下线), 填 R7 缺口 - 修 batch-001 P1: backend_gw.feed_find_game cursor 翻页(原 size=50 超后端单页上限30 致 400 误熔断) - 文档: M-b 生成下沉 execution(HJ-AGENT-LOOP-EXEC-002 已审定+§16 裁决) + 数据回路小波 execution + 真实鉴权与匿名玩家 review(双镜头 12 条意见全修订) + EXEC-001 §16 建设期裁决增补 三修均经 mini-desktop 隔离克隆构建: telemetry 17测/project+feed 39测 全绿+逐修对抗核验 pass Co-Authored-By: Claude Opus 4.8 --- .../orchestrator/backend_gw.py | 32 ++ .../orchestrator/run_batch.py | 3 +- .../2026-06-09-agent化生成QA闭环-execution.md | 5 +- .../2026-06-10-Mb生成下沉-execution.md | 479 ++++++++++++++++++ .../2026-06-10-数据回路小波-execution.md | 250 +++++++++ .../2026-06-10-真实鉴权与匿名玩家-review.md | 201 ++++++++ .../game/module/feed/api/FeedApi.java | 20 + .../game/module/feed/api/FeedApiImpl.java | 6 + .../feed/dal/mysql/rank/FeedRankMapper.java | 17 + .../module/feed/service/feed/FeedService.java | 13 + .../feed/service/feed/FeedServiceImpl.java | 12 + .../service/feed/FeedServiceImplTest.java | 24 + .../service/project/ProjectServiceImpl.java | 12 +- .../publish/PublishOrchestrationService.java | 18 + .../PublishOrchestrationServiceImpl.java | 20 + .../project/ProjectServiceImplTest.java | 39 +- .../PublishOrchestrationServiceImplTest.java | 91 ++++ .../dal/mysql/stat/GameStatMapper.java | 27 + .../service/event/EventIngestServiceImpl.java | 148 +++--- .../dal/mysql/stat/GameStatMapperTest.java | 137 +++++ .../event/EventIngestServiceImplTest.java | 134 +++-- 21 files changed, 1556 insertions(+), 132 deletions(-) create mode 100644 docs/agent-specs/2026-06-10-Mb生成下沉-execution.md create mode 100644 docs/agent-specs/2026-06-10-数据回路小波-execution.md create mode 100644 docs/agent-specs/2026-06-10-真实鉴权与匿名玩家-review.md create mode 100644 game-cloud/game-module-project/game-module-project-server/src/test/java/cn/wanxiang/game/module/project/service/publish/PublishOrchestrationServiceImplTest.java create mode 100644 game-cloud/game-module-telemetry/game-module-telemetry-server/src/test/java/cn/wanxiang/game/module/telemetry/dal/mysql/stat/GameStatMapperTest.java diff --git a/docs/agent-specs/2026-06-09-agent-loop-v1/orchestrator/backend_gw.py b/docs/agent-specs/2026-06-09-agent-loop-v1/orchestrator/backend_gw.py index 53d8fe33..db76ec34 100644 --- a/docs/agent-specs/2026-06-09-agent-loop-v1/orchestrator/backend_gw.py +++ b/docs/agent-specs/2026-06-09-agent-loop-v1/orchestrator/backend_gw.py @@ -194,6 +194,38 @@ class BackendGateway(object): return data raise BackendError("feed/stream 响应未找到卡片列表字段:%r" % (data,), kind="business") + def feed_find_game(self, game_id, page_size=30, max_pages=10): + """feed 流逐页查找指定 gameId(postcheck 金丝雀可见性专用)。 + + 修复背景(批 batch-001 P1 误熔断):后端 /app-api/feed/stream 为 cursor 分页且单页上限 30, + 原 postcheck 用 size=50 直接被参数校验拒(code=400「单页条数不能超过 30」)→ 连续 2 条 + accept_publish_fail → F10 误停发布段。本方法按 cursor 翻页直到找到 / 翻尽 / 达页数上限, + 同时消灭「feed 超过一页后新游戏(quality 基线 0 沉底)落在首页外被误判不可见」的同款隐患。 + + :param game_id: 目标游戏 ID + :param page_size: 单页条数(钳制到后端上限 30) + :param max_pages: 安全页数上限(防异常 cursor 死循环) + :return: True=在流中找到该游戏;False=翻尽未找到 + """ + size = min(int(page_size), 30) # 后端单页硬上限 30,超出会 400 + cursor = None + for _ in range(int(max_pages)): + path = "/app-api/feed/stream?size=%d" % size + if cursor: + path += "&cursor=%s" % cursor + data = self._request("GET", path) + cards = data.get("list") if isinstance(data, dict) else None + if not isinstance(cards, list): + raise BackendError("feed/stream 响应未找到 list 字段:%r" % (data,), kind="business") + if any(c.get("gameId") == game_id for c in cards): + return True + # nextCursor 空串=已到底(T-FED-06 契约);hasMore=False 同义兜底 + cursor = data.get("nextCursor") + if not cursor or data.get("hasMore") is False: + return False + # 翻页达上限仍未找到:如实返回不可见(不抛错,交由 postcheck 记 fail 留证) + return False + def probe_staging(self): """staging 探活(F9/§7.4-3 前置):GET /app-api/feed/zones 只读端点。失败抛 BackendError。""" self._request("GET", "/app-api/feed/zones") diff --git a/docs/agent-specs/2026-06-09-agent-loop-v1/orchestrator/run_batch.py b/docs/agent-specs/2026-06-09-agent-loop-v1/orchestrator/run_batch.py index 3441f545..9cc14bd0 100644 --- a/docs/agent-specs/2026-06-09-agent-loop-v1/orchestrator/run_batch.py +++ b/docs/agent-specs/2026-06-09-agent-loop-v1/orchestrator/run_batch.py @@ -939,7 +939,8 @@ class Orchestrator(object): self.backend.get_package(version_id, scene="play") # play 态仅放行已发布包 except Exception: pkg_play_ok = False - feed_visible = any(c.get("gameId") == game_id for c in self.backend.feed_stream(size=50)) + # cursor 逐页查找(修 batch-001 P1:size=50 超后端单页上限 30 致 400 误熔断;详见 backend_gw.feed_find_game) + feed_visible = self.backend.feed_find_game(game_id) ok = (project.get("status") == 4) and pkg_play_ok and feed_visible self._stage(design_id, round_, "postcheck", "ok" if ok else "fail", data={"gameId": game_id, "projectStatus": project.get("status"), diff --git a/docs/agent-specs/2026-06-09-agent化生成QA闭环-execution.md b/docs/agent-specs/2026-06-09-agent化生成QA闭环-execution.md index 02c001f9..e63dfa46 100644 --- a/docs/agent-specs/2026-06-09-agent化生成QA闭环-execution.md +++ b/docs/agent-specs/2026-06-09-agent化生成QA闭环-execution.md @@ -599,7 +599,9 @@ LEFT JOIN game_runtime_package p ON p.version_id = v.id WHERE t.trace_id = '<干跑traceId>';" REMOTE # 通过线:tStatus=2 ∧ vStatus=2 ∧ pStatus=0 ∧ manifestLen>0 ∧ checksum 64 位非全 0 -# ④ 取包不走 demo 兜底:GET package + manifest,sha256(manifest 原文)==checksum +# ④ 取包不走 demo 兜底:GET package?scene=preview + manifest?scene=preview,sha256(manifest 原文)==checksum +# (注意:两个 GET 都必须带 ?scene=preview——manifest 端点 scene 缺省 play 仅放行 status=1,裸 GET 会拿到 +# 1102001002 错误 envelope 导致比对必败;2026-06-09 冒烟实证,见 §16 D6) # ⑤ 失败回滚断言:POST 一次构造 storeForVersion 必败的回调(如先把该 versionId 包行手工置 status=1 且异 checksum 重放)→ 预期任务 failed(llm_error) 且无新 game_version/game_runtime_package 残行 # ⑥ 玩家 agent 真玩该 versionId(preview 路由)五条 AND 全绿 # ⑦ 批跑 20 创意(run_batch.py)→ 报告生成 + accept 率口径核对 @@ -654,6 +656,7 @@ v1 前端零改动(§1.2),不触发 `npm run build` 门;若例外发生 > - **D5-c(§9.7 口径追认)**:夹具 9001 系手工灌数占位包(package_json='{}'、checksum='a'×64),五条 AND 全绿物理不可能——self-test 通过线追认为「取证链可用 + demo 信号正确识别」;五绿真验收归 §12.3-⑥(依赖 D4 部署后的真落包 versionId)。 > - **D2-a(§7.7 口径澄清)**:designId=sha256(idea+templateId+round) 含 round 因子(评审版 §3.2 冻结),故 fix 前后对照两行 designId 必不同——labels.jsonl 行**增加 `rootDesignId` 字段(=round0 的 designId)作父子关联键**,§7.7「两行同 designId」按此更正;ledger/重放仍以各轮 designId 为幂等键。 > - **D3-a(§7.2 infra 重放轮级语义,批准 D3 实现决断)**:root 轮(round0)infra_fail → 该创意全部产物失效、从头重走;回炉轮(round1)infra_fail → 保留 round0 文本面产物(其结论已被 fix verdict 固化,重跑会产生重复 verdict)、仅强制新建任务+重走回炉轮。详见 run_batch.py process_idea 注释与 orchestrator README 决断 4。 +> - **D6(部署窗口冒烟 §12.3-⑥ 实证缝隙修复,2026-06-09 主 agent 裁决=方案 A)**:preview 场景宿主取 manifest 必走 demo 兜底——根因链=RespVO.manifestUrl 不带 scene → 宿主 inject.ts 裸 fetch → manifest 端点 scene 缺省 play → play 门禁仅放行 status=1 → status=0 预览包收错误 envelope → sha256 必败。**修复=后端 manifestUrl 按请求 scene 拼 `?scene=preview`**(RuntimeConvert+AppRuntimeController,宿主/编排器零改动、F7 同步救活;play/null 路径行为零变化+回归单测守卫)。佐证:同包置 status=1 后五条 AND 全绿(clicks=7/durationMs=735/synthesized),缝隙是唯一阻塞。**⑦ 批跑开跑前置=本修复部署后 ⑥ 在 status=0 真包上五绿复验。** > - **批跑前置提醒(D3 上报)**:①C1 spike 52 条 eval 种子落盘需跑一次 `evalflow.py --seed-spike`(幂等,已在 mini-desktop 实证 52/13+二跑零新增)②换模型抽检依赖 `NEWAPI_AUDIT_MODEL` 环境变量,未配置则抽检如实降级空转并在批报告披露——正式批跑前必须配置,否则冻结阀形同虚设。 1. **文本面 kill 的任务终态归桶(D3/D6)**:评审版只规定 schema-kill→config_invalid、P0-kill→unsafe_prompt,未规定撞重 kill 与 P1 残留 kill 是否回调及 failureReason。本文裁定:均回调 failed + `config_invalid`(避免任务永久 queued 残留),真因记 Verdict.reasons。若主 agent 倾向「不回调、任务留 queued」,仅需改 §10 D3/D6 与编排器一处。 diff --git a/docs/agent-specs/2026-06-10-Mb生成下沉-execution.md b/docs/agent-specs/2026-06-10-Mb生成下沉-execution.md new file mode 100644 index 00000000..454531a8 --- /dev/null +++ b/docs/agent-specs/2026-06-10-Mb生成下沉-execution.md @@ -0,0 +1,479 @@ +# M-b 生成半下沉 · execution 版(真实用户 submitGenerate 不再停 queued) + +- **文档编号**: HJ-AGENT-LOOP-EXEC-002 +- **日期**: 2026-06-10 +- **状态**: 草稿(待主 agent 审定开工;§16 决断项待确认) +- **唯一设计依据**: `docs/agent-specs/2026-06-09-agent化生成QA闭环-review.md`(HJ-AGENT-LOOP-REVIEW-001,§2.2 F3 / §5.3 M-b 行 / §6 R6 / §7 拍板记录)+ `docs/agent-specs/2026-06-09-agent化生成QA闭环-execution.md`(HJ-AGENT-LOOP-EXEC-001 §8 回调事务语义、§16 全部裁决,已实证上线 `9517f4e`)。本文不得与两者矛盾;若执行中发现矛盾,停工上报,以评审版为准修订本文。 +- **读者**: 实现 agent(后端 Java 为主);主 agent 验收 +- **环境铁律**: 构建/测试一律 mini-desktop(git push→pull 同步,本机严禁 mvn/npm build);staging app 在 mini-desktop `http://100.64.0.7:48080` 运行中且**正在执行 20 创意批跑,严禁触碰**;**部署窗口由主 agent 统一安排**;`NEWAPI_KEY` 等密钥只走环境变量,**严禁写进 repo 文件**;含中文 SQL 必须 `--default-character-set=utf8mb4`。 + +--- + +## 0. 前提与依赖 + +| # | 前提 | 状态 | +|---|---|---| +| P3 | M-b 资源排序:v1 收口后**先投生成半下沉** | ✅ 已拍板(评审版 §7 拍板记录第 3 项,创始人 2026-06-09) | +| P4 | M2 两段式:①accept 率 ≥80% 口径达标 ∧ ②生成链路贯通(M-b)= M2 整体收口 | ✅ 已拍板(评审版 §7 第 4 项;进度总账 :98/:131 已回写) | +| 回调写链 | DifyCallback 实做三表同事务(EXEC-001 §8.4)已上线 staging | ✅ 实证(`9bf3d54` 实做 + `9517f4e` preview scene 修复;§16 D6 五绿复验通过) | +| 部署窗口 | M-a 批跑(20 创意)与金丝雀在飞 | ⏳ **本案合入与开关灰度必须等当前批跑收口**,窗口归主 agent(§15) | +| LLM 通道 | new-api `http://100.64.0.8:3000` + `MiniMax-M2.7` + `json_object`(C1 spike 52/52、W2 试点已验) | ✅ 可用;Key 走 `NEWAPI_KEY` 环境变量 | + +--- + +## 1. 目标与边界 + +### 1.1 目标(一句话) + +在 aigc 模块内落一个**薄轮询生成执行器**:扫描 queued 任务 → 渲染 Registry 策划 prompt → LLM 直出 GameConfig → 服务端 schema 校验 → **构造同款 `DifyCallbackReqVO` 进程内调用 `DifyCallbackService.handleCallback`(复用 EXEC-001 §8.4 唯一写入路径,不绕过、不复制三表逻辑)** → 任务 succeeded+versionId / failed+failureReason。从此**真实用户 submitGenerate 不再永久停 queued**(M2 整体收口的第②段)。 + +### 1.2 用户链定义与质量边界(F3/R6 依据,铁律) + +**真实用户链 = 策划 LLM 直出 config + 服务端 schema 校验 + 既有回调写链 → 用户在预览页自判质量。** + +- **不含对抗策划 agent、不含玩家 agent 实玩取证、不含裁决引擎**。依据:评审版 §2.2 F3 化解明文「v1.1 把编排器的生成调用下沉为 aigc 内薄轮询执行器(**复用同一回调语义**)」——下沉对象只有「生成半」;评审版 §6 R6 明文「种子用户内容池 v1 依赖编排器批跑代产;M-b 薄轮询执行器为第一优先后端件」——QA 三 agent 闭环是**内容池批产路径**的质量门,用户路径的质量判定主体是**用户本人(预览页试玩自判)+ 发布链既有门禁与审核**(生产人审开关 = 评审版 §7 P2 拍板分界)。 +- 推论:用户路径**成功率口径与 accept 率无关**——用户任务只有 succeeded/failed 两态,没有 accept/fix/kill 裁决;M2 ①段(≥80%)继续由编排器批跑测量。 + +### 1.3 明确不做(实现 agent 不得擅自扩界) + +1. 不接 RocketMQ/MQ(`AigcTaskServiceImpl.java:76-78` 投递 TODO 注释**原样保留不动**,轮询即 MVP 形态,契约 D4「MVP 轮询非 MQ」一致); +2. 不把对抗/玩家/裁决任何一环引入用户路径(§1.2);不改编排器任何脚本; +3. 不扩 `FailureReasonEnum`(七值冻结)、不扩 `AigcTaskStatusEnum`、不动 `DifyCallbackService/DifyCallbackTxService` 任何一行(唯一写入路径只消费不修改); +4. 不做 M-b②「渲染面放大(theme/scoreLabel 入画布)」——评审版 §5.3 M-b 行的第②子项**另立 spec**(出口判据「不再停 queued」只系于①生成半); +5. 不做 Dify 部署/迁移(回调契约兼容,真 Dify 后接零改——届时本执行器经开关关停); +6. 不回填 `qualityScore`(契约#6 可选字段;执行器无评分主体,对齐 run_batch.py:684 不传该字段的既有形态); +7. 不写 `timed_out(4)` 状态位:超时一律走回调 failed 分支 + `timeout` 原因桶(回调状态参数只有 succeeded/failed 两值,`DifyCallbackTxService.java:138` 门禁;状态 4 继续无写入方,诚实记录于 §16-7); +8. 不引分布式锁/lock4j、不做多实例分片(§3 调度选型给演化路径); +9. 前端零改动(不触发前端验证门)。 + +--- + +## 2. 代码现状亲核账(2026-06-10 dev/2.0.0 @ 9517f4e 逐文件实查) + +> 行号以当前工作区为准;实现时若行号漂移,以语义定位。 + +### 2.1 aigc 入口与任务形态 + +| 文件 | 亲核事实 | +|---|---| +| `game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/cn/wanxiang/game/module/aigc/service/task/AigcTaskServiceImpl.java` | submitGenerate :55-82 仅校验 prompt 非空 + templateId 非空兜底,落 `status=0 queued` + traceId(:73 `aigc-`+UUID)+ promptHash(:70 MD5 占位,列「不做」)后立即返回;**全类无任何拉起生成的逻辑**。cancelTask :90-104:queued/**running** 均可取消(执行器认领后用户仍可取消,竞态见 §5.6)。completeWithVersion :144-166 幂等回填(回调链内部消费)。getTemplateList :48-52 **返回空列表**(UI 断点证据,见下) | +| `.../dal/dataobject/task/AigcTaskDO.java` | 执行器可用输入字段全集:`prompt` :53(用户一句话,→{{input.idea}})、`templateId` :49、`gameId` :41、`traceId` :89(回调定位键)、`promptHash` :57;继承 TenantBaseDO(`getTenantId()` 可取,:28) | +| `.../dal/mysql/task/AigcTaskMapper.java` | :33-46 仅 selectByTraceId/selectMyPage/selectAdminPage;**无 queued 扫描、无 CAS 认领、无 stale 扫描**——本案新增三查询(§9) | +| `contracts/db-schemas/V2.0.0__create_game_aigc.sql` | :47 `idx_status_create(status, create_time)`——queued 扫描与 stale 扫描天然有索引可走,**零 DDL 改动** | +| `game-module-aigc-api/.../AigcTaskStatusEnum.java` | :22-28 状态 0-5;isCancelable :41-43 覆盖 queued/running | +| `game-module-aigc-api/.../FailureReasonEnum.java` | :18-24 七值冻结:unsafe_prompt/intent_unclear/no_template_match/config_invalid/llm_error/timeout/asset_gen_failed | +| `.../controller/app/task/AppAigcTaskController.java` | :50-56 `POST /app-api/aigc/generate`(前端直打入口);:58-65 `GET /app-api/aigc/task/{id}` 轮询;:44-48 `GET /app-api/aigc/template/list` | +| `.../controller/app/task/vo/AigcGenerateReqVO.java` | :27 `templateId @NotBlank`——templateId 必有值,执行器无需处理空模板(非 clicker → no_template_match,§5.4) | + +### 2.2 唯一写入路径(执行器只消费,不修改) + +| 文件 | 亲核事实 | +|---|---| +| `.../service/callback/DifyCallbackService.java` | :29 `Boolean handleCallback(DifyCallbackReqVO reqVO)`——**执行器进程内调用的唯一对接面** | +| `.../service/callback/DifyCallbackServiceImpl.java` | :50-71 外层非事务编排 + 写链失败补偿置 failed(llm_error);:37-40 NO_COMPENSATE_CODES(任务不存在/终态拒重入/参数非法不补偿、原样上抛)——执行器对这三类异常的处置见 §10 | +| `.../service/callback/DifyCallbackTxService.java` | :110-205 七步三表同事务;:118-123 SUCCEEDED+versionId 幂等短路;:124-129 终态(failed/timed_out/canceled)拒重入抛 1-101-003-001;:131-135 受理置 1——**对已是 RUNNING 的任务重入安全(update 置 1 幂等)**,故执行器 CAS 认领置 1 与回调语义完全兼容;:158-167 succeeded 薄校验不过 → 业务性失败置 config_invalid | +| `.../controller/admin/task/vo/DifyCallbackReqVO.java` | :23-50 traceId/status/templateId/gameConfig/assets/qualityScore/failureReason——执行器构造同款 VO | +| `.../controller/admin/task/AdminAigcTaskController.java` | :52-62 HTTP 回调端点带 `@PreAuthorize('aigc:dify:callback')`——**留给未来真 Dify 的外部信任边界;执行器在进程内直调 Service,不走 HTTP、不绕权限语义**(同进程即信任域内) | +| `docs/agent-specs/2026-06-09-agent-loop-v1/orchestrator/run_batch.py` | :676-687 编排器回调 payload 形态:`gameConfig = design["config"]`(**只传 config 段,designIntent 不并入**,否则违 clicker schema additionalProperties:false)、`assets: []`、不传 qualityScore——执行器逐字段对齐 | + +### 2.3 调度可行性(本案关键陷阱,逐项实证) + +| 文件 | 亲核事实 | +|---|---| +| `game-cloud/yudao-server/src/main/resources/application-staging.yaml` | :89-94 **`xxl.job.enabled: false`**(注释明言「nacos/xxl 关」),无 xxl-admin 部署 | +| `game-cloud/yudao-framework/yudao-spring-boot-starter-job/.../YudaoXxlJobAutoConfiguration.java` | :20-24 `@ConditionalOnProperty(prefix="xxl.job", name="enabled", havingValue="true", matchIfMissing=true)` + `@EnableScheduling`——**enabled=false 时整类不装配,连同它身上的 @EnableScheduling 一起消失** | +| `game-cloud/game-module-trade/.../job/SettlementJob.java` | :39 `@XxlJob("tradeSettlementJob")`——**前车之鉴**:以 XXL-Job 为触发面,在 staging(执行器不装配、无 admin 调度)**永不运行**(B4 烟测实证的 trade 结算 Job 死法) | +| `game-cloud/yudao-framework/yudao-spring-boot-starter-mq/.../YudaoRedisMQConsumerAutoConfiguration.java` | :39 也有 `@EnableScheduling`——但属另一 starter 的副作用,**装配与否取决于 classpath 与该自动配置自身条件,不可作为执行器调度的依据**(依赖他人副作用 = 隐性断点) | +| `game-cloud/yudao-framework/.../tenant/core/util/TenantUtils.java` | :26/:50 `execute(tenantId, ...)`、:71/:88 `executeIgnore(...)`——定时线程无租户上下文的标准解法(aigc-server 已依赖 biz-tenant starter,pom :54-57) | +| `game-cloud/game-module-trade/.../SettlementServiceImpl.java` | :41 `@Value("${trade.creator-share:0.80}")`——game 模块配置项既有范本;框架侧 `XxlJobProperties` 为 @ConfigurationProperties 范本 | + +### 2.4 studio / 前端入口(两条真实入口都落同一张表) + +| 文件 | 亲核事实 | +|---|---| +| `game-cloud/game-module-studio/.../service/studio/StudioServiceImpl.java` | :109-156 generate 编排:校验会话→落任务链→`aigcApi.submitGenerate`(:138)→回填 aigc_task_id——studio 链入口 | +| `game-studio/src/api/aigc.ts` | :14 前端模板列表打 `/app-api/aigc/template/list`;:33 生成直打 `/app-api/aigc/generate`(**前端创建页不经 studio 模块**,两入口并存、殊途同归 game_aigc_task) | +| `game-studio/src/views/create/Create.vue` + `src/store/create.ts` | Create.vue :48-50 `canSubmit` 要求已选模板;:59-60 默认选 `templates[0]`;store :41 模板全量来自后端接口——**后端 getTemplateList 返回空 ⇒ 真实用户经 UI 当前根本无法提交**(诚实断点,处置见 §8 T6 与 §16-1) | + +### 2.5 LLM 通道与 Prompt 资产(复用 M-a 既有配方) + +| 文件 | 亲核事实 | +|---|---| +| `docs/agent-specs/2026-06-09-agent-loop-v1/orchestrator/llm_client.py` | :29-33 通道纪律:temperature 0.4 / 间隔 0.3s / 重试 ×2(共 3 次尝试,退避 1s/2s)/ 单次超时 90s;:80-85 body:`response_format={"type":"json_object"}`、渲染全文作 user 消息(system 省略,:74-79 注释明言) | +| `docs/agent-specs/2026-06-09-agent-loop-v1/orchestrator/prompts.py` | :102-110 frontmatter 剥离;:169-189 渲染语义:`{{input.xxx}}` 替换(含空白容差)+ **渲染后残留占位符即报错**——Java 侧渲染按此对拍(§6) | +| `contracts/prompts/04-config/clicker-designer.md` | frontmatter :10-18 id=config.clicker-designer/version=1.0.0;:19-26 输入变量约定 idea/template_schema/banned_list/findings(**schema 重出轮 = schema 校验错误文本灌 findings 槽**,:23-24 明文);正文 :42-68 输出三键 designIntent/expectedPlaySeconds/config | +| `contracts/templates/clicker.schema.json` | draft 2020-12;5 字段 required + `additionalProperties:false`、`templateId const "clicker"`、target 5-30——**既是注入 prompt 的 {{input.template_schema}} 文本,又是服务端校验对象(同源,§7)** | +| `contracts/prompts/registry.yaml` | :14-20 config.clicker-designer 注册(version 1.0.0 与 frontmatter 一致) | + +--- + +## 3. 调度选型(关键决断) + +**裁定:Spring `@Scheduled` 薄轮询 + 执行器自带 `@EnableScheduling` 的条件装配(`aigc.executor.enabled=true` 才装配)+ DB CAS 行级认领作并发守卫。不依赖 XXL-Job。** + +| 候选 | 结论 | 理由(均经 §2.3 实证) | +|---|---|---| +| XXL-Job(`@XxlJob`) | ❌ 否决 | staging `xxl.job.enabled=false` 且无 xxl-admin → 自动配置整类不装配 → **执行器在 staging 永不运行**(SettlementJob 即此死法)。为 M-b 部署 xxl-admin = 新基建件,违最小变更 | +| 依赖框架既有 `@EnableScheduling` 写裸 `@Scheduled` | ❌ 否决 | 两处 @EnableScheduling 都是别的自动配置的副作用(XxlJob 类在 staging 已消失;RedisMQ 类装配与否取决于 starter-mq classpath)——**调度生效与否变成隐式装配博弈,不可审计** | +| **`@Scheduled` + 自带条件化 `@EnableScheduling`**(本案) | ✅ 采用 | 执行器配置类自带 `@EnableScheduling` + `@ConditionalOnProperty("aigc.executor.enabled")`:开关开才装配、自给自足、与 xxl/mq 装配状态完全解耦;staging 单实例真实可跑;`fixedDelay` 语义天然单飞(上一 tick 未完不开下一 tick) | +| 单实例守卫 | CAS 认领即守卫 | 认领 = `UPDATE game_aigc_task SET status=1 WHERE id=? AND status=0`(affected=1 才算认领成功):**行级互斥,未来多实例部署下仍正确**(至多一实例胜出),无需分布式锁;lock4j 虽在 yudao-server classpath(starter-protection),但 aigc-server 未依赖,引入=新依赖且 CAS 已足,**不引** | + +**Bean 注册铁律(对抗核验补强,2026-06-10 亲核)**:开关的真实闸门是「执行器 Bean 缺席」而非「@EnableScheduling 缺席」——亲核 `yudao-spring-boot-starter-biz-tenant/pom.xml` 依赖 `yudao-spring-boot-starter-mq`(aigc-server 经 biz-tenant 间接携带;system/infra-server 亦直接依赖),而 `YudaoRedisMQConsumerAutoConfiguration` **无任何条件注解**、类上自带 `@EnableScheduling`(:39)→ **staging(及任意 profile)下全局 @Scheduled 注解处理器必然已开**。因此执行器全部 Bean(AigcGenerateExecutor / ExecutorLlmClient / PromptResourceLoader / GameConfigSchemaValidator)**只准经 `AigcExecutorConfiguration` 的 `@Bean` 方法注册,类上严禁标 `@Component/@Service`**——若被组件扫描注册,`aigc.executor.enabled=false` 时 tick 照样起跳,总开关与回滚(§14 首选层)双双失效。§11 用例 11(ApplicationContextRunner 断言 Bean 缺席)即守此铁律。 + +**与未来 XXL-Job 的演化关系(写死在代码注释里)**:执行器拆「触发面(@Scheduled tick)/ 执行体(扫描-认领-生成-回调)」两层。XXL-Job 基建上线后,仅把触发面换成 `@XxlJob` 方法调用同一执行体(认领 CAS 天然支持多实例分片竞争),`aigc.executor.enabled` 与 xxl 侧任务启停**互斥配置,禁止双触发面同时开**(CAS 保证即便双开也不双写,但会双倍空扫)。真 Dify 接入后,本执行器整体经开关关停退役,回调契约零改。 + +--- + +## 4. 数据流与组件图 + +```mermaid +flowchart TB + subgraph USER[真实用户(两入口殊途同归)] + UI[game-studio Create 页
POST /app-api/aigc/generate] + STU[studio 会话链
POST /app-api/studio/generate] + end + + subgraph AIGC[aigc-server(本案全部新增在此)] + T[(game_aigc_task
status=0 queued)] + EXE[AigcGenerateExecutor
@Scheduled tick(fixedDelay 10s)
开关 aigc.executor.enabled] + CLAIM[CAS 认领 0→1
affected=1 才处理] + PR[PromptResourceLoader
classpath 读 Registry prompt+schema
渲染 idea/template_schema] + LLM[ExecutorLlmClient
new-api MiniMax-M2.7 json_object
超时90s 重试×2 退避1s/2s] + SV[GameConfigSchemaValidator
clicker.schema.json 同文件校验
失败→错误文本回灌重出1次] + WD[stale watchdog(同 tick)
RUNNING 超15min→failed timeout] + end + + subgraph CB[唯一写入路径(9517f4e 已上线,零改动)] + DCS[DifyCallbackService.handleCallback
进程内调用·同款 DifyCallbackReqVO] + TX[三表同事务:task+version+package
失败补偿 failed llm_error] + end + + UI --> T + STU --> T + T --> EXE --> CLAIM --> PR --> LLM --> SV + SV -- 通过 --> DCS + SV -- 两轮仍不过 --> DCS + LLM -- 重试尽 --> DCS + WD --> DCS + DCS --> TX + TX -- succeeded+versionId --> T + T -- 前端轮询 task/{id} --> UI + UI -. 用户预览页自判质量 → 发布链既有门禁 .-> PUB[publish→review→feed] +``` + +--- + +## 5. 执行器契约(核心交付) + +### 5.1 tick 主流程(每次 `@Scheduled(fixedDelay=10s, initialDelay=30s)`) + +1. **自检门**(任一不过 → 本 tick 直接返回,**不崩 app**):开关已开(装配条件已保证);`api-key` 非空(空 → 自禁用,告警节流:首次 + 此后每 60 tick 一次 `log.error`,见 §8.3);prompt/schema classpath 资源已就位(启动时校验缓存结果,缺失同自禁用)。 +2. **认领扫描**(`TenantUtils.executeIgnore` 包裹——定时线程无租户上下文,跨租户扫描):`selectClaimableQueued(limit=scan-batch-size, minCreateTime=now()-max-task-age-hours)`,按 id 升序(FIFO 公平),走 `idx_status_create`。 +3. **逐任务处理**(tick 线程内串行;每任务整体 try/catch,单任务异常不中断本 tick 其余任务): + a. CAS 认领 `claimQueuedTask(id)`(0→1,affected=0 即被并发抢走/已取消,跳过)——置 1 同时 update_time 随之刷新作 stale 判据基准(机制=DDL 列定义 `ON UPDATE CURRENT_TIMESTAMP`,`V2.0.0__create_game_aigc.sql:41` 亲核;注意 wrapper 式 CAS update 不触发 MyBatis Plus 字段自动填充,实现不得依赖 MP 填充语义); + b. 其余步骤包在 `TenantUtils.execute(task.getTenantId(), ...)` 内(回调写链的三表行必须落对租户); + c. 走 §5.3 生成流水 → §5.4 终态映射,**一切终态只经 `difyCallbackService.handleCallback(reqVO)` 进程内调用落库**。 +4. **stale watchdog**(同 tick 末尾,`executeIgnore` 扫描):`selectStaleRunning(limit, staleBefore=now()-stale-running-minutes)` → 逐条构造 `DifyCallbackReqVO{traceId, status:"failed", failureReason:"timeout"}` → `handleCallback`(进程崩溃恢复:上次进程认领后死掉的任务在此收尸为 failed(timeout),用户可走既有 retryTask 重试)。 + +### 5.2 认领与幂等(防重四层) + +| 层 | 机制 | 防什么 | +|---|---|---| +| 行级 | CAS `UPDATE ... SET status=1 WHERE id=? AND status=0`,affected=1 才处理 | 多线程/多实例重复认领;认领前用户已取消(status=5 → affected=0) | +| 进程级 | `fixedDelay` 单飞:上一 tick 跑完才调度下一 tick | 同进程 tick 重叠 | +| 回调级 | EXEC-001 §8.4 既有:SUCCEEDED+versionId 幂等短路、终态拒重入 1-101-003-001 | 重复回调、watchdog 与在飞线程竞态(§5.6) | +| 重启恢复 | 认领后进程崩 → 任务停 RUNNING → watchdog 15min 后 failed(timeout) → 用户可 retryTask(新任务新 traceId) | 半途而废的任务永久卡 running | + +**阈值关系铁律**:直觉关系 `stale-running-minutes(15) > 单任务硬预算 task-budget-seconds(600s=10min) > LLM 最坏链路`(2 次调用 ×(3 次尝试 ×90s + 退避 3s)≈ 9.2min)。**精确式(对抗核验修正)**:预算门只在步与步之间检查(§5.3-7),越线瞬间在飞的单次 LLM 调用链最坏还可再跑 ≈273s(3×90s+退避 3s),故配置校验必须断言 `stale-running-minutes×60 > task-budget-seconds + 273`(273 作常量与配置同放并注明出处;默认 900 > 873 成立)——仅 `stale×60 > budget` 不足以保证 watchdog 不收割在飞任务。万一仍相交,§5.6 第 2 行的幂等/拒重入语义保证两序收敛,一致终态不破。三值同放配置类并以单测断言此精确式。 + +### 5.3 生成流水(单任务,预算内) + +1. **预检(零 LLM 成本,提前止损)**:`templateId` 不在支持集(v1 仅 `clicker`)→ 终态 `failed + no_template_match`;`task.gameId == null` → 终态 `failed + config_invalid`(与回调薄校验同桶,`DifyCallbackTxService.java:343-345` 同语义,提前判免烧 LLM)。 +2. **渲染 prompt**(§6):变量 `idea=task.prompt`、`template_schema=clicker.schema.json 原文`、`banned_list=""`(用户路径无批内禁重语境)、`findings=""`(首轮)。 +3. **LLM 调用**(§6.3 配方):重试尽 → 终态 `failed + llm_error`。 +4. **解析与校验**:content 解析 JSON → 取顶层 `config` 键(缺 config / 非 JSON 视同 schema 失败)→ `GameConfigSchemaValidator` 按 `clicker.schema.json` 逐字段校验(§7)。 +5. **schema 重出(至多 1 次,对齐裁决表 D1 语义)**:首轮失败 → 把校验错误文本灌 `findings` 槽重渲染重调(`clicker-designer.md:23-24` 明文支持此用法)→ 仍失败 → 终态 `failed + config_invalid`(对齐 D2 行 `config_invalid` 归桶)。 +6. **成功回调**:构造 `DifyCallbackReqVO{ traceId=task.traceId, status="succeeded", templateId="clicker", gameConfig=config(只传 config 段,对齐 run_batch.py:684), assets=[], qualityScore=null, failureReason=null }` → `handleCallback` → 回调链内部完成受理置 1(对 RUNNING 幂等)→ 建版本 → 组包 → 落包 → completeWithVersion(三表同事务,EXEC-001 §8.4 原语义)。 +7. **预算门**:每步开始前检查任务级墙钟 `task-budget-seconds`(600s)超限 → 终态 `failed + timeout`;LLM 调用次数 ≤2(首轮+重出)由流程结构静态保证。 + +### 5.4 失败 → FailureReasonEnum 映射决断表(全分支,单测覆盖) + +| # | 失败点 | 终态(均经 handleCallback failed 分支) | 归桶依据 | +|---|---|---|---| +| M1 | templateId ∉ 支持集(v1 仅 clicker) | `failed + no_template_match` | 枚举既有语义「无匹配模板」精确命中 | +| M2 | task.gameId 为空 | `failed + config_invalid` | 与回调薄校验同桶(无法组包),提前判 | +| M3 | LLM 通道重试尽(HTTP 错/超时/空补全) | `failed + llm_error` | 枚举既有「LLM 异常」 | +| M4 | LLM 输出非 JSON / 缺 config / schema 校验两轮仍不过 | `failed + config_invalid` | 对齐裁决表 D2「schema_invalid_after_retry → config_invalid」 | +| M5 | 任务级预算 600s 超 | `failed + timeout` | 枚举既有「超时」 | +| M6 | watchdog:RUNNING 超 15min(进程崩溃遗留) | `failed + timeout` | 同 M5;状态 4 不启用(§1.3-7) | +| M7 | handleCallback 抛 `AIGC_CALLBACK_TASK_FINAL`(处理期间用户取消 / watchdog 竞态已收) | **丢弃结果,仅 log.warn 留痕**,不重试不补偿 | EXEC-001 既有终态拒重入语义;结果作废是正确行为 | +| M8 | handleCallback 写链异常(回调侧已自补偿 failed(llm_error)) | 执行器仅 log.error 留痕,**不二次处理** | EXEC-001 §8.4 失败路径已闭环,执行器重复补偿反而有害 | +| M9 | handleCallback 返回 SUCCEEDED 幂等短路 | 视为成功 | 既有幂等语义 | + +诚实记录:七值枚举中 `unsafe_prompt / intent_unclear / asset_gen_failed` 三桶 v1 用户路径**不产**(无安全审 LLM 环节、无意图解析环节、assets 恒空)——不虚构映射。 + +### 5.5 可追溯中文日志(铁律) + +每任务全链路以 `taskId + traceId` 贯穿,关键行:认领成功(含排队时长)、prompt 版本(frontmatter version,§6.2)、LLM 每次尝试结果(attempt/耗时/模型)、schema 校验结论(通过/错误摘要/是否重出)、回调结论(succeeded versionId= / failed reason=)、watchdog 收尸(含 stale 时长)。tick 级汇总行:本 tick 认领 n / 成功 s / 失败 f / 收尸 w。所有 ERROR 级日志必须含可定位真因(不许裸 e.getMessage() 丢栈)。 + +### 5.6 竞态安全论证(两两穷举) + +| 竞态 | 时序 | 结局(既有语义保证,无需新代码) | +|---|---|---| +| 认领 vs 用户取消 | 取消先:status=5 → CAS affected=0 跳过;认领先:用户仍可取消 1→5(isCancelable 含 running)→ 执行器最终回调被「终态拒重入」拒 → M7 丢弃 | 一致终态 canceled | +| watchdog vs 在飞线程 | 阈值不等式(§5.2)使其理论不相交;万一相交:先 succeeded 则 watchdog 的 failed 回调被幂等短路/拒重入挡下;先 failed(timeout) 则在飞线程的 succeeded 回调被拒重入挡下 → M7 | 两序皆收敛一致终态 | +| 双实例双认领(未来) | CAS 行级互斥,至多一实例 affected=1 | 单写者 | +| 执行器 vs 编排器批跑 | **结构上有真冲突**(编排器走真实 submitGenerate 落 queued、数分钟后才自行回调;执行器若在批跑窗口开着会抢先认领并生成,编排器随后回调被幂等短路 → 其 verify_package 比对必不等 → infra_fail 推高熔断) | **运维互斥窗口纪律**(§15-2),v1 不做结构性隔离,列 §16-4 | + +--- + +## 6. Prompt 渲染与契约资源装载(§16-2 决断) + +### 6.1 选型 + +| 候选 | 结论 | 理由 | +|---|---|---| +| **A. 构建期把 contracts 两文件复制进 aigc-server classpath**(maven-resources-plugin,源 `${project.basedir}/../../../contracts/prompts/04-config/clicker-designer.md` 与 `.../contracts/templates/clicker.schema.json` → `classpath:wanxiang-contracts/`) | ✅ 采用 | **git 仍是唯一事实源**(jar 内只是构建时快照,contracts/ 原文件不动不复制副本入源码树);与评审版「不依赖未实现的 Java PromptRegistryLoader」一致;与 Prompt 治理兼容:改 prompt = 改 contracts 原文件 + 升 version + 过四道闸 → 下次构建部署自动携带新版(部署本就是 prompt 升版的发布动作,可审计);执行器启动读 frontmatter version 写日志,运行中版本可追溯 | +| B. 运行时读服务器目录(配置 prompt-dir,部署脚本同步 contracts) | ❌ 否决 | 服务器目录成第二事实源,可被手改漂移、绕过四道闸——治理破口 | +| C. Java 内嵌 prompt 字符串 | ❌ 否决 | 违「prompt 不内嵌代码」铁律(EXEC-001 §7.1);与 Registry 分叉 | + +**A 的已知坑与兜底**:maven 对不存在的 resource 目录**静默跳过**(不报错)——若未来 game-cloud 拆独立仓、相对路径失效,jar 会缺资源。兜底 = 执行器启动自检:classpath 两资源任一缺失 → 自禁用 + `log.error`(与 key 缺失同款行为,§8.3),**永不带病认领任务**。 + +### 6.2 Java 侧最小渲染器(语义对拍 prompts.py,不做超集) + +`PromptResourceLoader` 仅实现四件(全部对拍 `prompts.py` 行号语义,单测对照): +1. frontmatter 剥离(首行 `---` 至下一 `---`,缺失即资源非法 → 自禁用;对拍 :102-110); +2. 顶层 `version` 提取(日志与排障用;对拍 :113-119。**不校验 registry.yaml 对账**——那是 CI 四道闸职责,Java 不复制治理逻辑,诚实分界); +3. `{{input.xxx}}` 渲染(含 `{{ input.xxx }}` 空白容差;对拍 :169-183); +4. 渲染后残留占位符检测 → 抛异常按 M4 处置(禁止把花括号发给 LLM;对拍 :184-188)。 + +### 6.3 LLM 通道(`ExecutorLlmClient`,JDK `java.net.http.HttpClient`,零新 HTTP 依赖) + +逐项沿 `llm_client.py` 配方:`POST {llm-base}/v1/chat/completions`、`Authorization: Bearer {api-key}`、body `{model, temperature:0.4, response_format:{type:"json_object"}, messages:[{role:"user", content:渲染全文}]}`(system 省略,:74-79 语义);单次超时 90s;重试 ×2(共 3 次尝试),指数退避 1s/2s;空 content 按通道异常可重试;每次尝试间 0.3s 节流(串行执行下仍保留,对通道礼貌一致)。全部常量集中配置类并注明「沿 C1 spike 配方,gen_spike.py:28-30/100-110」。 + +--- + +## 7. 服务端 schema 校验(§16-3 决断) + +**采用 `com.networknt:json-schema-validator`(draft 2020-12)直接加载 §6.1 同一份 `clicker.schema.json` 校验**——注入 LLM 的 schema 文本与服务端校验器输入**字面同源**,零逻辑分叉;M-c 扩 dodge/runner/match 时只加 schema 文件 + 支持集枚举,校验零新码。 + +- 否决的备选:手写 5 字段校验(与 schema 文件双源漂移;每扩一模板写一遍 Java)。 +- 依赖管理:`yudao-dependencies/pom.xml` 加版本管理(1.5.x,运行时仅依赖 Jackson/slf4j,均已在 classpath)+ `aigc-server/pom.xml` 引坐标——**新第三方依赖,列 §16-3 知悉**。 +- 校验失败输出:ValidationMessage 集合 → 拼接为带字段路径的中文可读错误文本(一行一条),既灌重出轮 `findings` 槽,又入日志与(终态时)账面留痕。 +- schema 编译结果按资源缓存(启动一次编译),校验线程安全。 + +--- + +## 8. 配置开关与密钥注入 + +### 8.1 配置项(`AigcExecutorProperties`,`@ConfigurationProperties("aigc.executor")`,范本=框架 XxlJobProperties;每字段中文注释) + +| 键 | 默认值(代码层) | 说明 | +|---|---|---| +| `aigc.executor.enabled` | **false** | 总开关 = 装配条件(§3);**回滚 = 关此开关**;生产 profile 无此段即天然关闭 | +| `aigc.executor.api-key` | ""(空) | new-api 密钥;**只接受环境变量注入**;空 → 自禁用(§8.3) | +| `aigc.executor.llm-base` | `http://100.64.0.8:3000` | new-api 地址(内网地址按项目规则可入库) | +| `aigc.executor.llm-model` | `MiniMax-M2.7` | 模型名 | +| `aigc.executor.poll-interval-ms` | 10000 | tick fixedDelay | +| `aigc.executor.scan-batch-size` | 2 | 每 tick 认领上限(灰度小流量;FIFO) | +| `aigc.executor.task-budget-seconds` | 600 | 单任务墙钟硬预算(M5) | +| `aigc.executor.stale-running-minutes` | 15 | watchdog 收尸阈值(必须 > 预算,§5.2 不等式单测) | +| `aigc.executor.max-task-age-hours` | 24 | 只认领 24h 内创建的 queued(**历史存量 queued 测试残骸不自动烧钱处理**,处置见 §16-5) | +| `aigc.executor.supported-templates` | `clicker` | v1 支持集(M-c 扩列表即开新模板) | + +### 8.2 staging 注入(部署窗口动作,归主 agent;**密钥不入 repo**) + +1. `application-staging.yaml` 追加(**只有占位符,无真值**,与该文件既有 `${MYSQL_ROOT_PASSWORD}` 风格一致): + +```yaml +--- #################### aigc 生成执行器(M-b 生成半下沉 HJ-AGENT-LOOP-EXEC-002)#################### +aigc: + executor: + enabled: ${AIGC_EXECUTOR_ENABLED:false} # 灰度开关:经 ~/game-staging/infra/.env 注入,默认关 + api-key: ${NEWAPI_KEY:} # 密钥只走环境变量占位符,严禁写真值入 repo +``` + +2. mini-desktop `~/game-staging/infra/.env` 追加两行(**服务器本地文件,不在 repo**):`NEWAPI_KEY=<真值>`、`AIGC_EXECUTOR_ENABLED=false`(首次部署先 false)。app 经既有 `~/game-staging/start-app.sh` 拉起(pkill 旧进程 + `set -a; . ~/game-staging/infra/.env; set +a` 注入 + setsid nohup `java -jar ... --spring.profiles.active=staging`,B1 重部署实操配方)——env 自然进入进程。 +3. 灰度开:`.env` 改 `AIGC_EXECUTOR_ENABLED=true` → `start-app.sh` 重启 → 看启动日志「生成执行器已启用」行;**回滚 = 改回 false 重启**(或删行)。 + +### 8.3 key 缺失 / 资源缺失自禁用(不崩 app) + +`enabled=true` 但 `api-key` 空,或 classpath prompt/schema 资源缺失:执行器 Bean 正常装配、tick 正常触发,但**首检不过即返回,不认领任何任务**;告警节流 = 启动时 1 次 `log.error`(写明缺什么、去哪配)+ 此后每 60 tick(约 10 分钟)重复 1 次,避免 10s 级刷屏。该行为有专门单测(§11)。 + +### 8.4 T6(建议纳入,§16-1 待确认):模板列表最小实做 + +`AigcTaskServiceImpl.getTemplateList` 由空列表改为返回硬编码 1 条 clicker(`TemplateRespVO{templateId:"clicker", name:"点击收集", description:"点击屏幕给目标计分,达到目标即通关", examplePrompt:"做一个帮奶茶店点单收银的小游戏"}`,coverUrl 暂空;中文注释标注「T-AGC-03 模板注册表的 MVP 最小形态,注册表选型落地后替换」)。理由:§2.4 实证 UI `canSubmit` 依赖模板列表非空——**不补则 M-b 收口后真实用户仍只能走 HTTP 提交,UI 路径依旧死**,违「studio 真实用户链路活」(评审版 §5.3 M-b 行)与「无入口不做 API」的项目反孤儿设计铁律。注:评审版 §5.4 曾把「模板列表接 studio 正式入口」列 v1 不做——那是 M-a 批产路径的范围裁剪,M-b 目标即用户链路,**建议解禁**,留主 agent/创始人最终确认。 + +--- + +## 9. 涉及文件清单 + +### 9.1 新建(全部在 game-cloud/game-module-aigc/game-module-aigc-server) + +| 文件 | 内容 | +|---|---| +| `service/executor/AigcGenerateExecutor.java` | tick 主流程 + 认领 + 生成流水 + watchdog(§5;类注释写明调度选型依据与 XXL-Job 演化路径 §3) | +| `service/executor/AigcExecutorProperties.java` | §8.1 配置类(含阈值不等式校验方法) | +| `service/executor/ExecutorLlmClient.java` | §6.3 通道(JDK HttpClient;可注入 sleeper/sender 便于单测) | +| `service/executor/PromptResourceLoader.java` | §6.2 渲染四件 + 启动自检 | +| `service/executor/GameConfigSchemaValidator.java` | §7 校验封装(schema 缓存 + 错误文本化) | +| `framework/executor/config/AigcExecutorConfiguration.java` | `@Configuration + @EnableScheduling + @ConditionalOnProperty(prefix="aigc.executor", name="enabled", havingValue="true")` + `@EnableConfigurationProperties`;**承载执行器全部 Bean 的 @Bean 工厂方法(§3 Bean 注册铁律:上行四个执行器类一律不标 @Component/@Service,防组件扫描绕过开关)** | +| `src/test/java/.../service/executor/AigcGenerateExecutorTest.java` 等 | §11 全部用例 | + +### 9.2 修改 + +| 文件 | 改动 | 边界 | +|---|---|---| +| `dal/mysql/task/AigcTaskMapper.java` | + `selectClaimableQueued(limit, minCreateTime)`、`claimQueuedTask(id)`(CAS update 返回 boolean)、`selectStaleRunning(limit, staleBefore)` 三个 default 方法 | 走 `idx_status_create`,零 DDL | +| `game-module-aigc-server/pom.xml` | + networknt json-schema-validator 坐标;+ maven resources 段复制 contracts 两文件进 classpath(§6.1) | 纯叠加 | +| `game-cloud/yudao-dependencies/pom.xml` | + networknt 版本管理一条 | 纯叠加 | +| `game-cloud/yudao-server/src/main/resources/application-staging.yaml` | + §8.2 第 1 步两键(占位符) | 无密钥真值 | +| (T6,待确认)`service/task/AigcTaskServiceImpl.java` | getTemplateList 返回 1 条 clicker | 唯一动既有方法处;不动 submitGenerate/状态机任何逻辑 | + +### 9.3 明确不改 + +`DifyCallbackService/Impl/TxService`、`AdminAigcTaskController`、`DifyCallbackReqVO`、两枚举、`FailureReasonEnum`、project/runtime/studio/feed/telemetry 模块、前端任何文件、编排器任何脚本、`contracts/` 任何契约文件(prompt/schema 原文件零改动——本案只是**消费**它们)。 + +--- + +## 10. 边界失败路径表(执行器视角) + +| # | 失败点 | 表现 | 处置 | 日志关键词 | +|---|---|---|---|---| +| F1 | new-api 超时/5xx/空补全 | LLM 尝试失败 | 重试 ×2 退避 → 尽 → M3 failed(llm_error) | `[executor-llm]` | +| F2 | LLM 输出非 JSON / 缺 config | 解析失败 | 视同 schema 失败 → 重出 1 次 → M4 | `[executor-schema]` | +| F3 | schema 校验两轮不过 | 错误文本 | M4 failed(config_invalid),错误文本入日志 | `[executor-schema]` | +| F4 | 任务预算 600s 超 | 墙钟超限 | M5 failed(timeout) | `[executor-budget]` | +| F5 | handleCallback 业务校验异常三类(NOT_EXISTS/TASK_FINAL/STATUS_INVALID) | ServiceException | TASK_FINAL→M7 丢弃;NOT_EXISTS/STATUS_INVALID 理论不可达(traceId 取自本任务、status 取常量)→ log.error 留痕跳过 | `[executor-callback]` | +| F6 | handleCallback 写链异常 | 回调侧已补偿 failed(llm_error) 并上抛 | M8 仅留痕;该任务已终态,下 tick 不再扫到 | `[executor-callback]` | +| F7 | 进程重启/崩溃 | 在飞任务停 RUNNING | watchdog 15min 收尸 M6;用户侧可 retryTask | `[executor-watchdog]` | +| F8 | key/资源缺失 | 自禁用 | §8.3,节流 ERROR,不认领 | `[executor-selfcheck]` | +| F9 | 单任务处理抛未知异常 | tick 内 catch | log.error 全栈 + 该任务走 watchdog 兜底(不在 catch 里强行回调,避免双写歧义) | `[executor-tick]` | +| F10 | DB 抖动致扫描/CAS 失败 | tick 异常 | tick 级 catch 留痕,下一 tick 自愈(@Scheduled 不会因异常停摆,仍显式 catch 防御) | `[executor-tick]` | + +--- + +## 11. 单测要求(BaseMockitoUnitTest 风格,范本 `AigcTaskServiceImplTest`/`DifyCallbackServiceImplTest`;全中文注释) + +**AigcGenerateExecutorTest(≥12 用例)**: +1. 认领并发:mock mapper 两次 claim 一真一假(affected 1/0),仅胜者进入生成流水; +2. 预检 M1:templateId=dodge → handleCallback 收到 failed+no_template_match,零 LLM 调用; +3. 预检 M2:gameId=null → failed+config_invalid,零 LLM 调用; +4. 成功链:LLM 桩返回合法三键 JSON → 验证 handleCallback 收到的 ReqVO 逐字段(traceId/status=succeeded/templateId=clicker/gameConfig=config 段/assets 空集合/qualityScore=null); +5. schema 重出链:首轮越界(target=31)→ 第二次 LLM 调用的渲染文本含错误文本(findings 槽非空)→ 合法 → 成功回调; +6. M4:两轮均不合法 → failed+config_invalid,LLM 恰被调 2 次; +7. M3:LLM 桩三次尝试全抛 → failed+llm_error; +8. M5:注入假时钟超预算 → failed+timeout,不再发起后续 LLM; +9. M7:handleCallback 抛 AIGC_CALLBACK_TASK_FINAL → 吞掉留痕,不重试、不再次回调; +10. M8:handleCallback 抛写链 ServiceException → 仅留痕,无二次动作; +11. 开关行为:enabled=false 不装配(用 ApplicationContextRunner 断言 Bean 缺席)/ key 空 → tick 直接返回零认领(自禁用)+ 告警节流计数正确; +12. watchdog:stale RUNNING 一条 → handleCallback 收到 failed+timeout;fresh RUNNING 不动; +13. 阈值不等式:stale-running-minutes×60 ≤ task-budget-seconds + 273(单次 LLM 调用链最坏秒数)时配置校验抛错(§5.2 精确式;防误配收割在飞任务)。 + +**PromptResourceLoaderTest(≥5)**:frontmatter 剥离/version 提取/占位符替换(含空白容差)/残留占位符报错/资源缺失自禁用信号——其中渲染用例以 `contracts/prompts/04-config/clicker-designer.md` 真文件做对拍(构建期已复制进 test classpath)。 + +**GameConfigSchemaValidatorTest(≥3)**:`docs/agent-specs/2026-06-09-generation-spike/accepted-configs.json` 6 条全过;越界样本(target=31 / title="" / 多余字段)全拒且错误文本含字段路径(对齐 EXEC-001 §6.4 验收法);schema 资源缺失行为。 + +**验证命令(mini-desktop)**: + +```bash +mvn -f game-cloud/pom.xml -pl game-module-aigc/game-module-aigc-server -am test # 0 失败 0 错误 +mvn -f game-cloud/pom.xml -T 1C compile -DskipTests # 聚合编译绿(新依赖闭合) +``` + +--- + +## 12. staging 冒烟序列(部署窗口由主 agent 安排;B1 配方鉴权 `-H "Authorization: Bearer test1" -H "tenant-id: 1"`) + +```bash +# ⓪ 前置:当前 20 创意批跑已收口、无编排器批在飞(§15-2 互斥纪律);.env 已加 NEWAPI_KEY; +# AIGC_EXECUTOR_ENABLED=false 先部署新 jar(start-app.sh),日志确认「执行器未启用」→ 行为=现状(回滚态基线)。 +# ① 灰度开:.env 置 AIGC_EXECUTOR_ENABLED=true → start-app.sh 重启 → 日志三行自检全绿: +# 执行器已启用 / prompt 资源版本 1.0.0 / key 已配置。 +# ② 真实用户链(前端同款两步): +curl -s -X POST http://100.64.0.7:48080/app-api/project \ + -H "Authorization: Bearer test1" -H "tenant-id: 1" -H "Content-Type: application/json" \ + -d '{"title":"M-b 冒烟·奶茶店收银","templateId":"clicker"}' # → gameId +curl -s -X POST http://100.64.0.7:48080/app-api/aigc/generate \ + -H "Authorization: Bearer test1" -H "tenant-id: 1" -H "Content-Type: application/json" \ + -d '{"prompt":"做一个帮奶茶店点单收银的小游戏","templateId":"clicker","gameId":}' # → taskId(status=0) +# ③ 轮询(出口判据主断言):≤60s 内 status=2 且 versionId 非空 +watch -n 5 'curl -s http://100.64.0.7:48080/app-api/aigc/task/ -H "Authorization: Bearer test1" -H "tenant-id: 1"' +# ④ 三表断言:EXEC-001 §12.3-③ SQL 原样复用(按本次 traceId)→ tStatus=2 ∧ vStatus=2 ∧ pStatus=0 ∧ manifestLen>0 +# ⑤ 取包不走 demo:EXEC-001 §12.3-④ 原样复用(两个 GET 必带 ?scene=preview,9517f4e/D6 裁决) +# ⑥ 五绿配方复用:mini-desktop 前端 serve localhost:4173(D5-b 裁决) +cd docs/agent-specs/2026-06-09-agent-loop-v1/orchestrator && \ +python3 player_cdp.py --self-test --version-id <③的versionId> # 五条 AND 全绿(EXEC-001 §12.3-⑥ 真包口径) +# ⑦ 失败映射真值:templateId=dodge 重复② → ≤30s failed + failureReason=no_template_match +# ⑧ 取消竞态抽验:提交后立即 POST /app-api/aigc/task//cancel → 终态 canceled,日志见 M7 丢弃行(若已被处理完则跳过,不算失败) +# ⑨ 回滚验证:.env 置回 false 重启 → 新提交任务停 queued(=现状),日志「执行器未启用」 +# ⑩(T6 若纳入)GET /app-api/aigc/template/list 返回 1 条 clicker;浏览器走 Create 页真提交一单(UI 路径活) +``` + +观察项:全程 app 无 ERROR 级非预期日志;②-⑥ 期间 staging 既有 feed/试玩功能抽测一次无回归。 + +--- + +## 13. 完成条件 + +> **评审版 M-b 出口判据原文(§5.3):「真实用户 submitGenerate 不再停 queued」。** + +操作化判据(全部满足才可宣称,证据留存冒烟输出): +1. §12-③:真实链新提交 clicker 任务 **≤60s succeeded + versionId 非空**(p50 期望;超 60s 未失败仅记观察,不判死); +2. §12-④⑤⑥:三表齐备、预览取包不走 demo 兜底、五条 AND 全绿(生成产物在既有预览链真实可玩); +3. §12-⑦:失败映射真值至少 no_template_match 一条实证; +4. §12-⑨:开关关闭回滚到现状行为; +5. §11 单测全绿 + 聚合编译绿; +6. 收口回写:`docs/mvp/MVP进度总账.md` :98/:131 的 M2 ②段置达成(M2 整体收口 = ①∧②,①以 M-a 批跑报告为准,**两段证据分别引用,不得互相顶替**);更新 `docs/mvp/MVP作战清单.md` 与 aigc 模块 `.agent`;按 AGENTS.md §7 把「调度自带装配配方 / CAS 认领防重 / 回调路径复用」蒸馏进 `.agents/`。 + +--- + +## 14. 回滚策略 + +| 层 | 动作 | 影响 | +|---|---|---| +| 行为回滚(首选) | `.env` 置 `AIGC_EXECUTOR_ENABLED=false` → start-app.sh 重启 | 执行器整组 Bean 不装配,新任务停 queued = 精确回到现状;已成功任务为正常业务数据,零清理 | +| 物理回滚 | revert 本案 commit(纯叠加:新类 + Mapper 三 default 方法 + pom 两处 + yaml 占位符段) | 零既有调用方受影响;DifyCallback 链/契约/前端均未被触碰 | +| 半途任务 | 回滚时刻在飞 RUNNING 任务 | 关停后无 watchdog,会停留 running——回滚 SOP 附一条收尾 SQL(按 update_time 窗口将 running 置 failed+timeout,操作记录留档)或重开开关让 watchdog 收完再关 | +| 新依赖 | networknt 仅 aigc-server 引用 | revert pom 即净 | + +--- + +## 15. 与在跑批跑/金丝雀互不干扰 + 部署窗口 + +1. **部署窗口归主 agent**:本案合入、重启、灰度开关全部排在**当前 20 创意批跑收口之后**;staging app 在批跑期间严禁触碰(环境铁律)。 +2. **互斥窗口纪律(运维红线,§5.6 竞态第 4 行)**:`AIGC_EXECUTOR_ENABLED=true` 期间**不得启动编排器批跑**;要跑批先关执行器。原因:编排器经真实 submitGenerate 落 queued 后由其自身延迟回调,执行器会抢先认领生成 → 编排器回调被幂等短路 → 其落包核验(F7)必不等 → infra_fail 推高熔断。本纪律写进 orchestrator README 与作战清单各一行(文档行,不改脚本);结构性隔离(任务来源标记)列 M-c 候选(§16-4)。 +3. **金丝雀不受影响**:已发布游戏/feed/telemetry 链路与执行器无共享写入面(执行器只写 queued→终态这一段,且经唯一回调路径)。 +4. **通道共用**:执行器与批跑同用 new-api——互斥窗口下天然不并发;执行器自身串行 + 0.3s 节流 + 每 tick ≤2 任务,量级远低于批跑。 + +--- + +## 16. 需主 agent / 创始人确认与知悉点 + +> **主 agent 裁决(2026-06-10,开工前确认)**: +> - **#1 T6 纳入 M-b**——评审版 §5.4 的「不做」系 M-a(v1 批产路径)范围限定;M-b 出口判据的精神是「真实用户链活」,UI 不能提交则链不算活。按推荐最小实做:getTemplateList 返回 1 条硬编码 clicker(创始人如有异议可一键回退,改动半径=单方法)。 +> - **#5 存量 queued = 部署窗口一次性 SQL 置 canceled**(操作留档入冒烟记录)+ 保留 max-task-age-hours=24 防护双保险——账本干净优先。 +> - **#3 networknt 依赖批准**(同源校验防双源漂移,优于手写)。 +> - **#4 互斥窗口确认**:运维纪律=执行器开关与批跑错峰(批跑前必关执行器);任务来源标记列 M-c。 +> - **#2/#6/#7/#8/#9 知悉确认**,照本文执行。 + +1. **T6 模板列表最小实做是否纳入**(§8.4,推荐纳入):不纳入则 UI 真实用户仍无法提交(getTemplateList 空 → Create 页 canSubmit 恒 false),M-b 只能宣称「HTTP 链路活」;纳入则改动半径 = 一个方法返回 1 条硬编码 clicker。涉及对评审版 §5.4「模板列表接 studio 正式入口(v1 不做)」的解禁,**请创始人/主 agent 拍**。 +2. **prompt 渲染选型 = 构建期 classpath 快照**(§6.1 方案 A):prompt 升版需重新构建部署才生效(无热更)——与「改 prompt 必升 version 过四道闸」的治理节奏一致,但请知悉「staging 上改 prompt 不重启不生效」。 +3. **networknt json-schema-validator 新依赖**(§7):schema 文件即校验器、与注入 LLM 的 schema 文本同源;替代方案为手写校验(双源漂移)。请知悉确认。 +4. **执行器与编排器批跑的互斥窗口为运维纪律而非结构隔离**(§15-2):v1 靠开关错峰;若未来批跑常态化与用户流量并存,需任务来源标记(schema 加列)做结构隔离——列 M-c 候选,本案不做。 +5. **历史存量 queued 任务处置**:默认 `max-task-age-hours=24` 使旧测试残骸不被自动认领(不烧 LLM、不产垃圾包),它们将继续停 queued。可选项 = 部署窗口一次性 SQL 置 canceled(操作留档)。**请主 agent 拍:自然忽略(默认)还是窗口清理。** +6. **用户路径含 1 次 schema 重出**(§5.3-5,对齐裁决表 D1 语义、复用 prompt findings 槽):每任务 LLM 预算上限 2 次调用。C1 结构层 52/52 意味着重出极少触发,作为廉价保险保留。请知悉。 +7. **timed_out(4) 状态位继续无写入方**:执行器超时/收尸均走回调 failed 分支 + `timeout` 原因桶(回调 status 门禁只有 succeeded/failed 两值,保持唯一写入路径不开新口)。用户侧 retryTask 对 failed 同样可用,产品语义无损。请知悉。 +8. **审计列 creator 为空属预期**:定时线程无登录态,三表行的审计 creator 列将为空(业务字段 creatorUserId 不受影响);追溯靠 traceId 中文日志链。冒烟 §12-④ 顺带核对无异常。请知悉。 +9. **用户路径内容安全边界**:v1 无安全审 LLM 环节(`unsafe_prompt` 桶不产);防线 = prompt hard_constraints 注入防护 + schema `additionalProperties:false` 收窄输出面 + 发布链既有合规门禁与 admin 审核(生产人审开关 = P2 拍板)。与评审版用户链定义一致,请知悉。 + +--- + +## 附:引用清单(仓库相对路径) + +- 设计真源:`docs/agent-specs/2026-06-09-agent化生成QA闭环-review.md`(§2.2 F3 / §5.3 / §6 R6 / §7)、`docs/agent-specs/2026-06-09-agent化生成QA闭环-execution.md`(§8 / §12.3 / §16) +- 后端亲核:`game-cloud/game-module-aigc/game-module-aigc-server/.../AigcTaskServiceImpl.java`、`AigcTaskMapper.java`、`AigcTaskDO.java`、`DifyCallbackService.java`、`DifyCallbackServiceImpl.java`、`DifyCallbackTxService.java`、`DifyCallbackReqVO.java`、`AdminAigcTaskController.java`、`AppAigcTaskController.java`、`AigcGenerateReqVO.java`、`game-module-aigc-api/.../AigcTaskStatusEnum.java`、`FailureReasonEnum.java`、`ErrorCodeConstants.java` +- 调度亲核:`game-cloud/yudao-server/src/main/resources/application-staging.yaml`、`game-cloud/yudao-framework/yudao-spring-boot-starter-job/.../YudaoXxlJobAutoConfiguration.java`、`yudao-spring-boot-starter-mq/.../YudaoRedisMQConsumerAutoConfiguration.java`、`game-cloud/game-module-trade/.../job/SettlementJob.java`、`yudao-framework/.../tenant/core/util/TenantUtils.java` +- 通道/契约:`docs/agent-specs/2026-06-09-agent-loop-v1/orchestrator/llm_client.py`、`prompts.py`、`run_batch.py`、`contracts/prompts/04-config/clicker-designer.md`、`contracts/prompts/registry.yaml`、`contracts/templates/clicker.schema.json`、`contracts/db-schemas/V2.0.0__create_game_aigc.sql`、`docs/agent-specs/2026-06-09-generation-spike/accepted-configs.json` +- 前端入口实证:`game-studio/src/api/aigc.ts`、`game-studio/src/views/create/Create.vue`、`game-studio/src/store/create.ts` +- 进度口径:`docs/mvp/MVP进度总账.md`(:98/:131 两段式) diff --git a/docs/agent-specs/2026-06-10-数据回路小波-execution.md b/docs/agent-specs/2026-06-10-数据回路小波-execution.md new file mode 100644 index 00000000..54d6d6a0 --- /dev/null +++ b/docs/agent-specs/2026-06-10-数据回路小波-execution.md @@ -0,0 +1,250 @@ +# 数据回路小波 · 执行小记(spec-lite) + +> 基线:monorepo `dev/2.0.0` @ `9517f4e`。三个已实证缺陷的最小修,三建设 agent 并行,**文件归属互斥**(见各修「文件清单」;除此之外任何文件不许动)。 +> 铁律复述:严禁 git 写命令 / 严禁本机 mvn / 严禁触碰 mini-desktop 主检出(`/root/games-development-ai`,20 创意批跑读盘中)/ staging app+DB 只读。 +> 构建验证协议(每 agent 各自执行): +> ```bash +> ssh mini-desktop 'git clone --local /root/games-development-ai /root/dr-wave-' # 基线 9517f4e +> ssh mini-desktop 'cat > /root/dr-wave-/' < 本地改后文件 # 逐文件镜像 +> ssh mini-desktop 'mvn -f /root/dr-wave-/game-cloud/pom.xml -pl <模块> -am test' # 模块见各修 +> ``` +> 克隆留给核验员,不删。回滚策略(三修通用):纯代码改动,无 Flyway 迁移、无配置变更、无数据回填 → **revert 对应提交即净**。 + +--- + +## 修1 · 曝光清零 bug(曝光中性裁决) + +### 现状证据(亲核 @9517f4e) + +- `game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/service/event/EventIngestServiceImpl.java` + - **L144 `if (gameId != null)` 是聚合+算分+回灌的唯一闸门,无事件类型过滤**:任何带 gameId 的已注册事件(含 `game_impression`,注册表见 `TelemetryEventEnum.java:33`)都走 L147 `accumulateStat` → L149 `computeQualityScore` → L153 写 quality → L162 `feedApi.upsertRank(QUALITY_REFRESH)`。 + - L196-213:`accumulateStat` 首事件建行,曝光事件六增量全 0 → 建出**全零聚合行**(L211 `qualityScore=ZERO`);已有行则做 +0 空写(L215-224,纯浪费写)。 + - L245-259:`computeQualityScore` 对全零行算出 score=0。 +- `game-cloud/game-module-feed/game-module-feed-server/src/main/java/cn/wanxiang/game/module/feed/service/feed/FeedServiceImpl.java` L405-425:QUALITY_REFRESH 把 `feed_rank.quality_score=0、sort_score=0+既有boost`。 +- **后果链(B2′ 实证复现)**:未被玩过的已发布游戏,仅被曝光(feed 卡片刷到)就被刷成 quality=0、sort_score 砸到 boost 值 → 误降分。曝光量最大(每次出流每卡一条),破坏面最广。 + +### 口径裁决(主 agent 已定) + +**曝光中性**:仅 engagement 事件(`game_play_end` / `like` / `favorite` / `share`)触发该 gameId 的 quality 重算+feed 回灌。`game_play_start` 只累加 `play_count` 不触发重算(下一条 engagement 事件自然带入新分母收敛)。曝光等零增量事件**不写聚合表**(不建零行、不空写),原始事件照常入库置已聚合(曝光数据保留,供未来 CTR)。 + +### 改法(最小过滤,单文件) + +`ingestOne` 内 L142-163 改造为(与修2集成后的目标形态,**修1 agent 实施**): + +```java +if (gameId != null) { + LocalDate statDate = ...; // 不变 + // 六增量映射沿用既有 L189-194 逻辑(dPlay/dPlayEnd/dCompleted/dDurationMs/dLike/dShare) + if (dPlay + dPlayEnd + dLike + dShare > 0) { // 仅计数事件写聚合:曝光等零增量事件跳过 + gameStatMapper.insertOrAccumulate(gameId, statDate, + dPlay, dPlayEnd, dCompleted, dDurationMs, dLike, dShare); // 修2 冻结签名(见修2) + } + if (ENGAGEMENT_EVENTS.contains(envelope.getEvent())) { // 修1 裁决闸门 + GameStatDO stat = gameStatMapper.selectByGameAndDate(gameId, statDate); // 累加后重读整行(同事务视图) + if (stat != null) { + // 沿用既有:computeQualityScore + updateById(quality) + feedApi.upsertRank(QUALITY_REFRESH) + } else { + log.info("[ingestOne] engagement 事件无当日聚合行,跳过算分 gameId={}, event={}", gameId, envelope.getEvent()); + } + } +} +``` + +- 新增常量 `ENGAGEMENT_EVENTS = Set.of(GAME_PLAY_END/LIKE/FAVORITE/SHARE 的 getEvent())`,注释引用本裁决。 +- 原 `accumulateStat` 私有方法整体被上述形态取代(增量映射逻辑保留,读改写删除——归修2语义)。 +- 已知无害边界:`favorite` 在 engagement 集合内但无累加映射(favorite_count 既有缺口,本波不补列),触发重算结果不变,仅多一次无害回灌;`report`/`game_load_failed` 的累加缺口同为既有问题,本波不动(最小变更)。 + +### 文件清单(修1 agent 独占) + +| 文件(绝对路径前缀 `/root/games-development-ai/`) | 动作 | +|---|---| +| `game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/service/event/EventIngestServiceImpl.java` | 改 | +| `game-cloud/game-module-telemetry/game-module-telemetry-server/src/test/java/cn/wanxiang/game/module/telemetry/service/event/EventIngestServiceImplTest.java` | 改 | + +> 编译依赖说明:本文件调用修2冻结签名的 `insertOrAccumulate`。**验证克隆里需把修2节的冻结方法文本镜像进 `GameStatMapper.java` 以通过编译**;但该文件的交付归属仍是修2 agent(修1 交付物只含上表两文件)。 + +### 单测(BaseMockitoUnitTest 既有模式) + +1. **`testIngestOne_impressionNeutral`(必备)**:`game_impression` 带 gameId → verify 原始事件 insert + 置已聚合发生;`gameStatMapper` 零交互(无 insertOrAccumulate / 无 selectByGameAndDate / 无 updateById);`feedApi` 零交互。 +2. **`testIngestOne_playEndTriggersQualityRefresh`(必备)**:`game_play_end(completed)` → verify `insertOrAccumulate(dPlayEnd=1,dCompleted=1,…)` + 桩 `selectByGameAndDate` 返回累计行 → verify updateById(quality) + `upsertRank(QUALITY_REFRESH)` 入参分值正确。 +3. `testIngestOne_playStartAccumulatesNoRefresh`:play_start → verify insertOrAccumulate(dPlay=1) 但 feedApi 零交互。 +4. 适配既有用例到新调用形态:幂等跳过用例(never insertOrAccumulate)、like 累加用例、props JSON 守卫等保持语义不丢。 + +### 验收断言 + +- 模块测试绿:`mvn -f /root/dr-wave-/game-cloud/pom.xml -pl game-module-telemetry/game-module-telemetry-server -am test` +- 曝光事件路径上:聚合表零写入、feed 零回灌;engagement 路径上:重算+回灌行为与既有口径一致。 + +--- + +## 修2 · stat 原子累加(读改写 → 原生 ON DUPLICATE KEY UPDATE) + +### 现状证据(亲核 @9517f4e) + +- `EventIngestServiceImpl.java` L196-224:累加为「`selectByGameAndDate`(L196)→ 内存相加 → `updateById`(L224)」读-改-写。**并发丢增量**:两请求同读 play_count=5、各写 6,丢 1;首行并发双建则一方撞 `uk_game_date` 抛异常整批回滚(误伤整批事件)。 +- `GameStatMapper.java` L60-64:仅有 `selectByGameAndDate`,无原生 upsert。 +- 表唯一键:`yudao-server/src/main/resources/db/migration/V5.0.0__create_game_telemetry.sql` L72 `UNIQUE KEY uk_game_date (game_id, stat_date)`;全部计数列 `NOT NULL DEFAULT 0`,审计/租户列均有 DDL 默认值(L65-70)——原生 INSERT 可省略缺省列。 + +### 幂等语义确认(不被破坏的论证) + +- **事件层恰一次**:V10 迁移 L37 `uk_event_id(event_id)` + `ingestOne` L133-137 幂等预查、L140 真插入**先于**累加且同一事务(L75 `@Transactional`)。重放同 eventId → 预查命中直接 return,不进累加;并发同 eventId 双插 → 一方撞 uk_event_id → 该事务整体回滚(其累加增量一并回滚)→ 每个 eventId 恰好累加一次。 +- **聚合层是纯加法语义**(cnt=cnt+N),本身不幂等也无需幂等——幂等由事件层挡重放保证;本修只消除「加法本身的并发丢失」,不改变任何幂等边界。 + +### 改法(冻结签名,逐字落入 GameStatMapper) + +```java +/** + * 原子累加 upsert(数据回路小波·修2):单语句 INSERT…ON DUPLICATE KEY UPDATE cnt=cnt+N, + * 命中 uk_game_date(game_id,stat_date) 则行级原子累加,消除读-改-写并发丢增量; + * 首行并发双建由 MySQL 在唯一键上原子裁决(一方 INSERT 一方转 UPDATE,均不抛错)。 + * 幂等边界:聚合层为纯加法,恰一次由入库层 uk_event_id 挡重放保证(见 EventIngestServiceImpl.ingestOne)。 + * 注:刻意不用 VALUES() 取新值(MySQL 8.0.20+ 已弃用),UPDATE 子句直接复用绑定参数,跨版本安全; + * 审计/租户缺省列依赖 DDL 默认值 + 租户拦截器自动补 tenant_id;quality_score 不在本语句内(由算分步独立回写,避免被累加语句覆盖)。 + * + * @return 受影响行数(MySQL 语义:插入=1 / 累加=2),仅日志排障用 + */ +@Insert("INSERT INTO game_telemetry_game_stat " + + "(game_id, stat_date, play_count, play_end_count, completed_count, total_duration_ms, like_count, share_count) " + + "VALUES (#{gameId}, #{statDate}, #{dPlay}, #{dPlayEnd}, #{dCompleted}, #{dDurationMs}, #{dLike}, #{dShare}) " + + "ON DUPLICATE KEY UPDATE " + + "play_count = play_count + #{dPlay}, " + + "play_end_count = play_end_count + #{dPlayEnd}, " + + "completed_count = completed_count + #{dCompleted}, " + + "total_duration_ms = total_duration_ms + #{dDurationMs}, " + + "like_count = like_count + #{dLike}, " + + "share_count = share_count + #{dShare}") +int insertOrAccumulate(@Param("gameId") Long gameId, @Param("statDate") LocalDate statDate, + @Param("dPlay") long dPlay, @Param("dPlayEnd") long dPlayEnd, + @Param("dCompleted") long dCompleted, @Param("dDurationMs") long dDurationMs, + @Param("dLike") long dLike, @Param("dShare") long dShare); +``` + +### 并发安全说明 + +- 累加原子性由 MySQL 单语句 + InnoDB 行锁保证;首行竞争由唯一键原子裁决,不再有「双 insert 撞键回滚整批」路径。 +- 算分步(修1的 engagement 分支)在累加后 `selectByGameAndDate` 重读——读到本事务视图的累计值;并发场景下他事务未提交增量可能不可见,quality 短暂偏旧,**由下一条 engagement 事件重算自然收敛**(计数本身不丢是本修硬目标,分值收敛是既有口径)。 +- quality_score 回写仍为独立 `updateById(id, qualityScore)`:两 engagement 并发各算各写、后写覆盖,同样收敛(与现状一致,不在本修扩大范围)。 + +### 文件清单(修2 agent 独占) + +| 文件 | 动作 | +|---|---| +| `game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/dal/mysql/stat/GameStatMapper.java` | 改(新增上述冻结方法,其余不动) | +| `game-cloud/game-module-telemetry/game-module-telemetry-server/src/test/java/cn/wanxiang/game/module/telemetry/dal/mysql/stat/GameStatMapperTest.java` | 新建 | + +### 单测策略与验证边界(如实声明) + +- game 模块测试基建仅 `BaseMockitoUnitTest`(telemetry-server 无 test resources / 无 H2 基建),**mock 层无法执行真 SQL**。本修单测只能覆盖两层: + 1. **SQL 形态守卫**(`GameStatMapperTest`,反射读 `@Insert` 注解文本):断言含 `ON DUPLICATE KEY UPDATE`;六个累加列均为 `列 = 列 + #{…}` 形态;`quality_score` **不出现**在语句中(防误覆盖算分);INSERT 列与 VALUES 占位一一对应。 + 2. **调用形态验证**归修1测试(mock verify `insertOrAccumulate` 的增量入参正确)。 +- **真实验证边界**:MySQL 执行语义 + MyBatis-Plus 租户拦截器(jsqlparser)对 ON DUPLICATE 语句的改写,单测均不可达,由「克隆构建编译绿 + 部署后 staging 冒烟断言②」闭环。若拦截器解析异常(运行期才暴露),回退预案:拆两段原子语句(`INSERT IGNORE` + `UPDATE … SET cnt=cnt+N WHERE game_id AND stat_date`),仍无读-改-写。 + +### 验收断言 + +- 模块测试绿(同修1命令,集成克隆里与修1文件合并编译通过)。 +- SQL 形态守卫单测对上述 4 点全绿。 + +--- + +## 修3 · 下架编排(review decision=3 安全网) + +### 现状证据(亲核 @9517f4e) + +- 入口已存在:`AdminProjectController.java` L52-59 `POST /admin-api/project/review`;`ReviewReqVO.java` L25-27 `decision:1通过 2拒绝 3下架`。 +- **项目态翻转已存在(修正 R7 表述)**:`ProjectServiceImpl.java` L345-350 `resolveReviewTargetStatus` 对 UNLIST 校验「仅 PUBLISHED(4) 可下架」→ L202-208 直写 `status=UNLISTED(5)` + L211-218 落审核记录。`ProjectStatusEnum` 合法流转 `4已发布→(5已下架/6已封禁)`,UNLISTED(5) 即合法下架态。 +- **真实缺口 = 无编排,feed_rank 残留在流态**:全仓唯一 feed_rank 写点是 `FeedServiceImpl.upsertRank` 两模式(L337-341),PUBLISH_BASELINE 只写 status=1、QUALITY_REFRESH 保留 status(L401)——**没有任何代码把 feed_rank.status 写 0**。而 V4 DDL(`V4.0.0__create_game_feed.sql` L28)注释明示设计意图:`status:0屏蔽(举报降权/下架联动)1可出流`。 +- 读侧已有兜底:`FeedServiceImpl` L110-148 `filterByProjectPublished` 按 project.status==4 二次过滤,下架后游戏不出流(UX 不漏)。但残行后果实在:L109 先取 size 条再过滤 → 残行**占页槽**、L122 `hasMore=visibleRanks.size()>=size` 被扭曲(误截断流),且每次出流多付 RPC 过滤成本。金丝雀已往 feed 进真内容,写侧必须闭口。 +- 事务模板:`PublishOrchestrationServiceImpl.java` L57-100 发布编排单事务五步(@Transactional REQUIRED 与 `reviewProject` L193 的事务合并;seam 全为 @Primary 本地 bean,事务内本地调用)。 +- R3 联动点:`ProjectServiceImpl.unlistIfPublished` L264-279(封禁联动)复用 reviewProject(UNLIST) → 本修落地后**自动继承 feed 下线**(正向波及,补上封禁链路同款缺口)。 + +### 裁决:运行包/版本状态不动(依据) + +| 对象 | 裁决 | 依据 | +|---|---|---| +| `game_feed_rank.status` | **写 0(全分区)** | V4 DDL L28 自证设计意图「下架联动」;uk_game_zone 下同游戏可有多分区行(发布基线 + setFeatured),须按 gameId 全下 | +| `game_runtime_package.status` | **不动** | `PackageStatusEnum` 状态机 `0→1→2` 单向,2=已失效语义是包本体失效,无回路;误下架后人工把 project 复位尚可救,包翻 2 则不可逆。下架语义=「不再分发」(feed 出流、分享 `getShareMeta`、精选均按 project.status 封死),存量直链玩家经 play 门禁(`RuntimePackageServiceImpl` L58-71,权威=package.status,不查 project)仍可玩到属可接受残余;「下架即断玩」若为产品要求,归后续波次在取包门禁加 project.status 校验 | +| `game_version.status` | **不动** | V1 DDL L52 版本状态机 `0草稿 1已编译 2预览就绪 3已发布` 无下架态;版本是构建产物事实记录,下架是可见性决策,强行回翻=伪造历史 | + +### 改法(复用发布编排同款单事务,反向操作) + +1. **`PublishOrchestrationService`** 新增接口方法 `void unlist(ProjectDO project)`(javadoc 引用本裁决)。 +2. **`PublishOrchestrationServiceImpl`** 实现(@Transactional(rollbackFor=Exception.class),与 reviewProject 事务合并,两步任一失败全回滚): + - ① 翻项目态 `status=UNLISTED(5)`(合法性已由调用方 `resolveReviewTargetStatus` 校验,与 publish 不重复校验项目态的既有分工对称); + - ② `feedApi.offlineRank(project.getId())` 全分区下线(返回下线行数,记 INFO 审计日志:gameId + offlinedRows;行数 0 也合法——无基线行的存量数据,记日志不抛错); + - 错误路径:feed 侧抛错 → 编排抛出 → reviewProject 事务整体回滚(含审核记录),中文日志可溯。 +3. **`ProjectServiceImpl.reviewProject`** UNLIST 分支改为委托编排(REJECT 直写分支与审核记录落库逻辑不变): + ```java + if (Objects.equals(reqVO.getDecision(), ReviewDecisionEnum.UNLIST.getDecision())) { + publishOrchestrationService.unlist(project); // 下架编排:项目态 4→5 + feed_rank 全分区 status=0,同一本地事务 + } else if (!isApprove) { + /* REJECT 直写分支原样保留 */ + } + ``` +4. **feed 侧新增窄契约**(不复用 upsertRank:其 (gameId,zoneId) 单行形态承载不了「全分区下线」语义,硬塞第三 mode 属拼接设计): + - `FeedApi`(-api 包即 RPC 契约;亲核 `contracts/api-schemas/feed.yaml` 无 RPC 段、对外 HTTP 契约不变——review 端点已含 decision=3,**零新增对外端点**)新增: + ```java + @PostMapping(PREFIX + "/offline-rank") + @Operation(summary = "按游戏全分区下线排序行(status=0,下架编排反向操作)") + @Parameter(name = "gameId", description = "游戏 ID", required = true, example = "1024") + CommonResult offlineRank(@RequestParam("gameId") Long gameId); + ``` + - `FeedApiImpl` 委托 `FeedService.offlineRank`(@Primary 本地 bean,同进程事务内调用,与 upsertRank 同构)。 + - `FeedService`/`FeedServiceImpl` 新增 `int offlineRank(Long gameId)`:调 mapper 全分区置 0,INFO 日志记 gameId+行数。 + - `FeedRankMapper` 新增 default 方法 `updateStatusOfflineByGameId(Long gameId)`:MP `update(实体 status=0, wrapper eq gameId)`(享审计列自动填充与租户拦截,复用既有范式)。 +5. **幂等语义(重复下架安全)**:与发布编排同口径——状态机拒绝重复:第二次 decision=3 时 project 已是 UNLISTED(5) ≠ PUBLISHED → `resolveReviewTargetStatus` 抛 `PROJECT_REVIEW_STATUS_INVALID`,**任何写发生之前**拒绝,无副作用残留;`offlineRank` 自身天然幂等(已 0 置 0)。 +6. **错误码**:零新增——状态机拒绝复用 project 既有段位 `PROJECT_REVIEW_STATUS_INVALID(1_100_000_004)`(段位 1-100-***-***);feed 下线无业务失败分支不增码。建设中确需新码则从 `1_100_000_006` 顺延登记。 +7. **审计**:既有审核记录(decision=3 + reason + reviewerUserId + gate_result=block)不变;编排 INFO 日志(开始/完成/下线行数)补全链路。 + +### 文件清单(修3 agent 独占) + +| 文件 | 动作 | +|---|---| +| `game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/project/ProjectServiceImpl.java` | 改(仅 reviewProject UNLIST 分支) | +| `game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/publish/PublishOrchestrationService.java` | 改(+unlist 声明) | +| `game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/publish/PublishOrchestrationServiceImpl.java` | 改(+unlist 实现) | +| `game-cloud/game-module-project/game-module-project-server/src/test/java/cn/wanxiang/game/module/project/service/project/ProjectServiceImplTest.java` | 改 | +| `game-cloud/game-module-project/game-module-project-server/src/test/java/cn/wanxiang/game/module/project/service/publish/PublishOrchestrationServiceImplTest.java` | 新建 | +| `game-cloud/game-module-feed/game-module-feed-api/src/main/java/cn/wanxiang/game/module/feed/api/FeedApi.java` | 改(+offlineRank 契约) | +| `game-cloud/game-module-feed/game-module-feed-server/src/main/java/cn/wanxiang/game/module/feed/api/FeedApiImpl.java` | 改 | +| `game-cloud/game-module-feed/game-module-feed-server/src/main/java/cn/wanxiang/game/module/feed/service/feed/FeedService.java` | 改 | +| `game-cloud/game-module-feed/game-module-feed-server/src/main/java/cn/wanxiang/game/module/feed/service/feed/FeedServiceImpl.java` | 改(+offlineRank 实现) | +| `game-cloud/game-module-feed/game-module-feed-server/src/main/java/cn/wanxiang/game/module/feed/dal/mysql/rank/FeedRankMapper.java` | 改 | +| `game-cloud/game-module-feed/game-module-feed-server/src/test/java/cn/wanxiang/game/module/feed/service/feed/FeedServiceImplTest.java` | 改 | + +> 与修1/修2零交集(telemetry vs project+feed)。注意:FeedApi 加方法对修1测试的 `@Mock FeedApi` 无影响(Mockito 动态代理)。 + +### 单测 + +1. `ProjectServiceImplTest`: + - 改写 `testReviewProject_unlistFromPublished`(L256,现断言直写 updateById)→ verify `publishOrchestrationService.unlist(project)` 被调 + 审核记录落库(gate=block)。 + - **新增 `testReviewProject_unlistOnNotPublishedRejected`(必备,状态机非法流转拒绝)**:REVIEWING 项目 decision=3 → assertThrows `PROJECT_REVIEW_STATUS_INVALID`,verify 编排零调用 + 审核记录零落库。 + - 适配 `testUnlistIfPublished_publishedUnlists`(L270)→ verify 委托编排。 +2. `PublishOrchestrationServiceImplTest`(新建): + - `testUnlist_flipsStatusAndOfflinesFeed`:verify `projectMapper.updateById(status=5)` + `feedApi.offlineRank(gameId)`。 + - `testUnlist_feedOfflineFailurePropagates`:桩 feedApi 抛错 → 异常向上传播(事务回滚由 Spring 框架保证,mock 层只断言传播,回滚整体性由 staging 冒烟③验证——如实声明边界)。 +3. `FeedServiceImplTest`:`testOfflineRank_delegatesAndReturnsRows`(verify mapper 调用 + 行数透传 + 0 行不抛错)。 + +### 验收断言 + +- 两模块测试绿:`-pl game-module-project/game-module-project-server -am test` 与 `-pl game-module-feed/game-module-feed-server -am test`。 +- decision=1/2 行为零变化(既有 approve/reject 用例不改语义全绿)。 + +--- + +## 集成与核验(核验员/主 agent) + +- 三 agent 产物镜像进**同一克隆**后整体编译+测试:`mvn -f /root/dr-wave-/game-cloud/pom.xml -pl game-module-telemetry/game-module-telemetry-server,game-module-project/game-module-project-server,game-module-feed/game-module-feed-server -am test`。 +- 修1↔修2 接缝 = 本 spec 冻结的 `insertOrAccumulate` 签名(契约先行);任何一方需改签名必须先回到主 agent 重新冻结。 + +## 部署后 staging 冒烟三断言(主 agent 部署窗口执行) + +1. **曝光中性(修1)**:取一已发布游戏 G,连发 N 条带 gameId 的 `game_impression` → `game_feed_rank(G)` 的 quality_score/sort_score **不变**、`game_telemetry_game_stat` 当日**无新增全零行**;再发 1 条 `game_play_end(completed=true)` → 当日 stat 行 play_end_count+1 且 feed_rank.quality_score 按口径刷新。 +2. **原子累加(修2)**:对 G 快速连发(尽量并发)K 条不同 eventId 的 `game_play_start` → 当日 `play_count` 恰好 +K(无丢增量);重放其中任一 eventId → 计数不变(入库层幂等仍生效)。 +3. **下架编排(修3)**:管理端 `POST /admin-api/project/review {gameId:G, versionId:V, decision:3, reason:…}` → `game_project.status=5`、`game_feed_rank` 该 gameId 全行 status=0、`/app-api/feed` 流不再出现 G、审核记录落库;**重复同请求** → 返回错误码 `1_100_000_004`、无新副作用;`game_runtime_package(V).status` 保持 1(裁决:不动)。 + +## 开放问题(主 agent 裁决/排期) + +1. **存量污染数据修复**:修1只防新增误降分;B2′ 已被刷成 quality=0 的 feed_rank 行不会自动恢复(要等该游戏下一条 engagement 事件重算自愈)。是否在部署窗口人工修数(UPDATE 受影响行)由主 agent 决定(本波 staging DB 对建设 agent 只读)。 +2. **ON DUPLICATE 语句过租户拦截器(jsqlparser 改写)属运行期行为**,单测/编译均不可达;staging 冒烟②若失败,按修2预案回退两段原子语句。 +3. **下架后存量直链仍可玩**(play 门禁权威=package.status,不查 project.status):本波裁决接受为残余;若产品要求「下架即断玩」,需另波在取包门禁加 project.status 校验。 +4. **favorite 累加缺口**(favorite_count 无映射、quality 公式不含 favorite)为既有问题,本波不补列;是否排期补齐由主 agent 定。 diff --git a/docs/agent-specs/2026-06-10-真实鉴权与匿名玩家-review.md b/docs/agent-specs/2026-06-10-真实鉴权与匿名玩家-review.md new file mode 100644 index 00000000..09d44262 --- /dev/null +++ b/docs/agent-specs/2026-06-10-真实鉴权与匿名玩家-review.md @@ -0,0 +1,201 @@ +# 真实鉴权与匿名玩家身份 — 评审版(创始人拍板用) + +> 文档类型:Review(结论先行,供拍板;定稿后另出 execution 版) +> 日期:2026-06-10 | 状态:待创始人拍板 | 作者:架构 Agent +> 关联:`docs/agent-specs/2026-06-09-下一阶段路线-plan.md:79`("真实鉴权…建显式 backlog"——本文即该 backlog 落实);A 轨种子名单(A2)到位即卡此件 +> 修订记录:2026-06-10 双镜头评审(CEO+Eng)11 条意见**全部采纳**落稿——①拆准入拍板项(玩家注册 vs 创作白名单)并在漏斗图标明非白名单去向;②如实摊开"报备前玩家侧注册转化不可用"时序阻塞+短信报备今天进 A 轨闸门看板;③登录方式选项补全 B 权衡并增 C 混合项;④依赖改两级(短信=硬闸门/隐私文案=软前置)+P-ACC-02 口径调整声明;⑤⑨"与 glossary 完全对齐"改为显式变更声明;⑥保留期降为既定假设;⑦新增 §5A 匿名身份安全边界(伪造/刷量/bot 排除三联);⑧新增 R6 发码端点滥用;⑩计数口径修为 app 28 处/7 文件;⑪生产 profile 未建立注记;⑫平迁断言标注推断。意见原文核验:所有代码/文档引用均经本仓复读证实。 + +--- + +## 0. 结论先行 + +**推荐方案乙:不复活 member 模块,在 yudao system 基座上做"最小玩家身份"——匿名免登读路径(刷 feed/试玩零门槛)+ 手机验证码登录发真 OAuth2 token(userType=MEMBER)+ 创作/发布种子期限 A2 白名单(玩家注册是否开放为独立拍板项);staging 的 mock(test1) 与真实鉴权天然并存、批跑不断,生产环境关死 mock。** + +- 体量:后端薄层 + 前端登录页/守卫,AI 压缩后约 **3–5 个有效工作日**(推断),远小于复活 member(约 1.5–2 周,含裁剪与装配返工)。 +- 外部依赖分两级:**硬日历闸门 = 生产短信签名报备**(影响通道切换与玩家转化,**今天即加入 A 轨闸门看板**,行动项见 R2);**软前置 = 隐私政策/用户协议文案**(可占位文案过渡,正式拉新前须法务定稿,挂 A 轨;P-ACC-02 口径调整声明见 §3)。 +- **时序如实摊开**:短信报备完成前,Debug 短信通道(验证码落库、后台查码、人工下发)**只对 5 个已知种子创作者可运营**;分享链路来的陌生玩家,运营不知道把码发给谁——**玩家侧"互动触发一键登录→注册"在报备前实质不可用**。种子期是否需要在报备前激活玩家转化漏斗,是本件最尖锐的拍板项(§9-2,与登录方式 §9-1 联动)。 +- 报备完成后切真实渠道≈配置切换、无二次改造,**但有安全前置**:发码端点必然免登,须先补按 IP/设备限频与图形验证码开关(现状为上游 TODO + captcha 关闭,见 R6)——不要把"切渠道"理解为纯配置动作。 +- 需创始人拍板的产品方向问题见 §9:登录方式与漏斗激活时机(联动)/ 准入语义(玩家注册、创作白名单分开拍)/ 游客转正机制 / 实名收集时点 / 匿名身份机制。 + +--- + +## 1. 背景与现状(亲核事实,标注出处) + +### 1.1 mock 鉴权怎么 mock 的 + +| 事实 | 出处 | +|---|---| +| yudao 框架原生 mock:`mock-enable=true` 时,token 以 `test` 开头即直造登录态,`test1`→userId=1;**撤 mock=改一行配置,无代码改动** | `game-cloud/yudao-framework/yudao-spring-boot-starter-security/.../TokenAuthenticationFilter.java:117-129`、`SecurityProperties.java:34-40` | +| 校验顺序=**先查真 OAuth2 token,查不到才走 mock**——真实鉴权与 mock 天然可并存 | `TokenAuthenticationFilter.java:60-65` | +| staging/local 均 `mock-enable: true`;`tenant.enable: false`(单租户);验证码关 | `yudao-server/.../application-staging.yaml:152-157` | +| userType 按 URL 前缀推导:`/app-api`→MEMBER(1)(创作者/玩家),`/admin-api`→ADMIN(2)(运营) | `WebFrameworkUtils.java:105-122`、`UserTypeEnum.java:17-18` | +| staging 批跑(黄金闭环 e2e、agent 化生成 QA 闭环)全依赖 `Bearer test1` | 记忆 `golden-loop-b1-done`、`game-studio/.env.staging:5` | + +### 1.2 既有能力盘点(都在,不用新造轮子) + +| 能力 | 现状 | 出处 | +|---|---|---| +| C 端账号模块 member | **已整体裁剪**(11 个 demo 模块之一),无目录、server 引用被注释 | `game-cloud/pom.xml:16`、`yudao-server/pom.xml:96` | +| OAuth2 token 服务 | system 基座完备,**user-type 无关**:可直接发 userType=MEMBER 的真 token,框架过滤器原生校验 | `OAuth2TokenApiImpl.java:28`、`OAuth2AccessTokenCreateReqDTO.java:23` | +| 短信验证码 | 场景化验证码 API(`MEMBER_LOGIN` 场景已内置)+ 5 个渠道客户端(阿里云/腾讯/华为/七牛/**Debug钉钉**) | `system/api/sms/SmsCodeApi.java`、`SmsSceneEnum.java:19`、`framework/sms/core/client/impl/` | +| 管理端登录 | game-admin 原生 Login.vue + system 账密/短信登录端点齐备,staging 已实证可用(20 真实用户) | `game-admin/src/views/Login/`、`AuthController.java`、记忆 `m1-runtime-bringup-state` | +| 前端匿名 ID | `ensureAnonId()` localStorage 持久化;遥测上报已带 `user:{userId, anonId}` 双身份 | `game-studio/src/store/user.ts:10-24`、`src/telemetry/index.ts:143-160` | +| 遥测契约匿名位 | `UserVO{userId, anonId}`("匿名 token 派生的稳定匿名 ID")契约已锁定 | `EnvelopeReqVO.java:61-66`、`contracts/events.schema.json` | +| 既定设计意向 | "匿名玩家通过 framework 层扩展的匿名 Token 机制接入,只能浏览试玩、不能发布/收藏/进后台" | `.agents/knowledge/glossary.md:40` | + +### 1.3 缺口与既有错位 + +- **app-api 无任何登录端点**(member 裁剪后 C 端鉴权整体缺位);**app 控制器 28 处 `getLoginUserId()`(7 文件)**全靠 mock 喂身份,另有 admin 控制器 3 处(3 文件:AdminProjectController/ComplianceBanController/TradeAdminController)——admin 侧自 M1 起有真实登录,**不在撤 mock 的 C 端回归面内**(grep 实证,合计 31 处/10 文件)。 +- **app-api 全部强制登录**:feed/runtime/telemetry 无一处 `@PermitAll`(grep 0 命中)——今天"匿名刷 feed"实际不可能,靠 test1 掩盖。 +- **身份错位既有 bug**:前端固定 `userId='1001'`(`store/user.ts:30`)vs 后端 mock `test1`→userId=1——同一行为遥测记 1001、互动落库记 1,数据归属已错位。这是 mock 的真实危害样本,真实登录后自然消除。 +- 前端无登录 UI、无路由守卫(views/ 无 login,router 无 beforeEach;`setLogin/logout` 已预留空挂点 `store/user.ts:44-57`)。 +- 产品口径:MVP 黄金链路第一步="创作者登录"(`.agents/knowledge/mvp-scope-and-milestones.md:11`);账号 owner=system 基座(`需求模块映射.md:237,258`);安全基线=OAuth2+JWT+Refresh 轮换 7d/30d、手机号脱敏、隐私政策注册前展示(`.agents/rules/security-and-reliability.md:15,113,151`)。 + +--- + +## 2. 目标 + +1. **种子创作者真实登录**:手机号验证码登录→创作→发布全链真 token,支撑 A2 种子名单到位即可试用(**白名单只限创作/发布**、内容风险可控;玩家注册是否开放为独立拍板项,见 §9-3/§9-4——两者语义不同,勿混)。 +2. **匿名玩家零门槛**:不注册即可刷 feed、试玩、被遥测(短视频式体验);互动/创作才要求身份。 +3. **身份数据可衔接**:匿名 anonId ↔ 登录 userId 可关联(登录事件绑定),点赞收藏归属真实 userId,为后续支付实名留好挂点。 +4. **staging 批跑零中断**:mock 与真实鉴权并存;生产关死 mock 并有负路径验证。 + +## 3. 非目标(明确不做) + +- **第三方社交登录**(微信/抖音/小程序授权):MVP 不做;渠道版与小游戏提审闸门联动时再评。 +- **多租户**:维持 `tenant.enable=false` 单租户;数据行保留 `tenant_id=1` 兼容,不开租户拦截。 +- **生产短信供应商采购与签名报备**:属 A 轨日历闸门(记忆 `mvp-binding-constraint-calendar-gates`),不在本设计内,但是切真实短信通道的前提。 +- **防沉迷/学生账号(P-BIZ-11)、适龄分级展示(P-ACC-03)**:均 v2.0(`产品需求清单.md:216,239`)。 +- member 式运营包袱:积分/等级/签到/标签/分组;账号全功能(找回密码/换绑/注销)只留最小集(验证码登录天然免密码体系)。 + +> **口径调整声明(P-ACC-02 提前)**:隐私政策/用户协议展示(P-ACC-02)在产品需求清单中原排 v2.0(`产品需求清单.md:238`),但安全基线要求"隐私政策注册前展示"(`.agents/rules/security-and-reliability.md`)——本件验收标准 5 实质把它以**占位文案**形式提前到 MVP。处理方式:占位文案随注册页一并上线(软前置);正式文案法务定稿挂 A 轨隐私协议项,**不视为 P-ACC-02 的 v2.0 全功能整体提前**,避免与产品清单排期打架。 + +--- + +## 4. 方案对比(2 个可行 + 1 个反例) + +| 维度 | 甲:复活 yudao-module-member 全量 | **乙:system 基座最小玩家身份(推荐)** | 丙:纯匿名 + 创作者借用 ADMIN 体系 | +|---|---|---|---| +| 做法 | 从上游恢复 member 模块(用户表/AppAuth/profile),裁掉积分等级等无关件 | 新增 `game_player` 表 + app 端登录端点;复用 system 的 SmsCodeApi+OAuth2TokenApi 发 MEMBER 真 token;读路径 `@PermitAll`+anonId | 玩家全匿名;创作者用 system 账号走 ADMIN token | +| 体量(推断) | ~1.5–2 周:恢复+裁剪+M1 装配教训重走(repackage/Flyway/CommonApi @Primary,记忆 `m1-runtime-bringup-state`) | **~3–5 天**:后端薄层 + 前端登录页/守卫 + e2e | ~1–2 天 | +| 单租户契合 | member 自带租户/多端包袱,逆向裁剪 | 原生贴合,表带 tenant_id=1 即可 | — | +| 演进性 | 微信小程序登录等开箱(远期优势) | 实名/转正/三方登录挂点预留;真需要 member 时**迁移路径存在**(推断:平迁成立条件=member_user 表从未启用、ID 段不冲突,且 OAuth2 token 内 userId 语义切换需迁移映射——成本待执行版评估) | **死路**:ADMIN token 调 app-api 被 userType 校验拒(`TokenAuthenticationFilter.java:92-95`),要么破坏 API 约定要么放宽安全边界 | +| 风险 | 大块外来代码引入审计面;MVP 窗口占用 | fork 侵入面小(新增包路径隔离) | 身份模型畸形:互动无归属、实名无挂靠,违背 glossary 既定意向 | +| 结论 | 远期备选(需要会员运营体系时再评) | **采纳** | 仅作反例,否决 | + +**推荐乙的核心理由**:①账号 owner=system 是三文档套件既定口径(Doc C:258),扩展落点与文档一致、零跨模块 RPC;②OAuth2/SMS/过滤器全是现成基座能力,新写的只有"一张表+三个端点+一个登录页";③匿名**行为边界**与 glossary 既定意向一致(只能浏览试玩、不能发布/收藏/进后台),但**实现机制是对既定意向的显式变更**——由"framework 层匿名 Token"简化为免登读+anonId(更克制;变更声明、安全代价与待回写文档清单见 §5 关键设计点 1 与 §5A),属文档级返工(glossary 词条+契约 anonId 描述需同步修订),无代码级返工。 +*落点工程细节(system 内扩展 vs 新薄模块 passport)不需创始人拍板,执行版评审定稿;倾向 system 内扩展(口径一致+零 RPC),备选新模块(fork 零侵入)。* + +--- + +## 5. 推荐方案乙:身份模型与关键设计 + +```mermaid +flowchart LR + subgraph P[玩家侧(匿名优先,短视频式)] + A["打开 game-studio
本地 anonId(已有)"] -->|免登录| B["刷 feed / 试玩 / 遥测上报
读路径 @PermitAll + anonId 透传"] + B -->|点赞·收藏·支付 触发| C{"一键登录
手机号+验证码"} + C --> W{"准入判定
(§9-3 玩家注册 / §9-4 创作白名单
两个独立拍板项)"} + W -->|"推荐语义:玩家注册开放
任意手机号可注册"| PD["注册成真实玩家
可点赞/收藏,不能创作/发布"] + W -.->|"若拍'注册整体限 A2 白名单':
非白名单手机号被拒
=玩家转化漏斗第一跳即断"| X["流失(反例去向,
仅当放弃种子期玩家增长才可接受)"] + end + subgraph CRE[创作者侧(创作/发布限 A2 白名单 §9-4)] + PD -->|"手机号 ∈ A2 白名单"| D["game_player 创作者身份
OAuth2 真 token (userType=MEMBER)"] + D --> E["创作 / 发布 / 钱包
app-api 默认强制登录(不动)"] + E -->|提现前| F["实名收集
挂 A 轨支付进件闸门"] + end + subgraph OPS[运营侧(现状即可用)] + G["game-admin 原生登录
system AdminUser (userType=ADMIN)"] --> H["admin-api 审核/运营"] + end + B -. anonId .-> I[("telemetry
双身份可关联")] + PD -. "登录事件绑定 anonId↔userId" .-> I +``` + +> 图注:**白名单语义拆分**——推荐语义为"玩家注册开放(漏斗存活)+ 创作/发布限 A2 白名单(内容风险收口)";非白名单手机号用户的去向=可注册、可互动、**不能创作/发布**。若创始人改拍"注册整体白名单",则虚线分支生效、匿名玩家转化漏斗在第一跳被设计性切断——该后果必须有意识地拍,不能默认。另:报备前该漏斗还受短信通道时序阻塞(见 §0 与 §9-2),与准入语义是两个独立断点。 + +关键设计点: + +1. **匿名玩家=免登读路径,不发匿名 token——对 glossary 既定意向的显式变更声明**。feed 流/分享页/runtime 取包/遥测上报等约 7–10 个读端点加 `@PermitAll`,身份用前端既有 anonId 透传(契约已留位);**广告曝光是计费链上游,是否进免登清单单独评审(见 §5A)**。行为边界与 glossary 一致(只能浏览试玩、不能发布/收藏/进后台),但机制偏离既定意向"framework 层扩展匿名 Token"(`glossary.md:40`):**偏离理由**=免去匿名 token 签发/存储/续期厚度(token 存储量=真实登录用户,容量可控);**安全代价**=anonId 纯客户端生成、身份不可信,防伪造/刷量责任转嫁聚合侧(机制备选与归属见 §5A,§9-7 拍板);**定稿后需同步修订的文档**=`glossary.md:40` 匿名条目 + `contracts/events.schema.json` 与 `EnvelopeReqVO` 的 anonId 描述(现仍写"匿名 token 派生的稳定匿名 ID"),防止后续 agent 按旧意向去造匿名 token 机制。若 §9-7 拍 A(服务端匿名票据),机制回归 glossary 原意向、本变更声明作废。 +2. **互动是身份转化点**。点赞/收藏(`AppFeedController.interact`,其注释已预埋"匿名互动 anon_id 为后续对接点")默认弹一键登录;是否做"匿名影子账号+转正合并"由创始人拍板(§9-5)。 +3. **登录方式一次成型,短信通道分级——但 Debug 通道只对创作者侧可运营**。UI/API 首发即手机号+验证码(C 端习惯,复用 `MEMBER_LOGIN` 场景);种子期走 Debug 渠道(验证码落库,运营后台查码人工下发给 5 个已知种子创作者)。**对分享链路来的陌生玩家该通道完全不可运营(运营不知道把码发给谁)——报备前玩家侧注册转化实质不可用**,是否需要提前激活见 §9-2(选 B 账密或 C 邀请码旁路可绕开短信依赖)。短信报备闸门完成后切渠道≈配置切换、无二次改造,**但须先落安全前置(IP/设备限频+验证码开关,R6),不是纯配置动作**。 +4. **telemetry 身份衔接**。登录成功补发一条登录事件携带 anonId+userId;此后上报双带(前端已实现)——离线归因可把登录前行为串回同一人。 +5. **创作者实名链**。`game_player` 预留 `mobile/real_name/id_card_no(加密)` 字段;MVP 注册只收手机号,**实名收集后置到提现前**(降低注册摩擦,与 A 轨 LLM 实名/支付进件闸门节奏对齐,拍板项 §9-6)。 +6. **单租户立场**。维持 `tenant.enable=false`;新表带 `tenant_id` 默认 1,将来开租户无需改表。 +7. **既定假设(不进拍板表):匿名数据保留期默认 180 天**。这是合规参数而非产品方向决策——默认值写入隐私政策草案,随 A 轨隐私协议法务复核时一并定稿;法务若调整,仅改清理任务配置与政策文案,不影响架构。 + +### 5A. 匿名身份安全边界(伪造 / 刷量 / bot 排除三联) + +**现状亲核(不粉饰)**:anonId 为前端 localStorage 随机串(`game-studio/src/store/user.ts:10-24`),**可任意伪造、可批量铸造**;telemetry 入口对它的全部"校验"只有 traceId 的 `@NotBlank` 存在性校验 + `(traceId,event,ts)` 幂等键(`EnvelopeReqVO.java:41`、`TelemetryEventConsumer.java:12`)——**对变造 ts/traceId 的批量刷量无任何约束**;game-module-telemetry/feed 现无任何 bot 排除/反作弊代码(grep 0 命中),`技术架构与模块.md` 亦无对应技术项。 + +**利害关系(为什么不能含糊)**:遥测事件直接喂 quality_score→feed 推荐排序(数据回路已通,黄金闭环 B2);广告曝光事件后接 trade T+1 分账。**伪造匿名事件可操纵推荐排序与未来真金分成**——免登读路径放开前,这条边界必须先写明。 + +**机制备选(§9-7 拍板)**: + +| 维度 | A:服务端签发签名匿名设备票据 | B:纯客户端 anonId + 聚合侧异常剔除(建议种子期) | +|---|---|---| +| 做法 | 首访由服务端签发带签名的匿名设备票据,anonId 服务端派生;可限频、可撤销、可加签发难度 | 维持现状 anonId 透传;聚合侧按 anonId/IP 维度做异常剔除 | +| 身份可信度 | 可信(伪造需破签名) | **不可信(任意铸造)**,靠事后剔除兜底 | +| 实现厚度 | +签发端点/密钥管理/前端改造(推断 +1–2 天) | 最薄(前端零改动),种子期挂"聚合侧按 anonId/IP 异常剔除"最小桩即可 | +| 与 glossary 既定意向 | 贴合(即"匿名 Token"原意向,§5-1 变更声明作废) | 偏离(需按 §5-1 走显式变更声明) | + +**防刷归属(边界与 owner,拍板前即写明)**: + +- quality_score 喂入事件的异常剔除 → **telemetry 聚合侧**(owner=telemetry 模块);种子期最小桩=按 anonId/IP 维度异常剔除(阈值可粗暴),桩可薄、**归属边界必须先立**。 +- 广告曝光计费事件的反作弊 → **compliance/ad 反作弊项**(owner=compliance;`技术架构与模块.md` 现无此技术项,定稿后补列)。 + +**广告曝光单独标注**:它是计费链上游(T+1 分账依据),**不是普通读路径**——是否进免登 `@PermitAll` 清单单独评审;若进,必须同时挂聚合侧剔除桩+曝光校验(执行版定细节)。 + +**落地依赖(执行版注明)**:`@RateLimiter` 所在 `yudao-spring-boot-starter-protection` 仅 yudao-server/system/bpm 引入,**game-module-\* 的 pom 均未依赖**(grep 实证)——限频落地需先补该依赖。 + +--- + +## 6. 影响面 + +| 端 | 改动 | 说明 | +|---|---|---| +| 后端 game-cloud | 新增:`game_player` 表(Flyway 新版本)+ app 端 3 端点(发验证码/验证码登录/我的信息)+ 准入开关(按 §9-3/§9-4 拍板语义配置);存量:读端点加 `@PermitAll` 注解与 anonId 入参(每处 1–2 行) | 创作/交易/项目端点**不动**(默认强制登录即正确行为)。**发验证码端点必然 `@PermitAll`**:切真实短信渠道前须补 IP/设备限频+验证码开关(R6);限频依赖 protection starter,game 模块 pom 需补(§5A) | +| 前端 game-studio | 新增登录页(Vant)+ 路由守卫(创作/互动需登录,feed/play 放行)+ 401 统一拦截跳登录;`store/user.ts.setLogin` 已预留挂点 | `.env.staging` 与 mock 回退逻辑**保留不动** | +| 前端 game-admin | **零改动**(原生登录已可用) | — | +| 契约 contracts | 新增 `passport.yaml`(登录/验证码/匿名约定);telemetry/events 契约已含 anonId 不动 | 契约先行(§6 工作协议) | +| staging | `mock-enable=true` 保持:过滤器"真 token 优先、mock 兜底"天然并存,**批跑 test1 零中断**;新增一条"真 token 黄金闭环 e2e"并行验证 | 出处 §1.1 | +| 生产 | `mock-enable=false` + `mockSecret` 随机化(防 `test` 前缀默认值);上线验收含负路径 | 框架注释明示"线上一定要关"(`TokenAuthenticationFilter.java:110`)。**前置未点明项**:yudao-server 现仅有 dev/local/staging 三份 profile(grep 实证),**生产 profile 尚未建立**——"生产关死 mock 负路径实测"挂生产环境部署轨,部署项落地后补测 | + +## 7. 风险与兼容 + +| # | 风险 | 等级 | 缓解 | +|---|---|---|---| +| R1 | 撤 mock 回归面:app 控制器 28 处 `getLoginUserId`(7 文件)依赖登录态(admin 侧 3 处自 M1 有真实登录,不在面内) | 中 | staging 不撤 mock,新增真 token e2e 并行跑;生产负路径验收(test1→401,挂部署轨见 §6) | +| R2 | 短信报备闸门延误(1–2 周不可压缩);**且报备完成前玩家侧注册转化整体不可用(Debug 通道只对 5 个已知创作者可运营,陌生玩家无法拿码)** | **高** | **行动项(今天执行):短信签名报备加入 A 轨闸门看板**——plan(2026-06-09 §2.A)看板枚举现无此项;含其自身前置:企业主体资质、部分供应商要求已备案域名(可能与 ICP 串联)。创作者侧 Debug 通道先行;玩家侧是否报备前激活走 §9-2 拍板(选 B/C 可绕开短信依赖) | +| R3 | 匿名读路径放开后的伪造/刷量:anonId 可任意铸造;telemetry 入口仅 traceId 存在性校验+`(traceId,event,ts)` 幂等键,**对变造刷量无约束**;伪造事件可操纵 quality_score 推荐与未来广告分账 | 中 | 限频:`@PermitAll` 端点接 yudao RateLimiter+Nginx 限频(呼应 T-CMP-31"登录安全与限频";game 模块需补 protection starter 依赖);身份机制与防刷归属见 §5A(种子期最小桩=聚合侧按 anonId/IP 异常剔除,机制 §9-7 拍板) | +| R4 | 前端 userId 1001/后端 1 错位(既有) | 低 | 真实登录后 userId 以登录响应为准,错位自然消除;mock 路径维持现状不动(批跑兼容) | +| R5 | fork 侵入(system 落点) | 低 | 新增代码隔离在独立包路径,上游合并冲突面小;备选新模块零侵入 | +| R6 | **切真实短信渠道后的发码端点滥用**(短信轰炸/费用滥用——手机验证码登录经典攻击面):发码端点必然 `@PermitAll`;现状仅按手机号频控+日上限,**按 IP 的每日/每小时限制是上游 TODO**(`SmsCodeServiceImpl.java:65-66`),且 staging `captcha.enable=false`(`application-staging.yaml:155`) | 中 | 切渠道的安全前置(与"配置切换"绑定排期,不可省):按 IP/设备限频 + 图形/行为验证码开关(需可一键启用);种子期 Debug 渠道+白名单下风险≈0,前置随切渠道一并落地,列入执行版 | + +兼容性:API 前缀/userType 约定不变;既有契约零破坏;game-admin 零改动;所有新表带 tenant_id 兼容将来多租户。 + +## 8. 验收标准 + +1. **真 token 全链**:种子创作者验证码登录→创作→发布→审核→feed 可见,全程无 test1(staging 真 token e2e 证据)。 +2. **匿名零门槛**:无登录刷 feed/试玩成功,遥测 anonId 落库;点赞触发登录引导。 +3. **身份衔接**:登录事件含 anonId+userId;互动归属真实 userId(1001/1 错位消除)。 +4. **mock 并存**:staging 既有批跑(Bearer test1)零中断;生产 profile `Bearer test1`→401(负路径实测。**注:生产 profile 尚未建立,此条挂生产环境部署轨,部署落地后补测,不阻塞本件其余验收**)。 +5. **安全基线**:refresh 轮换 7d/30d 生效;日志手机号/token 脱敏抽查;隐私政策注册前展示**占位文案**(P-ACC-02 原 v2.0 提前的口径调整见 §3 声明,正式文案挂 A 轨法务定稿)。 +6. **运营不回归**:game-admin 原生登录链路冒烟通过。 + +## 9. 待创始人拍板项 + +> 表已按评审意见收敛与拆分:移除"匿名数据保留期"(合规参数,降为 §5 既定假设 7);原"种子期注册准入"因语义混淆拆为 #3/#4 两个独立项;新增 #2 漏斗激活时机(最尖锐)与 #7 匿名身份机制。**#1 与 #2 联动拍,#3 与 #4 分开拍。** + +| # | 决策 | 选项与建议 | +|---|---|---| +| 1 | **首发登录方式**(与 #2 联动) | A=手机号+验证码(C 端习惯、一次成型;**但报备前玩家侧转化不可用**,种子创作者后台查码过渡);B=账密+邀请码(包袱:后续二次改造+密码找回体系;**核心优势此前漏列:对玩家转化也零短信依赖,匿名→注册漏斗在短信报备闸门完成前即可激活**);C=验证码为主+邀请码旁路(混合:创作者走验证码 Debug 通道,陌生玩家凭邀请码/口令注册,报备后旁路自然退役)。**若 #2 拍"需要激活"→建议 C(或 B);拍"不需要"→建议 A** | +| 2 | **种子期是否需要在报备前激活玩家转化漏斗**(最尖锐,决定 #1 走向) | 是=分享拉来的路人在报备前就能注册互动,提前验证"创作者分享→玩家转化"假设 → #1 必须选 B 或 C;否=种子期只验证创作者闭环,玩家转化等报备落地(漏斗数据晚 1–2 周以上)→ #1 选 A 即可 | +| 3 | **玩家注册是否开放**(影响匿名转化漏斗存活) | A=开放注册(**建议**:匿名玩家点赞→一键登录→注册成玩家,漏斗不断;内容风险不在注册侧,在发布侧);B=注册整体限 A2 白名单(约 5 个手机号——非名单路人被拒,**玩家转化漏斗第一跳被设计性切断**,仅当种子期明确放弃玩家增长才可选) | +| 4 | **创作/发布是否限 A2 白名单**(影响内容风险) | A=限 A2 名单(**建议**:内容上架风险收口在 5 个已知创作者,审核压力可控);B=开放创作(流量大但审核压力前置,种子期不建议) | +| 5 | **游客转正机制** | A=互动即弹一键登录(**建议**:MVP 最简,转化点清晰);B=匿名影子账号+转正数据合并(体验最顺滑,但合并逻辑贵,建议 v2.0 再评) | +| 6 | **创作者实名收集时点** | A=提现前(**建议**:注册零摩擦,与支付进件闸门对齐);B=注册即收(合规最保守,但伤种子转化) | +| 7 | **匿名身份机制**(安全边界,详见 §5A) | A=服务端签发签名匿名设备票据(身份可信、可限频可撤销,贴合 glossary 原意向;+1–2 天);B=纯客户端 anonId+聚合侧异常剔除(**建议种子期**:实现最薄、前端零改动;代价=身份不可信、靠事后剔除兜底,并接受 §5-1 的 glossary 变更声明) | + +--- + +*出处均为本仓实读核验(2026-06-10);体量与"+1–2 天"类估算为推断,以执行版排期为准。下一步:拍板后出 execution 版(含落点定稿/端点清单/Flyway 版本/e2e 用例/§5A 聚合侧剔除最小桩与 protection starter 依赖补齐/R6 切渠道安全前置),并同步回写文档:`glossary.md:40` 匿名条目、`contracts/events.schema.json` 与 `EnvelopeReqVO` 的 anonId 描述(若 §9-7 拍 B)、`技术架构与模块.md` 补广告反作弊技术项。* diff --git a/game-cloud/game-module-feed/game-module-feed-api/src/main/java/cn/wanxiang/game/module/feed/api/FeedApi.java b/game-cloud/game-module-feed/game-module-feed-api/src/main/java/cn/wanxiang/game/module/feed/api/FeedApi.java index af121c81..f531b459 100644 --- a/game-cloud/game-module-feed/game-module-feed-api/src/main/java/cn/wanxiang/game/module/feed/api/FeedApi.java +++ b/game-cloud/game-module-feed/game-module-feed-api/src/main/java/cn/wanxiang/game/module/feed/api/FeedApi.java @@ -4,10 +4,12 @@ import cn.wanxiang.game.module.feed.dto.FeedRankUpsertReqDTO; import cn.wanxiang.game.module.feed.enums.ApiConstants; import cn.iocoder.yudao.framework.common.pojo.CommonResult; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; /** * RPC 服务 - 游戏流排序 API(feed 对外写排序行契约,黄金闭环新增) @@ -43,4 +45,22 @@ public interface FeedApi { @Operation(summary = "upsert 游戏流排序行(发布基线/算分回灌两路)") CommonResult upsertRank(@RequestBody FeedRankUpsertReqDTO req); + /** + * 按游戏全分区下线排序行(status=0,下架编排反向操作,数据回路小波·修3) + * + * 用途:下架编排(project-server,review decision=3 UNLIST)在翻项目态 4→5 后调用本方法,把该 gameId 在 + * game_feed_rank 的**全部分区行**置 status=0 屏蔽(uk_game_zone 下同游戏可有多分区行:发布基线 + setFeatured,须按 gameId 全下); + * 对齐 V4 DDL 设计意图「status:0屏蔽(举报降权/下架联动)1可出流」,封死写侧残留(读侧 filterByProjectPublished 兜底仍保留)。 + * 不复用 upsertRank:其 (gameId,zoneId) 单行 upsert 形态承载不了「全分区下线」语义,硬塞第三 mode 属拼接设计,故另立窄契约。 + * 幂等语义:天然幂等(已 0 置 0 无副作用);下线行数 0 也合法(无基线行的存量数据),由调用方记日志不报错。 + * 同步事务语义:调用方(下架编排)在本地 @Transactional 内调用本 @Primary 本地 bean,本方法抛错则下架事务整体回滚。 + * + * @param gameId 游戏 ID(game_project.id) + * @return 下线行数(CommonResult 包裹;0=无在流行,合法) + */ + @PostMapping(PREFIX + "/offline-rank") + @Operation(summary = "按游戏全分区下线排序行(status=0,下架编排反向操作)") + @Parameter(name = "gameId", description = "游戏 ID", required = true, example = "1024") + CommonResult offlineRank(@RequestParam("gameId") Long gameId); + } diff --git a/game-cloud/game-module-feed/game-module-feed-server/src/main/java/cn/wanxiang/game/module/feed/api/FeedApiImpl.java b/game-cloud/game-module-feed/game-module-feed-server/src/main/java/cn/wanxiang/game/module/feed/api/FeedApiImpl.java index 78e8131d..957ba951 100644 --- a/game-cloud/game-module-feed/game-module-feed-server/src/main/java/cn/wanxiang/game/module/feed/api/FeedApiImpl.java +++ b/game-cloud/game-module-feed/game-module-feed-server/src/main/java/cn/wanxiang/game/module/feed/api/FeedApiImpl.java @@ -34,4 +34,10 @@ public class FeedApiImpl implements FeedApi { return success(Boolean.TRUE); } + @Override + public CommonResult offlineRank(Long gameId) { + // 下架编排反向操作(数据回路小波·修3):按 gameId 全分区置 status=0 屏蔽,行数透传给调用方记审计日志(业务与日志在 Service) + return success(feedService.offlineRank(gameId)); + } + } diff --git a/game-cloud/game-module-feed/game-module-feed-server/src/main/java/cn/wanxiang/game/module/feed/dal/mysql/rank/FeedRankMapper.java b/game-cloud/game-module-feed/game-module-feed-server/src/main/java/cn/wanxiang/game/module/feed/dal/mysql/rank/FeedRankMapper.java index 686dabea..80c14370 100644 --- a/game-cloud/game-module-feed/game-module-feed-server/src/main/java/cn/wanxiang/game/module/feed/dal/mysql/rank/FeedRankMapper.java +++ b/game-cloud/game-module-feed/game-module-feed-server/src/main/java/cn/wanxiang/game/module/feed/dal/mysql/rank/FeedRankMapper.java @@ -61,4 +61,21 @@ public interface FeedRankMapper extends BaseMapperX { .eq(FeedRankDO::getZoneId, zoneId)); } + /** + * 按游戏全分区下线排序行(status=0 屏蔽,下架编排反向操作,数据回路小波·修3) + * + * 实体仅置 status=0(其余字段 null 由 MyBatis-Plus 默认策略跳过,不覆盖原值),wrapper 按 gameId 命中全部分区行; + * 走 MP update(entity, wrapper) 既有范式:享审计列自动填充(updater/update_time)与租户拦截(tenant_id 条件自动追加)。 + * 幂等:已 0 置 0 无副作用;0 行也合法(无基线行),由 Service 记日志。 + * + * @param gameId 游戏 ID + * @return 受影响行数(下线行数,0=无在流行) + */ + default int updateStatusOfflineByGameId(Long gameId) { + FeedRankDO update = new FeedRankDO(); + update.setStatus(0); // 0=屏蔽(V4 DDL:举报降权/下架联动) + return update(update, new LambdaQueryWrapperX() + .eq(FeedRankDO::getGameId, gameId)); + } + } diff --git a/game-cloud/game-module-feed/game-module-feed-server/src/main/java/cn/wanxiang/game/module/feed/service/feed/FeedService.java b/game-cloud/game-module-feed/game-module-feed-server/src/main/java/cn/wanxiang/game/module/feed/service/feed/FeedService.java index 6e3b123b..0d072ba5 100644 --- a/game-cloud/game-module-feed/game-module-feed-server/src/main/java/cn/wanxiang/game/module/feed/service/feed/FeedService.java +++ b/game-cloud/game-module-feed/game-module-feed-server/src/main/java/cn/wanxiang/game/module/feed/service/feed/FeedService.java @@ -98,4 +98,17 @@ public interface FeedService { */ void upsertRank(FeedRankUpsertReqDTO req); + /** + * 按游戏全分区下线排序行(status=0,下架编排反向操作,数据回路小波·修3) + * + * 下架编排(project review decision=3 UNLIST)经 FeedApi seam 调用:把该 gameId 在 game_feed_rank 的 + * 全部分区行置 status=0 屏蔽(同游戏可有多分区行,须按 gameId 全下),封死写侧残留。 + * 幂等:已 0 置 0 无副作用;0 行也合法(无基线行的存量数据),记日志不抛错。 + * 同步事务语义:由调用方(下架编排)在其本地 @Transactional 内调用,本方法抛错则上游整体回滚。 + * + * @param gameId 游戏 ID + * @return 下线行数(0=无在流行,合法) + */ + int offlineRank(Long gameId); + } diff --git a/game-cloud/game-module-feed/game-module-feed-server/src/main/java/cn/wanxiang/game/module/feed/service/feed/FeedServiceImpl.java b/game-cloud/game-module-feed/game-module-feed-server/src/main/java/cn/wanxiang/game/module/feed/service/feed/FeedServiceImpl.java index 338c1102..981699e1 100644 --- a/game-cloud/game-module-feed/game-module-feed-server/src/main/java/cn/wanxiang/game/module/feed/service/feed/FeedServiceImpl.java +++ b/game-cloud/game-module-feed/game-module-feed-server/src/main/java/cn/wanxiang/game/module/feed/service/feed/FeedServiceImpl.java @@ -424,6 +424,18 @@ public class FeedServiceImpl implements FeedService { req.getGameId(), zoneId, quality, boost, quality.add(boost)); } + // ============================== 排序行全分区下线(下架编排反向操作,数据回路小波·修3)============================== + + @Override + @Transactional(rollbackFor = Exception.class) // 由上游(下架编排)本地事务内调用;本方法抛错则上游整体回滚(与 upsertRank 同口径) + public int offlineRank(Long gameId) { + // 全分区置 status=0 屏蔽:同游戏在 uk_game_zone 下可有多分区行(发布基线 + setFeatured),须按 gameId 全下,封死写侧残留。 + // 幂等:已 0 置 0 无副作用;0 行也合法(无基线行的存量数据),记日志不抛错(无业务失败分支,不增错误码)。 + int rows = feedRankMapper.updateStatusOfflineByGameId(gameId); + log.info("[offlineRank] 下架联动全分区下线排序行 gameId={}, offlinedRows={}", gameId, rows); + return rows; + } + /** * Double 排序值 → BigDecimal(feed_rank 列为 DECIMAL10,4;null 安全缺省 0) * diff --git a/game-cloud/game-module-feed/game-module-feed-server/src/test/java/cn/wanxiang/game/module/feed/service/feed/FeedServiceImplTest.java b/game-cloud/game-module-feed/game-module-feed-server/src/test/java/cn/wanxiang/game/module/feed/service/feed/FeedServiceImplTest.java index b2bee40d..00742f0f 100644 --- a/game-cloud/game-module-feed/game-module-feed-server/src/test/java/cn/wanxiang/game/module/feed/service/feed/FeedServiceImplTest.java +++ b/game-cloud/game-module-feed/game-module-feed-server/src/test/java/cn/wanxiang/game/module/feed/service/feed/FeedServiceImplTest.java @@ -344,6 +344,30 @@ class FeedServiceImplTest extends BaseMockitoUnitTest { assertEquals(FEED_RANK_UPSERT_MODE_REQUIRED.getCode(), ex.getCode()); } + // ============================== 排序行全分区下线(下架编排反向操作,数据回路小波·修3)============================== + + @Test + void testOfflineRank_delegatesAndReturnsRows() { + // 委托 mapper 按 gameId 全分区置 status=0,下线行数原样透传(供下架编排记审计日志) + when(feedRankMapper.updateStatusOfflineByGameId(1L)).thenReturn(2); // 桩:命中 2 行(发布基线 + 精选分区) + + int rows = feedService.offlineRank(1L); + + assertEquals(2, rows); // 行数透传 + verify(feedRankMapper).updateStatusOfflineByGameId(1L); // 按 gameId 全分区下线 + } + + @Test + void testOfflineRank_zeroRowsNoError() { + // 0 行也合法(无基线行的存量数据 / 重复下架已 0 置 0):不抛错,返回 0 由调用方记日志 + when(feedRankMapper.updateStatusOfflineByGameId(1L)).thenReturn(0); + + int rows = assertDoesNotThrow(() -> feedService.offlineRank(1L)); + + assertEquals(0, rows); + verify(feedRankMapper).updateStatusOfflineByGameId(1L); + } + // ============================== 测试夹具 ============================== /** 构造排序覆盖层记录 */ diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/project/ProjectServiceImpl.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/project/ProjectServiceImpl.java index 60f8b731..217b35e6 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/project/ProjectServiceImpl.java +++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/project/ProjectServiceImpl.java @@ -190,17 +190,21 @@ public class ProjectServiceImpl implements ProjectService { } @Override - @Transactional(rollbackFor = Exception.class) // 改状态 + 落审核记录 +(APPROVE)发布编排,需同一本地事务(§3.2 C2 事务原子) + @Transactional(rollbackFor = Exception.class) // 改状态 + 落审核记录 +(APPROVE)发布编排 /(UNLIST)下架编排,需同一本地事务(§3.2 C2 事务原子) public void reviewProject(ReviewReqVO reqVO, Long reviewerUserId) { ProjectDO project = projectMapper.selectById(reqVO.getGameId()); if (project == null) { throw exception(PROJECT_NOT_EXISTS); } boolean isApprove = Objects.equals(reqVO.getDecision(), ReviewDecisionEnum.APPROVE.getDecision()); - // 决策 → 目标状态(含合法流转校验):APPROVE 仍校验「仅审核中可通过」,但 α 下项目态不停留 APPROVED(2),由发布编排直翻 PUBLISHED(4) + // 决策 → 目标状态(含合法流转校验):APPROVE 仍校验「仅审核中可通过」,但 α 下项目态不停留 APPROVED(2),由发布编排直翻 PUBLISHED(4); + // UNLIST 在此校验「仅 PUBLISHED(4) 可下架」——非法流转在任何写发生之前抛 PROJECT_REVIEW_STATUS_INVALID(重复下架无副作用残留) Integer targetStatus = resolveReviewTargetStatus(reqVO.getDecision(), project.getStatus()); - if (!isApprove) { - // REJECT/UNLIST 分支不变:直接写目标状态(REJECTED(3) / UNLISTED(5)) + if (Objects.equals(reqVO.getDecision(), ReviewDecisionEnum.UNLIST.getDecision())) { + // 下架编排(数据回路小波·修3):项目态 4→5 + feed_rank 全分区 status=0,同一本地事务,任一步失败全回滚(含审核记录) + publishOrchestrationService.unlist(project); + } else if (!isApprove) { + // REJECT 直写分支原样保留:直接写目标状态 REJECTED(3) ProjectDO update = new ProjectDO(); update.setId(project.getId()); update.setStatus(targetStatus); diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/publish/PublishOrchestrationService.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/publish/PublishOrchestrationService.java index 71a5357f..e53e6dce 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/publish/PublishOrchestrationService.java +++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/publish/PublishOrchestrationService.java @@ -23,4 +23,22 @@ public interface PublishOrchestrationService { */ void publish(ProjectDO project); + /** + * 执行下架编排(review decision=3 UNLIST,发布编排的反向操作,数据回路小波·修3) + * + * 两步同一本地事务(与调用方 reviewProject 的 @Transactional 合并,任一步失败全回滚含审核记录): + * ① 翻项目态 status=UNLISTED(5)(合法性「仅 PUBLISHED(4) 可下架」已由调用方 resolveReviewTargetStatus 校验, + * 与 publish 不重复校验项目态的既有分工对称); + * ② FeedApi.offlineRank(gameId) 全分区下线 feed_rank(status=0,对齐 V4 DDL「下架联动」设计意图,封死写侧残留)。 + * + * 裁决边界(2026-06-10 数据回路小波·修3):game_runtime_package.status 不动(2=已失效语义是包本体失效且不可逆, + * 下架=可见性决策,存量直链经 play 门禁仍可玩属可接受残余);game_version.status 不动(版本状态机无下架态, + * 版本是构建产物事实记录,强行回翻=伪造历史)。 + * 幂等:状态机拒绝重复——第二次 decision=3 时项目已 UNLISTED(5)≠PUBLISHED,调用方在任何写发生之前抛 + * PROJECT_REVIEW_STATUS_INVALID;offlineRank 自身天然幂等(已 0 置 0)。 + * + * @param project 待下架项目 DO(须已查出,提供 id;调用方已校验其处于 PUBLISHED(4)) + */ + void unlist(ProjectDO project); + } diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/publish/PublishOrchestrationServiceImpl.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/publish/PublishOrchestrationServiceImpl.java index c8fa4bc3..0c8a549c 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/publish/PublishOrchestrationServiceImpl.java +++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/publish/PublishOrchestrationServiceImpl.java @@ -99,4 +99,24 @@ public class PublishOrchestrationServiceImpl implements PublishOrchestrationServ log.info("[publish] 发布编排完成 gameId={}, versionId={}, zoneId={}", project.getId(), versionId, req.getZoneId()); } + @Override + @Transactional(rollbackFor = Exception.class) // 与调用方 reviewProject 事务合并(REQUIRED);两步任一失败全回滚(含审核记录) + public void unlist(ProjectDO project) { + log.info("[unlist] 下架编排开始 gameId={}", project.getId()); + + // ① 翻项目态 project.status=UNLISTED(5) + // 合法性「仅 PUBLISHED(4) 可下架」已由调用方 resolveReviewTargetStatus 校验(与 publish 不重复校验项目态的既有分工对称) + ProjectDO update = new ProjectDO(); + update.setId(project.getId()); + update.setStatus(ProjectStatusEnum.UNLISTED.getStatus()); + projectMapper.updateById(update); + + // ② feed_rank 全分区下线(status=0,对齐 V4 DDL「下架联动」设计意图,封死写侧残留) + // 经 FeedApi @Primary 本地 bean 事务内本地调用(非 Feign);feed 侧抛错则本编排抛出 → reviewProject 事务整体回滚(含审核记录)。 + // 行数 0 也合法(无基线行的存量数据),记审计日志不抛错;裁决:runtime_package/version 状态不动(见接口 javadoc)。 + Integer offlinedRows = feedApi.offlineRank(project.getId()).getCheckedData(); + + log.info("[unlist] 下架编排完成 gameId={}, offlinedRows={}", project.getId(), offlinedRows); + } + } diff --git a/game-cloud/game-module-project/game-module-project-server/src/test/java/cn/wanxiang/game/module/project/service/project/ProjectServiceImplTest.java b/game-cloud/game-module-project/game-module-project-server/src/test/java/cn/wanxiang/game/module/project/service/project/ProjectServiceImplTest.java index 6a990468..6ea67bc1 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/test/java/cn/wanxiang/game/module/project/service/project/ProjectServiceImplTest.java +++ b/game-cloud/game-module-project/game-module-project-server/src/test/java/cn/wanxiang/game/module/project/service/project/ProjectServiceImplTest.java @@ -254,21 +254,44 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest { @Test void testReviewProject_unlistFromPublished() { + // 数据回路小波·修3:UNLIST 不再由本方法直写项目态,而是委托下架编排(项目态 4→5 + feed_rank 全分区 status=0,同一事务) ProjectDO project = ownedProject(1L, 99L, ProjectStatusEnum.PUBLISHED.getStatus()); when(projectMapper.selectById(1L)).thenReturn(project); projectService.reviewProject(reviewReq(1L, 3), 7L); // 3=下架 - ArgumentCaptor captor = ArgumentCaptor.forClass(ProjectDO.class); - verify(projectMapper).updateById(captor.capture()); - assertEquals(ProjectStatusEnum.UNLISTED.getStatus(), captor.getValue().getStatus()); + // 委托下架编排(状态翻转与 feed 下线在编排内,已 mock) + verify(publishOrchestrationService).unlist(project); + // 落审核记录(下架=block) + ArgumentCaptor rc = ArgumentCaptor.forClass(ReviewRecordDO.class); + verify(reviewRecordMapper).insert(rc.capture()); + assertEquals("block", rc.getValue().getGateResult()); + assertEquals(7L, rc.getValue().getReviewerUserId()); + // UNLIST 分支不再由本方法直接写项目状态(带类型消歧) + verify(projectMapper, never()).updateById(any(ProjectDO.class)); + } + + @Test + void testReviewProject_unlistOnNotPublishedRejected() { + // 状态机非法流转拒绝(幂等安全网):非 PUBLISHED(如审核中/重复下架后已 UNLISTED)的 decision=3 + // → 在任何写发生之前抛 PROJECT_REVIEW_STATUS_INVALID,编排零调用、审核记录零落库(无副作用残留) + ProjectDO project = ownedProject(1L, 99L, ProjectStatusEnum.REVIEWING.getStatus()); + when(projectMapper.selectById(1L)).thenReturn(project); + + ServiceException ex = assertThrows(ServiceException.class, + () -> projectService.reviewProject(reviewReq(1L, 3), 7L)); + assertEquals(PROJECT_REVIEW_STATUS_INVALID.getCode(), ex.getCode()); + verify(publishOrchestrationService, never()).unlist(any(ProjectDO.class)); // 编排零调用 + verify(reviewRecordMapper, never()).insert(any(ReviewRecordDO.class)); // 审核记录零落库(带类型消歧) + verify(projectMapper, never()).updateById(any(ProjectDO.class)); // 项目态零写入(带类型消歧) } // ============================== unlistIfPublished 封禁联动下架(R3) ============================== @Test void testUnlistIfPublished_publishedUnlists() { - // 已发布 → 复用 reviewProject 状态机下架(PUBLISHED → UNLISTED),返回 true + // 已发布 → 复用 reviewProject 状态机下架(decision=3 委托下架编排),返回 true + // 修3 正向波及(R3):封禁联动经此路自动继承 feed 全分区下线(编排内承载,已 mock) ProjectDO project = ownedProject(1L, 99L, ProjectStatusEnum.PUBLISHED.getStatus()); project.setCurrentVersionId(2048L); when(projectMapper.selectById(1L)).thenReturn(project); @@ -276,11 +299,11 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest { boolean unlisted = projectService.unlistIfPublished(1L); assertTrue(unlisted); - // 走 project 权威状态机:状态置已下架 + 落审核记录(block) - ArgumentCaptor captor = ArgumentCaptor.forClass(ProjectDO.class); - verify(projectMapper).updateById(captor.capture()); - assertEquals(ProjectStatusEnum.UNLISTED.getStatus(), captor.getValue().getStatus()); + // 走 project 权威状态机:委托下架编排(项目态 4→5 + feed 下线)+ 落审核记录(block) + verify(publishOrchestrationService).unlist(project); verify(reviewRecordMapper).insert(any(ReviewRecordDO.class)); + // 状态翻转在编排内(已 mock),本方法不再直写项目态(带类型消歧) + verify(projectMapper, never()).updateById(any(ProjectDO.class)); } @Test diff --git a/game-cloud/game-module-project/game-module-project-server/src/test/java/cn/wanxiang/game/module/project/service/publish/PublishOrchestrationServiceImplTest.java b/game-cloud/game-module-project/game-module-project-server/src/test/java/cn/wanxiang/game/module/project/service/publish/PublishOrchestrationServiceImplTest.java new file mode 100644 index 00000000..24d3a2eb --- /dev/null +++ b/game-cloud/game-module-project/game-module-project-server/src/test/java/cn/wanxiang/game/module/project/service/publish/PublishOrchestrationServiceImplTest.java @@ -0,0 +1,91 @@ +package cn.wanxiang.game.module.project.service.publish; + +import cn.wanxiang.game.module.project.dal.dataobject.project.ProjectDO; +import cn.wanxiang.game.module.project.dal.mysql.project.ProjectMapper; +import cn.wanxiang.game.module.project.enums.ProjectStatusEnum; +import cn.wanxiang.game.module.project.service.version.GameVersionService; +import cn.wanxiang.game.module.feed.api.FeedApi; +import cn.wanxiang.game.module.runtime.api.RuntimePackageApi; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * {@link PublishOrchestrationServiceImpl} 单元测试(纯 Mockito,不依赖 DB) + * + * 覆盖下架编排(数据回路小波·修3):① 翻项目态 4→5 ② feed_rank 全分区下线(feedApi.offlineRank), + * 以及 feed 侧抛错向上传播(事务回滚由 Spring 框架保证,mock 层只断言传播,回滚整体性由 staging 冒烟③验证——如实声明边界)。 + * + * @author 造梦AI + */ +class PublishOrchestrationServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private PublishOrchestrationServiceImpl publishOrchestrationService; + + @Mock + private ProjectMapper projectMapper; + @Mock + private GameVersionService gameVersionService; // publish 路依赖(本测试聚焦 unlist,不参与) + @Mock + private RuntimePackageApi runtimePackageApi; // publish 路依赖(本测试聚焦 unlist,不参与) + @Mock + private FeedApi feedApi; // unlist 步骤②:全分区下线 feed_rank + + // ============================== unlist 下架编排(修3) ============================== + + @Test + void testUnlist_flipsStatusAndOfflinesFeed() { + // 两步编排:① projectMapper.updateById(status=UNLISTED(5)) ② feedApi.offlineRank(gameId) 全分区下线 + when(feedApi.offlineRank(1L)).thenReturn(CommonResult.success(2)); // 桩:下线 2 行(发布基线 + 精选分区) + + publishOrchestrationService.unlist(project(1L)); + + // ① 翻项目态 4→5 + ArgumentCaptor captor = ArgumentCaptor.forClass(ProjectDO.class); + verify(projectMapper).updateById(captor.capture()); + assertEquals(1L, captor.getValue().getId()); + assertEquals(ProjectStatusEnum.UNLISTED.getStatus(), captor.getValue().getStatus()); + // ② feed_rank 全分区下线(status=0) + verify(feedApi).offlineRank(1L); + } + + @Test + void testUnlist_zeroOfflinedRowsAccepted() { + // 行数 0 也合法(无基线行的存量数据):记日志不抛错,编排正常完成 + when(feedApi.offlineRank(1L)).thenReturn(CommonResult.success(0)); + + assertDoesNotThrow(() -> publishOrchestrationService.unlist(project(1L))); + + verify(projectMapper).updateById(any(ProjectDO.class)); // 项目态仍正常翻转(带类型消歧) + verify(feedApi).offlineRank(1L); + } + + @Test + void testUnlist_feedOfflineFailurePropagates() { + // feed 侧抛错 → 编排抛出(向上传播使 reviewProject 事务整体回滚;回滚整体性由 staging 冒烟③验证,mock 层只断言传播) + when(feedApi.offlineRank(1L)).thenThrow(new RuntimeException("feed 下线失败")); + + RuntimeException ex = assertThrows(RuntimeException.class, + () -> publishOrchestrationService.unlist(project(1L))); + assertEquals("feed 下线失败", ex.getMessage()); + } + + // ============================== 测试夹具 ============================== + + /** 构造已发布项目(合法性「仅 PUBLISHED 可下架」由调用方 resolveReviewTargetStatus 校验,编排不重复校验) */ + private static ProjectDO project(Long id) { + ProjectDO project = new ProjectDO(); + project.setId(id); + project.setStatus(ProjectStatusEnum.PUBLISHED.getStatus()); + return project; + } + +} diff --git a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/dal/mysql/stat/GameStatMapper.java b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/dal/mysql/stat/GameStatMapper.java index dfe83051..3c6414cc 100644 --- a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/dal/mysql/stat/GameStatMapper.java +++ b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/dal/mysql/stat/GameStatMapper.java @@ -5,7 +5,9 @@ import cn.wanxiang.game.module.telemetry.dal.dataobject.stat.GameStatDO; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import org.apache.ibatis.annotations.Insert; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; import java.time.LocalDate; @@ -63,4 +65,29 @@ public interface GameStatMapper extends BaseMapperX { .eq(GameStatDO::getStatDate, statDate)); } + /** + * 原子累加 upsert(数据回路小波·修2):单语句 INSERT…ON DUPLICATE KEY UPDATE cnt=cnt+N, + * 命中 uk_game_date(game_id,stat_date) 则行级原子累加,消除读-改-写并发丢增量; + * 首行并发双建由 MySQL 在唯一键上原子裁决(一方 INSERT 一方转 UPDATE,均不抛错)。 + * 幂等边界:聚合层为纯加法,恰一次由入库层 uk_event_id 挡重放保证(见 EventIngestServiceImpl.ingestOne)。 + * 注:刻意不用 VALUES() 取新值(MySQL 8.0.20+ 已弃用),UPDATE 子句直接复用绑定参数,跨版本安全; + * 审计/租户缺省列依赖 DDL 默认值 + 租户拦截器自动补 tenant_id;quality_score 不在本语句内(由算分步独立回写,避免被累加语句覆盖)。 + * + * @return 受影响行数(MySQL 语义:插入=1 / 累加=2),仅日志排障用 + */ + @Insert("INSERT INTO game_telemetry_game_stat " + + "(game_id, stat_date, play_count, play_end_count, completed_count, total_duration_ms, like_count, share_count) " + + "VALUES (#{gameId}, #{statDate}, #{dPlay}, #{dPlayEnd}, #{dCompleted}, #{dDurationMs}, #{dLike}, #{dShare}) " + + "ON DUPLICATE KEY UPDATE " + + "play_count = play_count + #{dPlay}, " + + "play_end_count = play_end_count + #{dPlayEnd}, " + + "completed_count = completed_count + #{dCompleted}, " + + "total_duration_ms = total_duration_ms + #{dDurationMs}, " + + "like_count = like_count + #{dLike}, " + + "share_count = share_count + #{dShare}") + int insertOrAccumulate(@Param("gameId") Long gameId, @Param("statDate") LocalDate statDate, + @Param("dPlay") long dPlay, @Param("dPlayEnd") long dPlayEnd, + @Param("dCompleted") long dCompleted, @Param("dDurationMs") long dDurationMs, + @Param("dLike") long dLike, @Param("dShare") long dShare); + } diff --git a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/service/event/EventIngestServiceImpl.java b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/service/event/EventIngestServiceImpl.java index 7778fd6c..b3575488 100644 --- a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/service/event/EventIngestServiceImpl.java +++ b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/main/java/cn/wanxiang/game/module/telemetry/service/event/EventIngestServiceImpl.java @@ -24,6 +24,7 @@ import java.time.Instant; import java.time.LocalDate; import java.time.ZoneId; import java.util.Map; +import java.util.Set; import java.util.UUID; import static cn.wanxiang.game.module.telemetry.enums.ErrorCodeConstants.TELEMETRY_BATCH_SIZE_EXCEEDED; @@ -37,6 +38,12 @@ import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionU * → 算 quality_score → 调 FeedApi.upsertRank(QUALITY_REFRESH) 回灌 feed rank → 置该事件 process_status=1 已聚合; * 任一步失败整体回滚(事件不计)。MQ producer/consumer 仅留契约 + TODO(不实现)。 * + * v2→v3 关键改动(数据回路小波·修1 曝光中性 + 修2 原子累加,2026-06-10 执行小记): + * ① 仅计数事件写聚合:game_impression 等零增量事件不建全零行、不做 +0 空写(原始事件照常入库并置已聚合,数据保留供未来 CTR); + * ② 仅 engagement 事件(game_play_end/like/favorite/share)触发 quality 重算 + feed 回灌,game_play_start 只累加 play_count 不重算 + * (修复 B2′ 实证缺陷:未被玩过的已发布游戏仅被曝光就被刷成 quality=0、sort_score 砸到 boost 值的误降分); + * ③ 累加改调 GameStatMapper.insertOrAccumulate(单语句 ON DUPLICATE KEY UPDATE 原子累加,修2 冻结签名),消除读-改-写并发丢增量。 + * * @author 绘境AI */ @Slf4j @@ -52,6 +59,19 @@ public class EventIngestServiceImpl implements EventIngestService { private static final BigDecimal W_SHARE = new BigDecimal("20"); private static final BigDecimal SCORE_MAX = new BigDecimal("100"); + /** + * engagement 事件集合(数据回路小波·修1「曝光中性」裁决,2026-06-10 执行小记): + * 仅这些事件触发该 gameId 的 quality 重算 + feed 回灌。背景:曝光(game_impression)每次出流每卡一条、量最大且六增量全零, + * 旧逻辑任何带 gameId 的已注册事件都触发重算,把未被玩过的已发布游戏刷成 quality=0、sort_score 砸到 boost 值(B2′ 实证误降分)。 + * game_play_start 只累加 play_count 不触发重算(新分母由下一条 engagement 事件重算时自然带入收敛)。 + * 已知无害边界:favorite 在集合内但无累加映射(favorite_count 既有缺口,本波不补列),触发重算结果不变,仅多一次无害回灌。 + */ + private static final Set ENGAGEMENT_EVENTS = Set.of( + TelemetryEventEnum.GAME_PLAY_END.getEvent(), + TelemetryEventEnum.LIKE.getEvent(), + TelemetryEventEnum.FAVORITE.getEvent(), + TelemetryEventEnum.SHARE.getEvent()); + @Resource private TelemetryEventMapper telemetryEventMapper; @@ -122,9 +142,10 @@ public class EventIngestServiceImpl implements EventIngestService { // ============================== 同步写核心(§3.5 C5)============================== /** - * 单条信封同步写:落原始事件(eventId 幂等)→ 仅真插入才累加 stat → 算 quality_score → 回灌 feed → 置已聚合 + * 单条信封同步写:落原始事件(eventId 幂等)→ 仅计数事件原子累加 stat(曝光等零增量跳过,修1) + * → 仅 engagement 事件算 quality_score + 回灌 feed(修1 裁决闸门)→ 置已聚合 * - * 调用前提:处于 @Transactional 内(由 ingestBatch / ingestOneTx 提供),任一步抛错则整体回滚。 + * 调用前提:处于 @Transactional 内(由 ingestBatch / ingestBeacon 提供),任一步抛错则整体回滚。 * * @param envelope 单条信封(已通过 isEnvelopeValid) * @param userId 当前登录用户 ID(补全 user_id 用,匿名为 null) @@ -139,30 +160,55 @@ public class EventIngestServiceImpl implements EventIngestService { TelemetryEventDO eventDO = buildEventDO(envelope, userId); telemetryEventMapper.insert(eventDO); - // 3) 仅当事件落到具体游戏(game_id 非空)才累加聚合;否则只落事件、置已聚合(无聚合落点) + // 3) 仅当事件落到具体游戏(game_id 非空)才进入聚合/算分链路;否则只落事件、置已聚合(无聚合落点) Long gameId = parseLong(envelope.getContext() == null ? null : envelope.getContext().getGameId()); if (gameId != null) { // 统计日:以事件 ts(毫秒)所在系统日期归集(与 stat_date 按日聚合粒度一致) LocalDate statDate = Instant.ofEpochMilli(envelope.getTs()).atZone(ZoneId.systemDefault()).toLocalDate(); - GameStatDO stat = accumulateStat(gameId, statDate, envelope); - // 4) 算 quality_score(DECIMAL5,2)写回 stat - BigDecimal qualityScore = computeQualityScore(stat); - GameStatDO scoreUpdate = new GameStatDO(); - scoreUpdate.setId(stat.getId()); - scoreUpdate.setQualityScore(qualityScore); - gameStatMapper.updateById(scoreUpdate); - // 5) 回灌 feed rank(QUALITY_REFRESH):quality_score 精度转 DECIMAL10,4;sort_score 由 feed 侧据既有 boost 权威重算 - FeedRankUpsertReqDTO feedReq = new FeedRankUpsertReqDTO(); - feedReq.setGameId(gameId); - feedReq.setVersionId(parseLong(envelope.getContext().getVersionId())); - feedReq.setZoneId(0L); // 回灌默认混合流 zone=0(与发布基线默认 zone 对齐) - feedReq.setQualityScore(qualityScore.doubleValue()); // DECIMAL5,2 → Double,feed 侧落 DECIMAL10,4 - feedReq.setSortScore(qualityScore.doubleValue()); // 占位;feed 侧 QUALITY_REFRESH 据既有 boost 重算 sort_score(保留 boost) - feedReq.setMode(FeedRankUpsertReqDTO.UpsertMode.QUALITY_REFRESH); - feedApi.upsertRank(feedReq); + // 3.1) 六增量映射(§3.5 计数口径,沿用既有逻辑):game_play_start → play_count; + // game_play_end → play_end_count(+completed_count if completed)+total_duration_ms;like → like_count;share → share_count + String event = envelope.getEvent(); + Map props = envelope.getProps(); + long dPlay = TelemetryEventEnum.GAME_PLAY_START.getEvent().equals(event) ? 1L : 0L; + long dPlayEnd = TelemetryEventEnum.GAME_PLAY_END.getEvent().equals(event) ? 1L : 0L; + long dCompleted = (dPlayEnd == 1L && isCompleted(props)) ? 1L : 0L; + long dDurationMs = dPlayEnd == 1L ? parseDurationMs(props) : 0L; + long dLike = TelemetryEventEnum.LIKE.getEvent().equals(event) ? 1L : 0L; + long dShare = TelemetryEventEnum.SHARE.getEvent().equals(event) ? 1L : 0L; + // 3.2) 仅计数事件写聚合(曝光中性裁决·其一):game_impression 等零增量事件跳过——不建全零行、不做 +0 空写; + // 累加走修2冻结签名 insertOrAccumulate(单语句 ON DUPLICATE KEY UPDATE 行级原子累加,消除读-改-写并发丢增量) + if (dPlay + dPlayEnd + dLike + dShare > 0) { + gameStatMapper.insertOrAccumulate(gameId, statDate, dPlay, dPlayEnd, dCompleted, dDurationMs, dLike, dShare); + } + // 3.3) engagement 裁决闸门(曝光中性裁决·其二):仅 engagement 事件触发 quality 重算 + feed 回灌; + // game_play_start 只累加 play_count 不重算(新分母由下一条 engagement 事件重算时自然带入收敛) + if (ENGAGEMENT_EVENTS.contains(event)) { + // 累加后重读当日整行(同一事务视图,可见本事务已提交到视图的累计值),据此算分 + GameStatDO stat = gameStatMapper.selectByGameAndDate(gameId, statDate); + if (stat != null) { + // 4) 算 quality_score(DECIMAL5,2)写回 stat(独立 updateById,不与累加语句混写,避免覆盖计数) + BigDecimal qualityScore = computeQualityScore(stat); + GameStatDO scoreUpdate = new GameStatDO(); + scoreUpdate.setId(stat.getId()); + scoreUpdate.setQualityScore(qualityScore); + gameStatMapper.updateById(scoreUpdate); + // 5) 回灌 feed rank(QUALITY_REFRESH):quality_score 精度转 DECIMAL10,4;sort_score 由 feed 侧据既有 boost 权威重算 + FeedRankUpsertReqDTO feedReq = new FeedRankUpsertReqDTO(); + feedReq.setGameId(gameId); + feedReq.setVersionId(parseLong(envelope.getContext().getVersionId())); + feedReq.setZoneId(0L); // 回灌默认混合流 zone=0(与发布基线默认 zone 对齐) + feedReq.setQualityScore(qualityScore.doubleValue()); // DECIMAL5,2 → Double,feed 侧落 DECIMAL10,4 + feedReq.setSortScore(qualityScore.doubleValue()); // 占位;feed 侧 QUALITY_REFRESH 据既有 boost 重算 sort_score(保留 boost) + feedReq.setMode(FeedRankUpsertReqDTO.UpsertMode.QUALITY_REFRESH); + feedApi.upsertRank(feedReq); + } else { + // 边界(已知无害):engagement 事件但当日无聚合行(如 favorite 无累加映射且当日尚无任何计数事件)→ 跳过算分,中文日志可溯 + log.info("[ingestOne] engagement 事件无当日聚合行,跳过算分 gameId={}, event={}", gameId, envelope.getEvent()); + } + } } - // 6) 置该事件 process_status=1 已聚合(无 game_id 的事件同样置已聚合:已落库无需再聚合) + // 6) 置该事件 process_status=1 已聚合(无 game_id / 零增量事件同样置已聚合:原始事件已保留,曝光数据供未来 CTR 分析) TelemetryEventDO statusUpdate = new TelemetryEventDO(); statusUpdate.setId(eventDO.getId()); statusUpdate.setProcessStatus(EventProcessStatusEnum.AGGREGATED.getStatus()); @@ -170,68 +216,6 @@ public class EventIngestServiceImpl implements EventIngestService { log.info("[ingestOne] 同步写完成 eventId={}, event={}, gameId={}", envelope.getEventId(), envelope.getEvent(), gameId); } - /** - * 按 (game_id, stat_date) 累加聚合行(无则建首事件初值,有则行级累加;同事务内顺序累加可见前序写) - * - * 按事件名映射计数增量(§3.5 计数口径): - * game_play_start → play_count;game_play_end → play_end_count(+completed_count if completed)+total_duration_ms; - * like → like_count;share → share_count。其余事件不增计数(仅落原始事件)。 - * - * @param gameId 游戏 ID - * @param statDate 统计日 - * @param envelope 单条信封 - * @return 累加后的聚合行(含 id,供算分回写) - */ - private GameStatDO accumulateStat(Long gameId, LocalDate statDate, EnvelopeReqVO envelope) { - String event = envelope.getEvent(); - Map props = envelope.getProps(); - // 计数增量 - long dPlay = TelemetryEventEnum.GAME_PLAY_START.getEvent().equals(event) ? 1L : 0L; - long dPlayEnd = TelemetryEventEnum.GAME_PLAY_END.getEvent().equals(event) ? 1L : 0L; - long dCompleted = (dPlayEnd == 1L && isCompleted(props)) ? 1L : 0L; - long dDuration = dPlayEnd == 1L ? parseDurationMs(props) : 0L; - long dLike = TelemetryEventEnum.LIKE.getEvent().equals(event) ? 1L : 0L; - long dShare = TelemetryEventEnum.SHARE.getEvent().equals(event) ? 1L : 0L; - - GameStatDO existing = gameStatMapper.selectByGameAndDate(gameId, statDate); - if (existing == null) { - // 首事件:建聚合行,计数为本事件增量(quality_score 先 0,下一步算分回写) - GameStatDO stat = new GameStatDO(); - stat.setGameId(gameId); - stat.setStatDate(statDate); - stat.setPlayCount(dPlay); - stat.setPlayEndCount(dPlayEnd); - stat.setCompletedCount(dCompleted); - stat.setTotalDurationMs(dDuration); - stat.setLikeCount(dLike); - stat.setFavoriteCount(0L); - stat.setShareCount(dShare); - stat.setReportCount(0L); - stat.setLoadFailCount(0L); - stat.setQualityScore(BigDecimal.ZERO); - gameStatMapper.insert(stat); - return stat; - } - // 已存在:行级累加(仅累加本事件涉及的计数列;其余列只显式回写既有值,避免被 null 覆盖) - GameStatDO update = new GameStatDO(); - update.setId(existing.getId()); - update.setPlayCount(nz(existing.getPlayCount()) + dPlay); - update.setPlayEndCount(nz(existing.getPlayEndCount()) + dPlayEnd); - update.setCompletedCount(nz(existing.getCompletedCount()) + dCompleted); - update.setTotalDurationMs(nz(existing.getTotalDurationMs()) + dDuration); - update.setLikeCount(nz(existing.getLikeCount()) + dLike); - update.setShareCount(nz(existing.getShareCount()) + dShare); - gameStatMapper.updateById(update); - // 回填累加后的值到 existing,供算分(避免再查一次库) - existing.setPlayCount(update.getPlayCount()); - existing.setPlayEndCount(update.getPlayEndCount()); - existing.setCompletedCount(update.getCompletedCount()); - existing.setTotalDurationMs(update.getTotalDurationMs()); - existing.setLikeCount(update.getLikeCount()); - existing.setShareCount(update.getShareCount()); - return existing; - } - /** * 算 quality_score(§3.5 口径):clamp(60×完玩率 + 20×like率 + 20×share率, 0, 100),DECIMAL5,2 * diff --git a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/test/java/cn/wanxiang/game/module/telemetry/dal/mysql/stat/GameStatMapperTest.java b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/test/java/cn/wanxiang/game/module/telemetry/dal/mysql/stat/GameStatMapperTest.java new file mode 100644 index 00000000..fa2cb3a1 --- /dev/null +++ b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/test/java/cn/wanxiang/game/module/telemetry/dal/mysql/stat/GameStatMapperTest.java @@ -0,0 +1,137 @@ +package cn.wanxiang.game.module.telemetry.dal.mysql.stat; + +import org.apache.ibatis.annotations.Insert; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.time.LocalDate; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link GameStatMapper#insertOrAccumulate} SQL 形态守卫单测(数据回路小波·修2) + * + * 背景与验证边界(如实声明):game 模块测试基建仅 Mockito(telemetry-server 无 test resources / 无 H2), + * mock 层无法执行真 SQL,故本测试以反射读取 @Insert 注解文本做「SQL 形态守卫」, + * 防止后续改动悄然破坏原子累加语义;MySQL 真实执行语义与租户拦截器(jsqlparser)改写 + * 属运行期行为,由「克隆构建编译绿 + staging 冒烟断言②」闭环验证。 + * + * 守卫四点(对应执行小记修2验收断言): + * 1. 必须含 ON DUPLICATE KEY UPDATE —— 单语句原子 upsert,禁止退化回读-改-写; + * 2. 六个计数列均保持「列 = 列 + #{增量}」行级原子累加形态; + * 3. quality_score 不得出现 —— 算分由独立回写步负责,防止被累加语句覆盖清零; + * 4. INSERT 列清单与 VALUES 占位一一对应 —— 防列错位写串数据。 + * + * @author 绘境AI + */ +class GameStatMapperTest { + + /** + * 反射读取冻结签名方法上的 @Insert SQL 文本。 + * + * 签名(参数类型/顺序)即修1↔修2接缝的冻结契约:任何一方改签名, + * 此处 getMethod 抛 NoSuchMethodException 直接红测,倒逼回主 agent 重新冻结。 + * + * @return 注解承载的完整 SQL 文本(多段拼接为一条) + */ + private String loadInsertSql() throws NoSuchMethodException { + Method method = GameStatMapper.class.getMethod("insertOrAccumulate", + Long.class, LocalDate.class, + long.class, long.class, long.class, long.class, long.class, long.class); + Insert insert = method.getAnnotation(Insert.class); + assertNotNull(insert, "insertOrAccumulate 必须以 @Insert 注解承载原生 upsert 语句"); + return String.join(" ", insert.value()); + } + + /** + * 守卫①:必须为单语句 INSERT…ON DUPLICATE KEY UPDATE 原子 upsert。 + */ + @Test + void testInsertOrAccumulate_containsOnDuplicateKeyUpdate() throws Exception { + String sql = loadInsertSql(); + + assertTrue(sql.contains("ON DUPLICATE KEY UPDATE"), + "必须保持单语句 INSERT…ON DUPLICATE KEY UPDATE 原子 upsert,禁止退化回读-改-写(并发丢增量)"); + } + + /** + * 守卫②:六个计数列均保持「列 = 列 + #{增量}」行级原子累加形态。 + */ + @Test + void testInsertOrAccumulate_sixCountersAtomicIncrement() throws Exception { + String sql = loadInsertSql(); + + // 六个累加列 → 对应增量占位(与冻结签名参数名一致) + Map counterToParam = new LinkedHashMap<>(); + counterToParam.put("play_count", "dPlay"); + counterToParam.put("play_end_count", "dPlayEnd"); + counterToParam.put("completed_count", "dCompleted"); + counterToParam.put("total_duration_ms", "dDurationMs"); + counterToParam.put("like_count", "dLike"); + counterToParam.put("share_count", "dShare"); + + counterToParam.forEach((column, param) -> { + // 形态正则:列 = 列 + #{增量}(容忍空白变化,语义不容变化) + Pattern atomicForm = Pattern.compile( + column + "\\s*=\\s*" + column + "\\s*\\+\\s*#\\{" + param + "\\}"); + assertTrue(atomicForm.matcher(sql).find(), + "累加列必须保持行级原子累加形态:" + column + " = " + column + " + #{" + param + "}"); + }); + } + + /** + * 守卫③:quality_score 不得出现在累加语句中(防误覆盖算分结果)。 + */ + @Test + void testInsertOrAccumulate_qualityScoreNotTouched() throws Exception { + String sql = loadInsertSql(); + + assertFalse(sql.contains("quality_score"), + "quality_score 不得出现在累加语句中:算分由独立回写步负责,混入累加语句会覆盖/清零质量分"); + } + + /** + * 守卫④:INSERT 列清单与 VALUES 占位逐位一一对应(防列错位写串数据)。 + */ + @Test + void testInsertOrAccumulate_insertColumnsMatchValuePlaceholders() throws Exception { + String sql = loadInsertSql(); + + // 提取「INSERT INTO 表 (列清单) VALUES (占位清单)」两个括号组 + Matcher matcher = Pattern.compile( + "INSERT INTO game_telemetry_game_stat\\s*\\(([^)]+)\\)\\s*VALUES\\s*\\(([^)]+)\\)").matcher(sql); + assertTrue(matcher.find(), "语句必须是对 game_telemetry_game_stat 的显式列清单 INSERT(禁止省略列清单)"); + + List columns = splitTrim(matcher.group(1)); + List placeholders = splitTrim(matcher.group(2)); + + // 列数与占位数一致 + assertEquals(columns.size(), placeholders.size(), "INSERT 列数与 VALUES 占位数必须一致"); + + // 逐位对应冻结契约:列顺序 ↔ 占位顺序(V5 DDL 计数列全 NOT NULL DEFAULT 0,审计/租户缺省列可省略) + List expectedColumns = List.of( + "game_id", "stat_date", "play_count", "play_end_count", + "completed_count", "total_duration_ms", "like_count", "share_count"); + List expectedPlaceholders = List.of( + "#{gameId}", "#{statDate}", "#{dPlay}", "#{dPlayEnd}", + "#{dCompleted}", "#{dDurationMs}", "#{dLike}", "#{dShare}"); + assertEquals(expectedColumns, columns, "INSERT 列清单/顺序偏离冻结契约"); + assertEquals(expectedPlaceholders, placeholders, "VALUES 占位清单/顺序偏离冻结契约(列错位会写串数据)"); + } + + /** + * 按逗号切分并去除首尾空白(解析括号组内的列/占位清单用)。 + */ + private List splitTrim(String group) { + return Arrays.stream(group.split(",")) + .map(String::trim) + .toList(); + } + +} diff --git a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/test/java/cn/wanxiang/game/module/telemetry/service/event/EventIngestServiceImplTest.java b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/test/java/cn/wanxiang/game/module/telemetry/service/event/EventIngestServiceImplTest.java index bc113b1c..11faaeb4 100644 --- a/game-cloud/game-module-telemetry/game-module-telemetry-server/src/test/java/cn/wanxiang/game/module/telemetry/service/event/EventIngestServiceImplTest.java +++ b/game-cloud/game-module-telemetry/game-module-telemetry-server/src/test/java/cn/wanxiang/game/module/telemetry/service/event/EventIngestServiceImplTest.java @@ -7,6 +7,7 @@ import cn.wanxiang.game.module.telemetry.dal.dataobject.event.TelemetryEventDO; import cn.wanxiang.game.module.telemetry.dal.dataobject.stat.GameStatDO; import cn.wanxiang.game.module.telemetry.dal.mysql.event.TelemetryEventMapper; import cn.wanxiang.game.module.telemetry.dal.mysql.stat.GameStatMapper; +import cn.wanxiang.game.module.telemetry.enums.EventProcessStatusEnum; import cn.wanxiang.game.module.feed.api.FeedApi; import cn.wanxiang.game.module.feed.dto.FeedRankUpsertReqDTO; import cn.iocoder.yudao.framework.common.exception.ServiceException; @@ -18,6 +19,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import java.math.BigDecimal; +import java.time.LocalDate; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -35,7 +37,9 @@ import static org.mockito.Mockito.*; * {@link EventIngestServiceImpl} 单元测试(纯 Mockito,不依赖 DB/MQ) * * 覆盖黄金闭环 §3.5 C5 同步写:批量上限、信封轻量校验(eventId/event 必填 + 注册表)、部分成功计数、 - * eventId 幂等跳过、计数累加、quality_score 算分 + feed 回灌、beacon 恒受理。 + * eventId 幂等跳过、原子累加调用形态(修2 冻结签名 insertOrAccumulate)、quality_score 算分 + feed 回灌、beacon 恒受理; + * 以及数据回路小波·修1「曝光中性」裁决:game_impression 零增量事件聚合表零写入 + feed 零回灌, + * 仅 engagement 事件(game_play_end/like/favorite/share)触发重算,game_play_start 只累加不重算。 * * @author 绘境AI */ @@ -98,7 +102,7 @@ class EventIngestServiceImplTest extends BaseMockitoUnitTest { @Test void testIngestOne_idempotentSkipOnDuplicateEventId() { - // 同 eventId 已落库 → 非真插入,跳过:不再 insert、不累加、不回灌(重放同批次不重复计数) + // 同 eventId 已落库 → 非真插入,跳过:不再 insert、不累加(never insertOrAccumulate)、不回灌(重放同批次不重复计数) EnvelopeReqVO env = gameEnvelope("game_play_start", "t1", "1024", "2048", null); when(telemetryEventMapper.selectByEventId(env.getEventId())).thenReturn(new TelemetryEventDO()); @@ -106,34 +110,87 @@ class EventIngestServiceImplTest extends BaseMockitoUnitTest { assertEquals(1, result.getAccepted()); // 校验通过仍计 accepted(已受理),但不重复写 verify(telemetryEventMapper, never()).insert(any(TelemetryEventDO.class)); - verify(gameStatMapper, never()).insert(any(GameStatDO.class)); + verifyNoInteractions(gameStatMapper); // 聚合表零交互:无 insertOrAccumulate / 无重读 / 无算分写回 + verifyNoInteractions(feedApi); + } + + // ============================== 曝光中性(数据回路小波·修1 裁决)============================== + + @Test + void testIngestOne_impressionNeutral() { + // 曝光中性(修1 必备用例①):game_impression 带 gameId(六增量全零、非 engagement)→ + // 原始事件照常入库 + 置已聚合(曝光数据保留供未来 CTR);聚合表零交互(不建全零行、不空写);feed 零回灌 + // ——锁死 B2′ 实证缺陷不回归:未被玩过的已发布游戏不再因曝光被刷成 quality=0 + EnvelopeReqVO env = gameEnvelope("game_impression", "t1", "1024", "2048", null); + when(telemetryEventMapper.selectByEventId(env.getEventId())).thenReturn(null); + + EventBatchResultVO result = eventIngestService.ingestBatch(batchOf(env), 99L); + + assertEquals(1, result.getAccepted()); + // 原始事件真插入发生 + verify(telemetryEventMapper).insert(any(TelemetryEventDO.class)); + // 置已聚合发生(process_status=1) + ArgumentCaptor statusCaptor = ArgumentCaptor.forClass(TelemetryEventDO.class); + verify(telemetryEventMapper).updateById(statusCaptor.capture()); + assertEquals(EventProcessStatusEnum.AGGREGATED.getStatus(), statusCaptor.getValue().getProcessStatus()); + // 聚合表零交互:无 insertOrAccumulate / 无 selectByGameAndDate / 无 updateById + verifyNoInteractions(gameStatMapper); + // feed 零回灌 + verifyNoInteractions(feedApi); + } + + @Test + void testIngestOne_playStartAccumulatesNoRefresh() { + // 修1 裁决:game_play_start 只累加 play_count(dPlay=1),非 engagement 不触发重算/回灌 + // (新分母由下一条 engagement 事件重算时自然带入收敛) + EnvelopeReqVO env = gameEnvelope("game_play_start", "t1", "1024", "2048", null); + when(telemetryEventMapper.selectByEventId(env.getEventId())).thenReturn(null); + + eventIngestService.ingestBatch(batchOf(env), 99L); + + // 原子累加入参(修2 冻结签名):仅 dPlay=1,其余增量为 0 + verify(gameStatMapper).insertOrAccumulate(eq(1024L), any(LocalDate.class), + eq(1L), eq(0L), eq(0L), eq(0L), eq(0L), eq(0L)); + // 非 engagement:不重读、不算分写回、不回灌 + verify(gameStatMapper, never()).selectByGameAndDate(any(), any()); verify(gameStatMapper, never()).updateById(any(GameStatDO.class)); verifyNoInteractions(feedApi); } - // ============================== 计数累加 + 算分 + 回灌 ============================== + // ============================== 计数累加 + 算分 + 回灌(engagement 闸门)============================== @Test - void testIngestOne_playEndAccumulatesAndRefreshesFeed() { - // game_play_end(completed=true,duration=5000) 首事件 → 建 stat 行;算分回灌 feed(QUALITY_REFRESH) + void testIngestOne_playEndTriggersQualityRefresh() { + // 修1 必备用例②:game_play_end(completed=true,duration=5000) 属 engagement → + // 原子累加(dPlayEnd=1,dCompleted=1,dDurationMs=5000) → 重读累计行算分 → 写回 quality → 回灌 feed(QUALITY_REFRESH) Map props = new HashMap<>(); props.put("completed", true); props.put("duration_ms", 5000); EnvelopeReqVO env = gameEnvelope("game_play_end", "t1", "1024", "2048", props); when(telemetryEventMapper.selectByEventId(env.getEventId())).thenReturn(null); - when(gameStatMapper.selectByGameAndDate(eq(1024L), any())).thenReturn(null); // 首事件无聚合行 + // 桩累加后的当日聚合行(同事务视图重读):play_end=1、completed=1 → 完玩率 1 → score=60.00 + GameStatDO accumulated = new GameStatDO(); + accumulated.setId(7L); + accumulated.setGameId(1024L); + accumulated.setPlayCount(0L); + accumulated.setPlayEndCount(1L); + accumulated.setCompletedCount(1L); + accumulated.setTotalDurationMs(5000L); + accumulated.setLikeCount(0L); + accumulated.setShareCount(0L); + when(gameStatMapper.selectByGameAndDate(eq(1024L), any(LocalDate.class))).thenReturn(accumulated); eventIngestService.ingestBatch(batchOf(env), 99L); - // 建 stat 行:play_end_count=1, completed_count=1, total_duration_ms=5000 - ArgumentCaptor statCaptor = ArgumentCaptor.forClass(GameStatDO.class); - verify(gameStatMapper).insert(statCaptor.capture()); - GameStatDO stat = statCaptor.getValue(); - assertEquals(1024L, stat.getGameId()); - assertEquals(1L, stat.getPlayEndCount()); - assertEquals(1L, stat.getCompletedCount()); - assertEquals(5000L, stat.getTotalDurationMs()); - // 回灌 feed:mode=QUALITY_REFRESH,gameId/versionId 透传;quality_score=60×完玩率(1.0)=60 + // 原子累加入参(修2 冻结签名):dPlay=0, dPlayEnd=1, dCompleted=1, dDurationMs=5000, dLike=0, dShare=0 + verify(gameStatMapper).insertOrAccumulate(eq(1024L), any(LocalDate.class), + eq(0L), eq(1L), eq(1L), eq(5000L), eq(0L), eq(0L)); + // 算分写回:quality_score=60.00(60×完玩率 1/1 + 20×0 + 20×0),写回行 id=7 + ArgumentCaptor scoreCaptor = ArgumentCaptor.forClass(GameStatDO.class); + verify(gameStatMapper).updateById(scoreCaptor.capture()); + assertEquals(7L, scoreCaptor.getValue().getId()); + assertEquals(0, new BigDecimal("60.00").compareTo(scoreCaptor.getValue().getQualityScore())); + // 回灌 feed:mode=QUALITY_REFRESH,gameId/versionId 透传,分值正确 ArgumentCaptor feedCaptor = ArgumentCaptor.forClass(FeedRankUpsertReqDTO.class); verify(feedApi).upsertRank(feedCaptor.capture()); FeedRankUpsertReqDTO feedReq = feedCaptor.getValue(); @@ -157,7 +214,8 @@ class EventIngestServiceImplTest extends BaseMockitoUnitTest { props.put("duration_ms", 8000); EnvelopeReqVO env = gameEnvelope("game_play_end", "t1", "1024", "2048", props); when(telemetryEventMapper.selectByEventId(env.getEventId())).thenReturn(null); - when(gameStatMapper.selectByGameAndDate(eq(1024L), any())).thenReturn(null); + // engagement 重读桩为 null → 顺带覆盖「engagement 事件无当日聚合行 → 跳过算分」边界分支(仅日志,不抛错不回灌) + when(gameStatMapper.selectByGameAndDate(eq(1024L), any(LocalDate.class))).thenReturn(null); eventIngestService.ingestBatch(batchOf(env), 99L); @@ -170,34 +228,38 @@ class EventIngestServiceImplTest extends BaseMockitoUnitTest { assertEquals(Boolean.TRUE, parsed.get("completed")); assertEquals(8000, ((Number) parsed.get("duration_ms")).intValue()); assertFalse(propsJson.contains("="), "props 不应为 Map.toString() 的 {k=v} 形态"); + // 无当日聚合行 → 跳过算分:不写 quality、不回灌 feed + verify(gameStatMapper, never()).updateById(any(GameStatDO.class)); + verifyNoInteractions(feedApi); } @Test void testIngestOne_likeAccumulatesOnExistingStat() { - // like 事件命中既有 stat(play_count=2,like_count=1)→ like_count 累加为 2;quality_score 含 like 率 - GameStatDO existing = new GameStatDO(); - existing.setId(7L); - existing.setPlayCount(2L); - existing.setPlayEndCount(2L); - existing.setCompletedCount(0L); - existing.setLikeCount(1L); - existing.setShareCount(0L); - existing.setTotalDurationMs(0L); + // like 事件(engagement)命中既有聚合 → 原子累加仅 dLike=1 → 重读累计行(like 1→2)→ 算分回灌 + // 累计行视图:play=2, play_end=2, completed=0, like=2(累加后), share=0 → score=20×like率(2/2)=20.00 + GameStatDO accumulated = new GameStatDO(); + accumulated.setId(7L); + accumulated.setPlayCount(2L); + accumulated.setPlayEndCount(2L); + accumulated.setCompletedCount(0L); + accumulated.setLikeCount(2L); // 累加后的同事务视图(insertOrAccumulate 已 +1) + accumulated.setShareCount(0L); + accumulated.setTotalDurationMs(0L); EnvelopeReqVO env = gameEnvelope("like", "t1", "1024", "2048", null); when(telemetryEventMapper.selectByEventId(env.getEventId())).thenReturn(null); - when(gameStatMapper.selectByGameAndDate(eq(1024L), any())).thenReturn(existing); + when(gameStatMapper.selectByGameAndDate(eq(1024L), any(LocalDate.class))).thenReturn(accumulated); eventIngestService.ingestBatch(batchOf(env), 99L); - // 累加:like_count 1→2(仅累加 like 涉及列) - ArgumentCaptor statCaptor = ArgumentCaptor.forClass(GameStatDO.class); - // 两次 updateById:一次累加计数、一次写 quality_score(捕获首个累加调用) - verify(gameStatMapper, atLeastOnce()).updateById(statCaptor.capture()); - GameStatDO accUpdate = statCaptor.getAllValues().get(0); - assertEquals(7L, accUpdate.getId()); - assertEquals(2L, accUpdate.getLikeCount()); // like 累加 - assertEquals(2L, accUpdate.getPlayCount()); // play_count 保持(显式回写既有值) - verify(feedApi).upsertRank(any(FeedRankUpsertReqDTO.class)); // 有 game_id → 回灌 + // 原子累加入参(修2 冻结签名):仅 dLike=1,其余增量为 0 + verify(gameStatMapper).insertOrAccumulate(eq(1024L), any(LocalDate.class), + eq(0L), eq(0L), eq(0L), eq(0L), eq(1L), eq(0L)); + // 算分写回:60×(0/2) + 20×(2/2) + 20×(0/2) = 20.00 + ArgumentCaptor scoreCaptor = ArgumentCaptor.forClass(GameStatDO.class); + verify(gameStatMapper).updateById(scoreCaptor.capture()); + assertEquals(7L, scoreCaptor.getValue().getId()); + assertEquals(0, new BigDecimal("20.00").compareTo(scoreCaptor.getValue().getQualityScore())); + verify(feedApi).upsertRank(any(FeedRankUpsertReqDTO.class)); // engagement → 回灌 } // ============================== ingestBeacon 恒受理 ==============================