feat(data-loop): 数据回路三修 + postcheck翻页修复 + M-b/小波双spec + 鉴权评审版

- fix1 曝光中性: 零增量事件不写聚合不建全零行, 仅 engagement 事件触发 quality 重算(修曝光清零 bug)
- fix2 原子累加: GameStatMapper.insertOrAccumulate 单语句 ON DUPLICATE KEY UPDATE 替换读改写丢增量
- fix3 下架编排: reviewProject UNLIST 委托 unlist 单事务(项目4→5+feed_rank 全分区下线), 填 R7 缺口
- 修 batch-001 P1: backend_gw.feed_find_game cursor 翻页(原 size=50 超后端单页上限30 致 400 误熔断)
- 文档: M-b 生成下沉 execution(HJ-AGENT-LOOP-EXEC-002 已审定+§16 裁决) + 数据回路小波 execution
  + 真实鉴权与匿名玩家 review(双镜头 12 条意见全修订) + EXEC-001 §16 建设期裁决增补

三修均经 mini-desktop 隔离克隆构建: telemetry 17测/project+feed 39测 全绿+逐修对抗核验 pass

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zizi 2026-06-10 01:05:55 +00:00
parent 9517f4e406
commit b87511b967
21 changed files with 1556 additions and 132 deletions

View File

@ -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 流逐页查找指定 gameIdpostcheck 金丝雀可见性专用)。
修复背景 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")

View File

@ -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 P1size=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"),

View File

@ -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 + manifestsha256(manifest 原文)==checksum
# ④ 取包不走 demo 兜底GET package?scene=preview + manifest?scene=previewsha256(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 真玩该 versionIdpreview 路由)五条 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 轮round0infra_fail → 该创意全部产物失效、从头重走回炉轮round1infra_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 与编排器一处。

View File

