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 恒受理 ==============================