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