@ -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-desktopgit push→pull 同步,本机严禁 mvn/npm buildstaging 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-104queued/**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-5isCancelable :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 starterpom :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-125 字段 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 classpathstarter-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 注解处理器必然已开**。因此执行器全部 BeanAigcGenerateExecutor / ExecutorLlmClient / PromptResourceLoader / GameConfigSchemaValidator**只准经 `AigcExecutorConfiguration``@Bean` 方法注册,类上严禁标 `@Component/@Service`**——若被组件扫描注册,`aigc.executor.enabled=false` 时 tick 照样起跳总开关与回滚§14 首选层双双失效。§11 用例 11ApplicationContextRunner 断言 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 页<br/>POST /app-api/aigc/generate]
STU[studio 会话链<br/>POST /app-api/studio/generate]
end
subgraph AIGC[aigc-server本案全部新增在此]
T[(game_aigc_task<br/>status=0 queued)]
EXE[AigcGenerateExecutor<br/>@Scheduled tickfixedDelay 10s<br/>开关 aigc.executor.enabled]
CLAIM[CAS 认领 0→1<br/>affected=1 才处理]
PR[PromptResourceLoader<br/>classpath 读 Registry prompt+schema<br/>渲染 idea/template_schema]
LLM[ExecutorLlmClient<br/>new-api MiniMax-M2.7 json_object<br/>超时90s 重试×2 退避1s/2s]
SV[GameConfigSchemaValidator<br/>clicker.schema.json 同文件校验<br/>失败→错误文本回灌重出1次]
WD[stale watchdog同 tick<br/>RUNNING 超15min→failed timeout]
end
subgraph CB[唯一写入路径9517f4e 已上线,零改动)]
DCS[DifyCallbackService.handleCallback<br/>进程内调用·同款 DifyCallbackReqVO]
TX[三表同事务task+version+package<br/>失败补偿 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.3prompt/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→1affected=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 调用链最坏还可再跑 ≈273s3×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 | watchdogRUNNING 超 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→5isCancelable 含 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-2v1 不做结构性隔离,列 §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. 预检 M1templateId=dodge → handleCallback 收到 failed+no_template_match零 LLM 调用;
3. 预检 M2gameId=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_invalidLLM 恰被调 2 次;
7. M3LLM 桩三次尝试全抛 → failed+llm_error
8. M5注入假时钟超预算 → failed+timeout不再发起后续 LLM
9. M7handleCallback 抛 AIGC_CALLBACK_TASK_FINAL → 吞掉留痕,不重试、不再次回调;
10. M8handleCallback 抛写链 ServiceException → 仅留痕,无二次动作;
11. 开关行为enabled=false 不装配(用 ApplicationContextRunner 断言 Bean 缺席)/ key 空 → tick 直接返回零认领(自禁用)+ 告警节流计数正确;
12. watchdogstale RUNNING 一条 → handleCallback 收到 failed+timeoutfresh 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 先部署新 jarstart-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":<gameId>}' # → taskId(status=0)
# ③ 轮询出口判据主断言≤60s 内 status=2 且 versionId 非空
watch -n 5 'curl -s http://100.64.0.7:48080/app-api/aigc/task/<taskId> -H "Authorization: Bearer test1" -H "tenant-id: 1"'
# ④ 三表断言EXEC-001 §12.3-③ SQL 原样复用(按本次 traceId→ tStatus=2 ∧ vStatus=2 ∧ pStatus=0 ∧ manifestLen>0
# ⑤ 取包不走 demoEXEC-001 §12.3-④ 原样复用(两个 GET 必带 ?scene=preview9517f4e/D6 裁决)
# ⑥ 五绿配方复用mini-desktop 前端 serve localhost:4173D5-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/<id>/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-av1 批产路径范围限定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 恒 falseM-b 只能宣称「HTTP 链路活」;纳入则改动半径 = 一个方法返回 1 条硬编码 clicker。涉及对评审版 §5.4「模板列表接 studio 正式入口v1 不做)」的解禁,**请创始人/主 agent 拍**。
2. **prompt 渲染选型 = 构建期 classpath 快照**§6.1 方案 Aprompt 升版需重新构建部署才生效(无热更)——与「改 prompt 必升 version 过四道闸」的治理节奏一致但请知悉「staging 上改 prompt 不重启不生效」。
3. **networknt json-schema-validator 新依赖**§7schema 文件即校验器、与注入 LLM 的 schema 文本同源;替代方案为手写校验(双源漂移)。请知悉确认。
4. **执行器与编排器批跑的互斥窗口为运维纪律而非结构隔离**§15-2v1 靠开关错峰若未来批跑常态化与用户流量并存需任务来源标记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 两段式)

View File

@ -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-<key>' # 基线 9517f4e
> ssh mini-desktop 'cat > /root/dr-wave-<key>/<repo相对路径>' < 本地改后文件 # 逐文件镜像
> ssh mini-desktop 'mvn -f /root/dr-wave-<key>/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-425QUALITY_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-<key>/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_idquality_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 `decision1通过 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-341PUBLISH_BASELINE 只写 status=1、QUALITY_REFRESH 保留 statusL401——**没有任何代码把 feed_rank.status 写 0**。而 V4 DDL`V4.0.0__create_game_feed.sql` L28注释明示设计意图`status0屏蔽举报降权/下架联动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<Integer> offlineRank(@RequestParam("gameId") Long gameId);
```
- `FeedApiImpl` 委托 `FeedService.offlineRank`@Primary 本地 bean同进程事务内调用与 upsertRank 同构)。
- `FeedService`/`FeedServiceImpl` 新增 `int offlineRank(Long gameId)`:调 mapper 全分区置 0INFO 日志记 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-<key>/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 定。

View File

@ -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+Eng11 条意见**全部采纳**落稿——①拆准入拍板项(玩家注册 vs 创作白名单)并在漏斗图标明非白名单去向;②如实摊开"报备前玩家侧注册转化不可用"时序阻塞+短信报备今天进 A 轨闸门看板;③登录方式选项补全 B 权衡并增 C 混合项;④依赖改两级(短信=硬闸门/隐私文案=软前置)+P-ACC-02 口径调整声明;⑤⑨"与 glossary 完全对齐"改为显式变更声明;⑥保留期降为既定假设;⑦新增 §5A 匿名身份安全边界(伪造/刷量/bot 排除三联);⑧新增 R6 发码端点滥用;⑩计数口径修为 app 28 处/7 文件;⑪生产 profile 未建立注记;⑫平迁断言标注推断。意见原文核验:所有代码/文档引用均经本仓复读证实。
---
## 0. 结论先行
**推荐方案乙:不复活 member 模块,在 yudao system 基座上做"最小玩家身份"——匿名免登读路径(刷 feed/试玩零门槛)+ 手机验证码登录发真 OAuth2 tokenuserType=MEMBER+ 创作/发布种子期限 A2 白名单玩家注册是否开放为独立拍板项staging 的 mock(test1) 与真实鉴权天然并存、批跑不断,生产环境关死 mock。**
- 体量:后端薄层 + 前端登录页/守卫AI 压缩后约 **35 个有效工作日**(推断),远小于复活 member约 1.52 周,含裁剪与装配返工)。
- 外部依赖分两级:**硬日历闸门 = 生产短信签名报备**(影响通道切换与玩家转化,**今天即加入 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/ 无 loginrouter 无 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.52 周:恢复+裁剪+M1 装配教训重走repackage/Flyway/CommonApi @Primary,记忆 `m1-runtime-bringup-state` | **~35 天**:后端薄层 + 前端登录页/守卫 + e2e | ~12 天 |
| 单租户契合 | 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<br/>本地 anonId(已有)"] -->|免登录| B["刷 feed / 试玩 / 遥测上报<br/>读路径 @PermitAll + anonId 透传"]
B -->|点赞·收藏·支付 触发| C{"一键登录<br/>手机号+验证码"}
C --> W{"准入判定<br/>§9-3 玩家注册 / §9-4 创作白名单<br/>两个独立拍板项)"}
W -->|"推荐语义:玩家注册开放<br/>任意手机号可注册"| PD["注册成真实玩家<br/>可点赞/收藏,不能创作/发布"]
W -.->|"若拍'注册整体限 A2 白名单'<br/>非白名单手机号被拒<br/>=玩家转化漏斗第一跳即断"| X["流失(反例去向,<br/>仅当放弃种子期玩家增长才可接受)"]
end
subgraph CRE[创作者侧(创作/发布限 A2 白名单 §9-4]
PD -->|"手机号 ∈ A2 白名单"| D["game_player 创作者身份<br/>OAuth2 真 token (userType=MEMBER)"]
D --> E["创作 / 发布 / 钱包<br/>app-api 默认强制登录(不动)"]
E -->|提现前| F["实名收集<br/>挂 A 轨支付进件闸门"]
end
subgraph OPS[运营侧(现状即可用)]
G["game-admin 原生登录<br/>system AdminUser (userType=ADMIN)"] --> H["admin-api 审核/运营"]
end
B -. anonId .-> I[("telemetry<br/>双身份可关联")]
PD -. "登录事件绑定 anonId↔userId" .-> I
```
> 图注:**白名单语义拆分**——推荐语义为"玩家注册开放(漏斗存活)+ 创作/发布限 A2 白名单(内容风险收口)";非白名单手机号用户的去向=可注册、可互动、**不能创作/发布**。若创始人改拍"注册整体白名单",则虚线分支生效、匿名玩家转化漏斗在第一跳被设计性切断——该后果必须有意识地拍,不能默认。另:报备前该漏斗还受短信通道时序阻塞(见 §0 与 §9-2与准入语义是两个独立断点。
关键设计点:
1. **匿名玩家=免登读路径,不发匿名 token——对 glossary 既定意向的显式变更声明**。feed 流/分享页/runtime 取包/遥测上报等约 710 个读端点加 `@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 维度做异常剔除 |
| 身份可信度 | 可信(伪造需破签名) | **不可信(任意铸造)**,靠事后剔除兜底 |
| 实现厚度 | +签发端点/密钥管理/前端改造(推断 +12 天) | 最薄(前端零改动),种子期挂"聚合侧按 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 入参(每处 12 行) | 创作/交易/项目端点**不动**(默认强制登录即正确行为)。**发验证码端点必然 `@PermitAll`**:切真实短信渠道前须补 IP/设备限频+验证码开关R6限频依赖 protection startergame 模块 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 三份 profilegrep 实证),**生产 profile 尚未建立**——"生产关死 mock 负路径实测"挂生产环境部署轨,部署项落地后补测 |
## 7. 风险与兼容
| # | 风险 | 等级 | 缓解 |
|---|---|---|---|
| R1 | 撤 mock 回归面app 控制器 28 处 `getLoginUserId`7 文件依赖登录态admin 侧 3 处自 M1 有真实登录,不在面内) | 中 | staging 不撤 mock新增真 token e2e 并行跑生产负路径验收test1→401挂部署轨见 §6 |
| R2 | 短信报备闸门延误12 周不可压缩);**且报备完成前玩家侧注册转化整体不可用Debug 通道只对 5 个已知创作者可运营,陌生玩家无法拿码)** | **高** | **行动项(今天执行):短信签名报备加入 A 轨闸门看板**——plan2026-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 可见,全程无 test1staging 真 token e2e 证据)。
2. **匿名零门槛**:无登录刷 feed/试玩成功,遥测 anonId 落库;点赞触发登录引导。
3. **身份衔接**:登录事件含 anonId+userId互动归属真实 userId1001/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否=种子期只验证创作者闭环,玩家转化等报备落地(漏斗数据晚 12 周以上)→ #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 原意向;+12 天B=纯客户端 anonId+聚合侧异常剔除(**建议种子期**:实现最薄、前端零改动;代价=身份不可信、靠事后剔除兜底,并接受 §5-1 的 glossary 变更声明) |
---
*出处均为本仓实读核验2026-06-10体量与"+12 天"类估算为推断,以执行版排期为准。下一步:拍板后出 execution 版(含落点定稿/端点清单/Flyway 版本/e2e 用例/§5A 聚合侧剔除最小桩与 protection starter 依赖补齐/R6 切渠道安全前置),并同步回写文档:`glossary.md:40` 匿名条目、`contracts/events.schema.json``EnvelopeReqVO` 的 anonId 描述(若 §9-7 拍 B`技术架构与模块.md` 补广告反作弊技术项。*

View File

@ -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 服务 - 游戏流排序 APIfeed 对外写排序行契约黄金闭环新增
@ -43,4 +45,22 @@ public interface FeedApi {
@Operation(summary = "upsert 游戏流排序行(发布基线/算分回灌两路)")
CommonResult<Boolean> upsertRank(@RequestBody FeedRankUpsertReqDTO req);
/**
* 按游戏全分区下线排序行status=0下架编排反向操作数据回路小波·修3
*
* 用途下架编排project-serverreview decision=3 UNLIST在翻项目态 45 后调用本方法把该 gameId
* game_feed_rank **全部分区行** status=0 屏蔽uk_game_zone 下同游戏可有多分区行发布基线 + setFeatured须按 gameId 全下
* 对齐 V4 DDL 设计意图status0屏蔽举报降权/下架联动1可出流封死写侧残留读侧 filterByProjectPublished 兜底仍保留
* 不复用 upsertRank (gameId,zoneId) 单行 upsert 形态承载不了全分区下线语义硬塞第三 mode 属拼接设计故另立窄契约
* 幂等语义天然幂等 0 0 无副作用下线行数 0 也合法无基线行的存量数据由调用方记日志不报错
* 同步事务语义调用方下架编排在本地 @Transactional 内调用本 @Primary 本地 bean本方法抛错则下架事务整体回滚
*
* @param gameId 游戏 IDgame_project.id
* @return 下线行数CommonResult 包裹0=无在流行合法
*/
@PostMapping(PREFIX + "/offline-rank")
@Operation(summary = "按游戏全分区下线排序行status=0下架编排反向操作")
@Parameter(name = "gameId", description = "游戏 ID", required = true, example = "1024")
CommonResult<Integer> offlineRank(@RequestParam("gameId") Long gameId);
}

View File

@ -34,4 +34,10 @@ public class FeedApiImpl implements FeedApi {
return success(Boolean.TRUE);
}
@Override
public CommonResult<Integer> offlineRank(Long gameId) {
// 下架编排反向操作数据回路小波·修3 gameId 全分区置 status=0 屏蔽行数透传给调用方记审计日志业务与日志在 Service
return success(feedService.offlineRank(gameId));
}
}

View File

@ -61,4 +61,21 @@ public interface FeedRankMapper extends BaseMapperX<FeedRankDO> {
.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<FeedRankDO>()
.eq(FeedRankDO::getGameId, gameId));
}
}

View File

@ -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);
}

View File

@ -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 排序值 BigDecimalfeed_rank 列为 DECIMAL10,4null 安全缺省 0
*

View File

@ -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);
}
// ============================== 测试夹具 ==============================
/** 构造排序覆盖层记录 */

View File

@ -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项目态 45 + feed_rank 全分区 status=0同一本地事务任一步失败全回滚含审核记录
publishOrchestrationService.unlist(project);
} else if (!isApprove) {
// REJECT 直写分支原样保留直接写目标状态 REJECTED(3)
ProjectDO update = new ProjectDO();
update.setId(project.getId());
update.setStatus(targetStatus);

View File

@ -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_rankstatus=0对齐 V4 DDL下架联动设计意图封死写侧残留
*
* 裁决边界2026-06-10 数据回路小波·修3game_runtime_package.status 不动2=已失效语义是包本体失效且不可逆
* 下架=可见性决策存量直链经 play 门禁仍可玩属可接受残余game_version.status 不动版本状态机无下架态
* 版本是构建产物事实记录强行回翻=伪造历史
* 幂等状态机拒绝重复第二次 decision=3 时项目已 UNLISTED(5)PUBLISHED调用方在任何写发生之前抛
* PROJECT_REVIEW_STATUS_INVALIDofflineRank 自身天然幂等 0 0
*
* @param project 待下架项目 DO须已查出提供 id调用方已校验其处于 PUBLISHED(4)
*/
void unlist(ProjectDO project);
}

View File

@ -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 事务内本地调用 Feignfeed 侧抛错则本编排抛出 reviewProject 事务整体回滚含审核记录
// 行数 0 也合法无基线行的存量数据记审计日志不抛错裁决runtime_package/version 状态不动见接口 javadoc
Integer offlinedRows = feedApi.offlineRank(project.getId()).getCheckedData();
log.info("[unlist] 下架编排完成 gameId={}, offlinedRows={}", project.getId(), offlinedRows);
}
}

View File

@ -254,21 +254,44 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest {
@Test
void testReviewProject_unlistFromPublished() {
// 数据回路小波·修3UNLIST 不再由本方法直写项目态而是委托下架编排项目态 45 + 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<ProjectDO> 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<ReviewRecordDO> 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<ProjectDO> captor = ArgumentCaptor.forClass(ProjectDO.class);
verify(projectMapper).updateById(captor.capture());
assertEquals(ProjectStatusEnum.UNLISTED.getStatus(), captor.getValue().getStatus());
// project 权威状态机委托下架编排项目态 45 + feed 下线+ 落审核记录block
verify(publishOrchestrationService).unlist(project);
verify(reviewRecordMapper).insert(any(ReviewRecordDO.class));
// 状态翻转在编排内 mock本方法不再直写项目态带类型消歧
verify(projectMapper, never()).updateById(any(ProjectDO.class));
}
@Test

View File

@ -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 翻项目态 45 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));
// 翻项目态 45
ArgumentCaptor<ProjectDO> 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;
}
}

View File

@ -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<GameStatDO> {
.eq(GameStatDO::getStatDate, statDate));
}
/**
* 原子累加 upsert数据回路小波·修2单语句 INSERTON 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_idquality_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);
}

View File

@ -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不实现
*
* v2v3 关键改动数据回路小波·修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=0sort_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=0sort_score 砸到 boost B2 实证误降分
* game_play_start 只累加 play_count 不触发重算新分母由下一条 engagement 事件重算时自然带入收敛
* 已知无害边界favorite 在集合内但无累加映射favorite_count 既有缺口本波不补列触发重算结果不变仅多一次无害回灌
*/
private static final Set<String> 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_scoreDECIMAL5,2写回 stat
BigDecimal qualityScore = computeQualityScore(stat);
GameStatDO scoreUpdate = new GameStatDO();
scoreUpdate.setId(stat.getId());
scoreUpdate.setQualityScore(qualityScore);
gameStatMapper.updateById(scoreUpdate);
// 5) 回灌 feed rankQUALITY_REFRESHquality_score 精度转 DECIMAL10,4sort_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 Doublefeed 侧落 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_mslike like_countshare share_count
String event = envelope.getEvent();
Map<String, Object> 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_scoreDECIMAL5,2写回 stat独立 updateById不与累加语句混写避免覆盖计数
BigDecimal qualityScore = computeQualityScore(stat);
GameStatDO scoreUpdate = new GameStatDO();
scoreUpdate.setId(stat.getId());
scoreUpdate.setQualityScore(qualityScore);
gameStatMapper.updateById(scoreUpdate);
// 5) 回灌 feed rankQUALITY_REFRESHquality_score 精度转 DECIMAL10,4sort_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 Doublefeed 侧落 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_countgame_play_end play_end_count(+completed_count if completed)+total_duration_ms
* like like_countshare 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<String, Object> 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
*

View File

@ -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 模块测试基建仅 Mockitotelemetry-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());
}
/**
* 守卫必须为单语句 INSERTON 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<String, String> 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<String> columns = splitTrim(matcher.group(1));
List<String> placeholders = splitTrim(matcher.group(2));
// 列数与占位数一致
assertEquals(columns.size(), placeholders.size(), "INSERT 列数与 VALUES 占位数必须一致");
// 逐位对应冻结契约列顺序 占位顺序V5 DDL 计数列全 NOT NULL DEFAULT 0审计/租户缺省列可省略
List<String> expectedColumns = List.of(
"game_id", "stat_date", "play_count", "play_end_count",
"completed_count", "total_duration_ms", "like_count", "share_count");
List<String> expectedPlaceholders = List.of(
"#{gameId}", "#{statDate}", "#{dPlay}", "#{dPlayEnd}",
"#{dCompleted}", "#{dDurationMs}", "#{dLike}", "#{dShare}");
assertEquals(expectedColumns, columns, "INSERT 列清单/顺序偏离冻结契约");
assertEquals(expectedPlaceholders, placeholders, "VALUES 占位清单/顺序偏离冻结契约(列错位会写串数据)");
}
/**
* 按逗号切分并去除首尾空白解析括号组内的列/占位清单用
*/
private List<String> splitTrim(String group) {
return Arrays.stream(group.split(","))
.map(String::trim)
.toList();
}
}

View File

@ -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 冻结签名 insertOrAccumulatequality_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<TelemetryEventDO> 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_countdPlay=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<String, Object> 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=1completed=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<GameStatDO> 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());
// 回灌 feedmode=QUALITY_REFRESHgameId/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.0060×完玩率 1/1 + 20×0 + 20×0写回行 id=7
ArgumentCaptor<GameStatDO> 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()));
// 回灌 feedmode=QUALITY_REFRESHgameId/versionId 透传分值正确
ArgumentCaptor<FeedRankUpsertReqDTO> 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 事件命中既有 statplay_count=2,like_count=1 like_count 累加为 2quality_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 12 算分回灌
// 累计行视图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 12仅累加 like 涉及列
ArgumentCaptor<GameStatDO> 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<GameStatDO> 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 恒受理 ==============================