refactor/优化陶泥平台发布 #670

Merged
k88936 merged 105 commits from style/polish-taonier-publish into master 2026-10-08 18:07:42 +08:00
Member

close #668
参考github的结构重新组织了endpoint

方法 路径 请求 响应 鉴权 / 头
POST /api/game-distribution/games multipart:metadata = NewGameVersionRequest(JSON),cover / screenshot 二进制 part GameDistributionPublishVersionResponse Bearer + Idempotency-Key + 发布灰度
POST /api/game-distribution/games/{game_id}/versions/{version_number} 同上,路径值优先并与 body 校验一致 GameDistributionPublishVersionResponse Bearer + Idempotency-Key + 发布灰度
GET /api/game-distribution/games/{game_id}/versions/{version_number} – 版本详情(GameDistributionPrivateVersion + 作品上下文) Bearer
PATCH /api/game-distribution/games/{game_id}/versions/{version_number} JSON:GameMetadata + expectedPublicationRevision CAS 版本详情 Bearer + Idempotency-Key
PUT …/versions/{version_number}/package application/octet-stream 上传 ack Bearer + Idempotency-Key
GET …/versions/{version_number}/package/upload-state – 上传状态 Bearer
PUT …/versions/{version_number}/package/chunk application/octet-stream + 偏移头 分片 ack Bearer
POST …/versions/{version_number}/package/complete JSON ack Bearer + Idempotency-Key
POST …/versions/{version_number}/package/reset JSON ack Bearer
PUT / GET / PUT / POST / POST …/versions/{version_number}/source(/upload-state /chunk /complete /reset) 与发行包族逐条对齐 同发行包族 Bearer(幂等键同族)
POST …/versions/{version_number}/submit JSON:expectedPublicationRevision 版本详情 Bearer + Idempotency-Key
POST …/versions/{version_number}/cancel JSON:CancelVersionRequest 版本详情 Bearer + Idempotency-Key
PATCH /api/game-distribution/my-games/{game_id} multipart:metadata = GameDistributionUpdateGameMetadataRequest(并入 forkAuthorization)+ 媒体 part { game, replayed } Bearer + Idempotency-Key
DELETE /api/game-distribution/my-games/{game_id} – 软删 ack Bearer
GET /api/game-distribution/games/{game_id}/forks – GameDistributionDerivedResponse 公开(原 /derived 改名)
GET /api/game-distribution/games/{game_id}/network – GameDistributionLineageResponse(整棵改编树) 公开(原 /lineage 改名)
GET /api/game-distribution/games/{game_id}/source(/package /project) – GameDistributionForkSourceResponse / 二进制 Bearer(原路径改名)
GET /api/game-distribution/games/{game_id}/network/stats – GameDistributionContributionResponse(子树归集) Bearer(原路径改名)

close #673

close #668 参考github的结构重新组织了endpoint | 方法 | 路径 | 请求 | 响应 | 鉴权 / 头 | |-------------------------------|---------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------|---------------------------------------| | POST | `/api/game-distribution/games` | multipart:`metadata` = `NewGameVersionRequest`(JSON),`cover` / `screenshot` 二进制 part | `GameDistributionPublishVersionResponse` | Bearer + `Idempotency-Key` + 发布灰度 | | POST | `/api/game-distribution/games/{game_id}/versions/{version_number}` | 同上,路径值优先并与 body 校验一致 | `GameDistributionPublishVersionResponse` | Bearer + `Idempotency-Key` + 发布灰度 | | GET | `/api/game-distribution/games/{game_id}/versions/{version_number}` | – | 版本详情(`GameDistributionPrivateVersion` + 作品上下文) | Bearer | | PATCH | `/api/game-distribution/games/{game_id}/versions/{version_number}` | JSON:`GameMetadata` + `expectedPublicationRevision` CAS | 版本详情 | Bearer + `Idempotency-Key` | | PUT | `…/versions/{version_number}/package` | `application/octet-stream` | 上传 ack | Bearer + `Idempotency-Key` | | GET | `…/versions/{version_number}/package/upload-state` | – | 上传状态 | Bearer | | PUT | `…/versions/{version_number}/package/chunk` | `application/octet-stream` + 偏移头 | 分片 ack | Bearer | | POST | `…/versions/{version_number}/package/complete` | JSON | ack | Bearer + `Idempotency-Key` | | POST | `…/versions/{version_number}/package/reset` | JSON | ack | Bearer | | PUT / GET / PUT / POST / POST | `…/versions/{version_number}/source`(`/upload-state` `/chunk` `/complete` `/reset`) | 与发行包族逐条对齐 | 同发行包族 | Bearer(幂等键同族) | | POST | `…/versions/{version_number}/submit` | JSON:`expectedPublicationRevision` | 版本详情 | Bearer + `Idempotency-Key` | | POST | `…/versions/{version_number}/cancel` | JSON:`CancelVersionRequest` | 版本详情 | Bearer + `Idempotency-Key` | | PATCH | `/api/game-distribution/my-games/{game_id}` | multipart:`metadata` = `GameDistributionUpdateGameMetadataRequest`(并入 `forkAuthorization`)+ 媒体 part | `{ game, replayed }` | Bearer + `Idempotency-Key` | | DELETE | `/api/game-distribution/my-games/{game_id}` | – | 软删 ack | Bearer | | GET | `/api/game-distribution/games/{game_id}/forks` | – | `GameDistributionDerivedResponse` | 公开(原 `/derived` 改名) | | GET | `/api/game-distribution/games/{game_id}/network` | – | `GameDistributionLineageResponse`(整棵改编树) | 公开(原 `/lineage` 改名) | | GET | `/api/game-distribution/games/{game_id}/source`(`/package` `/project`) | – | `GameDistributionForkSourceResponse` / 二进制 | Bearer(原路径改名) | | GET | `/api/game-distribution/games/{game_id}/network/stats` | – | `GameDistributionContributionResponse`(子树归集) | Bearer(原路径改名) | close #673
k88936 added 5 commits 2026-10-07 17:18:16 +08:00
- 删除陶泥儿表单每个文本字段的「复制」按钮接线
- 移除 taonierFields 的 copyable 元数据
- 清理测试里无用的剪贴板与文件保存替身
- 恢复 read_game_publish_availability 原生命令,读取 frontend-config 的 gameDistributionPublishEnabled
- desktop.rs 重新注册发布灰度命令,check-config 白名单重新登记
- 恢复 services 层 readGamePublishAvailability facade 并补原生调用用例
- 新增 useGamePublishAvailability,读取中与读取失败都按未命中 fail closed
- useTaonierTab 自行读取灰度并并入 enabled/draftEnabled,未命中不起订阅与注册表读写
- 陶泥儿 tab 灰度未命中时渲染 EmptyTab,与 TapTap 空 tab 一致,并补空占位用例
- owner 媒体读改为校验 objectKey 落在该作品媒体命名空间,不再要求命中游戏行当前媒体
- 删除只服务旧判定、已无调用方的 game_media_object_keys
- 新增 owner 媒体读按命名空间授权的单测:待审 / 历史版本 key 放行,他人与项目快照 key 拒绝
- 同步后端数据契约、平台玩法链路、AGC 技术方案与项目记忆(决策记录、踩坑记录)
- 玩法链路新增「游戏分发统一发布接口与版本号自然幂等合同(2026-10-07)」,取代旧的 POST /games 与 POST /games/{gameId}/versions 两条路由
- 玩法链路更新 versionNumber 必填且客户端冻结、同号同版本、幂等双口径、媒体只解析一次与 API 路由表
- 后端数据契约补充统一发布接口、自然幂等键、确定性 gameId 与单事务 procedure
- AGC 实施计划更新发布媒体为单次 POST /versions 并冻结版本号
- decision-log 记录统一发布接口与版本号自然幂等决策
- pitfalls 记录首次发布封面/截图重复上传的现象、根因与现行口径
docs(游戏分发): 新增统一发布接口里程碑与实施计划
Project CI / Backend tests (pull_request) Failing after 24s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 5m52s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m32s
Project CI / Repository checks (pull_request) Failing after 19s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m53s
Project CI / Frontend tests (pull_request) Successful in 2m32s
Project CI / Native shell tests (pull_request) Successful in 10m22s
Project CI / AI game creator shell web tests (pull_request) Failing after 4m2s
6e13c32223
- 里程碑规范固定目标、边界、M1–M5 验收标准与门禁
- 实施计划固定统一请求/响应形状、领域规则收敛、小而深模块拆分与提交切分
- 实施计划固定验证矩阵、风险回滚与非目标
- docs/README.md 索引新增合同、里程碑与实施计划入口
Author
Member
  • 1. apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx:78-79(maintainability · low)

    • 原实现:JSDoc 已写成「发布入口」,但 prop 仍叫 onRequestExport,经 DirectProjectChatBox、DirectProjectChatHeader、App.tsx 一路透传,测试也按旧名断言。
    • 问题:语义早已是「打开 AGC 发布面板」而非导出,旧名字误导后续读者。
    • 建议 / 修复:onRequestExport → onRequestPublish,机械改 5 个文件;按钮文案本来就是「发布」,行为不变。
    • 处理:已修复,提交 199026617。
  • 2. packages/shared/src/contracts/gameDistributionPublish.ts:32(maintainability · medium)

    • 原实现:gameMetadata: GameDistributionCreateGameRequest,该类型含 projectKey;同一请求里顶层还有一个 projectKey。
    • 问题:服务端只读顶层 projectKey 解析身份,嵌套的 projectKey 既不参与身份、也不进冻结快照,却会进 publish_request_digest——同一个逻辑发布带不同嵌套值会算出不同摘要,重试变成摘要冲突。一个 payload 能携带两个不同 projectKey,契约有歧义。
    • 建议 / 修复:建议用专用类型 Omit<GameDistributionCreateGameRequest, 'projectKey'>(Rust 侧同样拆一个不含 project_key 的 metadata 类型)并同步 parity 检查;退而求其次是在两侧注释明确「嵌套值被忽略」。这是契约形状变更,留给你定。
    • 处理:已修复,提交 8a42d20ee。采用方案 A:外层改名 NewGameVersionRequest,内层改名 GameMetadata。GameMetadata 由 Rust 经 ts-rs 生成 TS 绑定(packages/shared/src/contracts/generated/GameMetadata.ts),TS 侧删除手写镜像,且该类型不再含 projectKey——同一 payload 不再能携带两个身份值,摘要歧义消失。
  • 3. packages/shared/src/contracts/index.ts:6(style · low)

    • 原实现:export * from './gameDistributionPublish'。
    • 问题:该模块只导出类型,export * 会把它当运行时模块再导出,且与同文件 ./creator、./editorAudio、./hyper3d 的 export type * 不一致。
    • 建议 / 修复:改为 export type *。
    • 处理:已修复,提交 12ee915c0(TS 5.8.2 支持该语法,tsc 与 parity 均通过)。
  • 4. packages/shared/src/index.ts:10(style · low)

    • 原实现:export * from './contracts/gameDistributionPublish'。
    • 问题:同 #3,type-only 模块不该进运行时模块图。
    • 建议 / 修复:改为 export type *。
    • 处理:已修复,提交 1af94272d。
  • 5. .../export/tabs/taonier/FormCard.tsx:352-353(style · low)

    • 原实现:截图槽位用 reusedKey ? (reusedPreview ? <img/> : <span/>) : <IconPreview/> 的嵌套三元。
    • 问题:违反仓库「禁止嵌套三元」约定;同文件 inputSlot 已刻意用显式分支。
    • 建议 / 修复:在 return 前用 let screenshotSlot: ReactNode + if / else if / else 赋值。
    • 处理:已修复,提交 2ed5b9265(ReactNode 已在该文件导入)。
  • 6. .../export/tabs/taonier/FormCard.tsx:420(bug · medium)

    • 原实现:「沿用」截图按钮 disabled={onlineScreenshots.length === 0},只要上一版本有截图就可点。
    • 问题:点一下会把整个 screenshotPaths 替换成上一版本 key,静默丢掉用户已选的本地截图;草稿已完全沿用(顺序与值都一致)时按钮仍可点,不镜像文字字段「已相同则禁用」的行为。
    • 建议 / 修复:disabled 增加「草稿截图槽位与 onlineScreenshots 逐槽相等」的条件。属于交互行为变动,留给你。
    • 处理:不改。按你的决定:UI 允许该字段承载 OSS objectKey(用于和线上键对照),「沿用上一版本」按钮整组替换截图的交互保留。
  • 7. server-rs/crates/shared-contracts/src/game_distribution_publish.rs:18-23(bug · medium)

    • 原实现:DTO 允许 game_id 与 project_key 同时非空;服务端不拒绝。
    • 问题:publish_version 用 game_id 定位游戏,但幂等锚 game_distribution_version_key 优先取 projectKey。同时带两者时,版本写在 gameId 名下、收据却记成 {projectKey}:v{n};之后真有一次同 projectKey/versionNumber 的首次发布会撞上这张收据(摘要不同 → 409,或错误重放)。
    • 建议 / 修复:在 DTO / handler 强制二选一(同时非空就 400),或让幂等锚在 gameId 存在时一律取 gameId,并把优先级写进契约。属于契约语义收紧,留给你。
    • 处理:已修复,提交 5b7dd3e4b。按你的决定,幂等键改为覆盖 gameId + projectKey + versionNumber:game_distribution_version_key(game_id, project_key, version_number),不再依赖「优先取谁」的歧义优先级;首次发布(只有 projectKey)与更新(带 gameId)都落到同一把自然键上。
  • 8. server-rs/crates/shared-contracts/src/game_distribution_publish.rs:37-38(maintainability · low)

    • 原实现:嵌套的 GameDistributionCreateGameRequest 自身带 project_key(由 local_project_id 改名而来)。
    • 问题:与 #2 同根因的 Rust 侧。服务端只认顶层 payload.project_key,嵌套值被忽略却不进冻结快照、只进摘要,导致「改嵌套值就摘要冲突」。
    • 建议 / 修复:与 #2 一起处理(专用不含 project_key 的 metadata 类型,或两侧注释明确忽略)。与 #2 同属契约变更,留给你。
    • 处理:已修复,提交 8a42d20ee(与 #2 同一次变更)。Rust 侧内层改为不含 project_key 的 GameMetadata,服务端只从外层请求读身份。
  • 9. .../export/state/useTaonierExport.ts:136-140(bug · medium)

    • 原实现:草稿为空截图列表时不再回填 online.screenshots,空列表被按字面理解为「提交 0 张截图」。
    • 问题:旧语义下写的历史草稿 screenshotPaths: [] 会被原样提交,更新发布时静默丢掉已发布的截图;仓库里没有标记区分「历史空」与「用户主动清空」。
    • 建议 / 修复:在加载时对 online.state === 'update' 且 online.screenshots 非空的空草稿做一次性迁移回填,而不是只靠用户点「沿用上一版本截图」。涉及历史草稿迁移,留给你。
    • 处理:保持现状。你确认这是预期行为;后端 API 也一致——screenshots 空数组就是字面「本次提交 0 张截图」,没有「空 = 未填」的隐式回填。旧草稿兼容问题因产品尚未上线、无历史数据,不做迁移。
  • 10. server-rs/crates/module-game-distribution/src/commands.rs:53-54(maintainability · low)

    • 原实现:PublishVersionInput 在定义与 lib.rs 再导出出现,全仓库无构造 / 反序列化;真实路径用 spacetime-module 里字段更全的 GameDistributionPublishVersionInput。
    • 问题:两个描述同一命令的结构体会静默漂移,且文档注释让人误以为它是在役契约。
    • 建议 / 修复:删除无调用方的结构体与再导出(仓库约定「无现役调用方直接清理」)。
    • 处理:已修复,提交 c90f14b9b(module-game-distribution 32 passed)。
  • 11. server-rs/crates/module-game-distribution/src/domain.rs:73(maintainability · low)

    • 原实现:derive_game_distribution_game_id 直接哈希原始字节,不 trim;兄弟函数 game_distribution_version_key 会 trim。
    • 问题:调用方若忘了预归一化," proj-1" 与 "proj-1" 派生出两个不同且已持久化的身份,事后难恢复。
    • 建议 / 修复:函数内 project_key.trim()(owner_user_id 来自鉴权、本就稳定,未动以免改变既有 ID),与幂等键口径一致。
    • 处理:已修复,提交 87dc80c75,并补单测断言两侧空白归一化。
  • 12. server-rs/crates/api-server/src/modules/game_distribution_publish.rs:110-114(bug · medium)

    • 原实现:幂等键 game_distribution_version_key(project_key.as_deref(), game_id, version_number);首次发布只带 projectKey 时锚是 projectKey,更新只带 gameId 时锚退回 game_id。
    • 问题:同一次发布的同 versionNumber 若从 projectKey 形态切到返回的 gameId 形态重试,会算出不同键、错过既有收据;而 game_distribution_version 在 (game_id, version_number) 上没有唯一索引,于是插入重复版本行。
    • 建议 / 修复:current 已经带游戏行存储的 project_key,请求没带 projectKey 时用它补锚:
      let anchor = project_key.clone().or_else(|| current.as_ref().and_then(|g| g.project_key.clone())); 再传给 game_distribution_version_key。属于幂等行为改动,留给你。
    • 处理:已修复,提交 5b7dd3e4b(与 #7 同一处)。键改成 {gameId}:{projectKey}:v{n} 后,projectKey 形态与 gameId 形态不再算出两把键,「错过收据 → 重复版本行」的路径随之消失。(game_id, version_number) 唯一索引未加;若要数据库级兜底可另开一项。
  • 13. .../export/tabs/taonier/ConflictCard.tsx:34-38(bug · medium)

    • 原实现:differing 过滤用 formatFieldValue 的输出比较。
    • 问题:该格式化器把任何 objectKey 折叠成「沿用上一版本」。两个不同的上一版本 key(或同 key 不同顺序)会格式化成相同字符串而判为一致,真实冲突被隐藏,「按选择保存」会不问就保留用户值。
    • 建议 / 修复:展示与相等分离——differing 里对截图数组逐槽 trim() 比较,formatFieldValue 只用于渲染 Choice。属于冲突判定行为改动,留给你。
    • 处理:待你确认后实施。你的方向:diff resolver 展示精确值、不做预处理。示例:线上截图是 game-media/shot-A.png,用户本地槽位是 game-media/shot-B.png;两者都命中「媒体 objectKey」,formatFieldValue 把它们都折叠成 沿用上一版本,于是 differing 判为相等、整行不显示,真实的线上键差异被隐藏。实施方式(相等判定直接比较原始值、截图逐槽比较,formatFieldValue 只用于渲染)已确定;唯一待定的是展示口径:按你说的给精确 objectKey,还是保留「沿用上一版本」这种友好文案?确认后我再改。
  • 14. .../export/tabs/taonier/Page.tsx:18-20(other · low)

    • 原实现:灰度未命中(含读取中 / 读取失败)时渲染 EmptyTab,文案固定是「陶泥儿导出页尚未接入」。
    • 问题:功能其实已实现,只是当前账号灰度没命中;「尚未接入」会让用户以为功能不存在。
    • 建议 / 修复:EmptyTab 增加可选 message,陶泥儿灰度未命中传「陶泥儿发布正在灰度中,暂未对你开放」。
    • 处理:已修复,提交 a347d9282,同步 exportGamePublishAvailability 断言并移除不再使用的导入。
  • 15. server-rs/crates/spacetime-client/src/game_distribution.rs:704(bug · critical)

    • 原实现(review 描述):方法引用了 GameDistributionPublishVersionInput 与新 procedure,但 module_bindings.rs 未重新生成。
    • 问题:review 生成时点的状态。当前 module_bindings.rs 已重新生成:旧 create_game_distribution_* 声明为 0、新 publish_game_distribution_version_and_return_procedure / game_distribution_publish_version_input_type 已声明并再导出,cargo check -p spacetime-client --all-targets 通过。
    • 处理:过时,无需改动(绑定在 c7c99567b / 8a38ce35a 已重生成)。
  • 16. server-rs/crates/spacetime-client/src/game_distribution.rs:742-745(maintainability · low)

    • 原实现:match (game, version) 把「缺作品快照」与「缺版本快照」都收敛成 missing_snapshot("游戏发布结果")。
    • 问题:mapper 本就分别返回两个 Option,合并后无法判断缺的是哪一半,排障变难。
    • 建议 / 修复:按 (Some,None)、(None,Some)、(None,None) 给三种不同文案。
    • 处理:已修复,提交 c9190a26f。
  • 17. .../export/state/useTaonierTab.ts:287-291(bug · medium)

    • 原实现:发布后 await taonier.refreshOnline(),回来后 rewriteScreenshotsToOnline(refreshed) 无条件把 screenshotPaths 覆盖成回读 key。
    • 问题:await 期间用户仍可增删换截图;回读解析后覆盖会静默吞掉这些编辑,且 updateImageFields 会标 dirty 把被覆盖的状态自动存盘。
    • 建议 / 修复:发布时快照 effectiveForm.screenshotPaths,回读后只有当前槽位仍与快照逐槽相等才改写。属于行为改动,留给你。
    • 处理:已修复,提交 6fdca1f8c。按你的方向把「回读线上媒体」并进发布进度弹窗:发布成功后弹窗保持 running(“正在回读线上媒体…”),等 refreshOnline 与回写都完成再关闭;弹窗开着时页面不可操作,用户没法在 await 窗口改截图,回写不会静默覆盖。回读失败单独 catch,不把已成功的发布误报成「发布失败」。
  • 18. .../export/state/useTaonierExport.ts:754-756(maintainability · low)

    • 原实现:adoptOnlineScreenshots 与 rewriteScreenshotsToOnline 各自写一遍 map(image => image.objectKey.trim()) + 丢空值。
    • 问题:同一归一化口径重复两处,新增调用点会继续复制。
    • 建议 / 修复:抽模块级 helper 复用。
    • 处理:已修复,提交 aecb5a1ae(新增 onlineScreenshotObjectKeys,taonierExport 30 passed)。
  • 19. server-rs/crates/spacetime-module/src/game_distribution.rs:2145-2150(bug · high)

    • 原实现:统一发布路径删掉了旧 create_game_distribution_version_tx 里「把同 game_id + version_number 的 pending_review 版本取消替换」的循环。
    • 问题:version_number 现在是客户端可复用的标签;同一标签重新提交会留下多个同标签 pending_review 版本,而 approve_game_distribution_version_tx 不带「有无更新版本」的守卫,运营可能激活过期包。
    • 建议 / 修复:插入新版本前,对同 game_id 同 version_number 的 pending_review 版本重新执行取消(status → cancelled,reason「被同一项目版本的新提交替代」)。属于审核语义,留给你。
    • 说明(无术语):同一款游戏、同一个版本号(比如都叫「1.2」)可以一前一后提交两份待审包。审核台若按版本号打开,可能点开先提交的旧包上线,而后来提交的新包还躺在待审列表。旧流程在插入新包时会把同号旧待审包标成「已取消」,统一发布后这一步没了。
    • 处理:保持现状(按你的要求只解释);要恢复就在插入新版本前,把同 game_id + 同 version_number 的 pending_review 置为 cancelled。
  • 20. server-rs/crates/spacetime-module/src/game_distribution.rs:2084(bug · medium)

    • 原实现:let _game_id = required_game_distribution_text(input.game_id.clone(), "game_id")?; 校验并 trim 了 game_id,却把它丢进 _game_id;后续查找、插入与收据写仍用原始 input.game_id。
    • 问题:带首尾空白的 game_id 能过校验但绕过归一化,可能创建出与后续查找所用值不一致的行。
    • 建议 / 修复:保留校验后的值并贯穿整条事务,而不是丢弃。属于行为修正,留给你。
    • 说明(无术语):game_id 是作品编号。这个编号既可能是客户端显式传的(更新既有作品),也可能是服务端为首次发布派生的;模块对编号做了一次「去掉首尾空格」的校验,但校验结果被丢进 _game_id,后面查找、写入、收据还是用原始没去空格的编号。所以这次 trim 没有任何实际效果,是一段没接上的防御代码。
    • 处理:保持现状(按你的要求只解释);要么把校验后的值贯穿整条事务,要么删掉这行无效校验,当前无害。
  • 21. server-rs/crates/spacetime-module/src/game_distribution.rs:2018-2028(bug · medium)

    • 原实现:game_id 命中分支会拒绝 SUSPENDED 游戏,但 (owner, project_key) 复用回退只过滤软删。
    • 问题:请求的 game_id 不存在、而存在同 project_key 的被暂停游戏时,会静默复用它并为它建新版本,绕过了暂停限制。
    • 建议 / 修复:这里也补 GAME_DISTRIBUTION_VISIBILITY_SUSPENDED 检查,与 game_id 分支一致。属于行为修正,留给你。
    • 处理:已修复,提交 db0d99fc8。(owner, project_key) 回退分支补上 SUSPENDED 检查,与 game_id 命中分支一致,并补单测 suspended_game_is_rejected_for_publish。
  • 22. server-rs/crates/spacetime-module/src/game_distribution.rs:2037-2040(maintainability · low)

    • review 描述:旧的 create-game 事务通过 required_game_distribution_text 校验并 trim title/summary/category/orientation,新 insert 原样落库。
    • 核实:误报。用 Git 取 7a710bb2b^ 的旧实现核对,旧 create_game_distribution_game_tx 与旧 api-server 都是 input.title.clone() / payload.title.clone() 原样透传,从未 trim。现实现没有回归。
    • 说明:确实存在「作品行未 trim、而版本快照 resolve_version_metadata_json 里 title/summary/tags 会 trim」的口径差,但这是既有状态、且属于会改持久化数据的变更。若要对齐再单独决定。
    • 处理:误报,未改动。
  • 23. src/components/game-distribution/gamePublishSubmission.ts:219(bug · medium)

    • 原实现:更新模式指向与既有草稿不同的游戏时,resolveDraftIdentity 会新铸 projectKey/versionNumber,但下一行仍无条件把旧草稿的 versionId 带进新草稿。
    • 问题:新草稿出现 gameId 指新游戏、versionId 属旧游戏的错配;恢复时 GamePublishPage 直接 getGameVersion(stored.versionId) 不校验归属,会加载并可能重提旧游戏的版本。
    • 建议 / 修复:只有持久化身份仍指向同一游戏(或 create 模式)时才带 versionId。属于恢复行为修正,留给你。
    • 处理:已修复,提交 f96842960。只有持久化身份仍指向同一游戏时才附带旧草稿 versionId,并补单测「更新目标换成另一款游戏时丢弃旧草稿的 versionId」。
  • 24. src/components/game-distribution/gamePublishDraft.ts:45(bug · medium)

    • 原实现:schema 现在要求 projectKey,但存储键仍是 ...publish-draft.v1。
    • 问题:旧流程写的草稿(有 gameId+versionId、无 projectKey)会被 isDraft 判否、被 readPublishDraft 静默丢弃,用户无法续上在途发布;重试会新铸 projectKey,可能新建重复游戏/版本并孤立服务端旧版本。
    • 建议 / 修复:接受旧形状并在读取时回填本地 projectKey,或一次性迁移旧键。属于持久化兼容,留给你。
    • 处理:不做迁移。按你的决定:产品尚未上线、没有历史草稿数据,旧存储键兼容与一次性迁移都不做。
  • 25. src/components/game-distribution/gamePublishDraft.ts:49-50(maintainability · low)

    • 原实现:两个独立检查允许只带 gameId 或只带 versionId 的半写草稿进入恢复路径。
    • 问题:半成品服务端身份会流到恢复流程。
    • 建议 / 修复:要求 gameId/versionId 同时有或同时无(hasGameId === hasVersionId)。
    • 处理:已修复,提交 a4bd72887,新增 gamePublishDraft.test.ts 覆盖 4 例。
  • 26. src/components/game-distribution/gamePublishSubmission.ts:149-154(maintainability · low)

    • 原实现:create 模式只要存在 owned 草稿就无条件复用其 projectKey/versionNumber;publish 阶段失败留下的草稿没有 versionId,页面只在 draftDetail 可用时渲染恢复提示与清除按钮。
    • 问题:这种「隐形草稿」无法放弃。若原发布其实成功但响应丢失,之后内容变了再提交会 409,用户没有开始全新发布的入口。
    • 建议 / 修复:review 建议「只在草稿有 versionId 时才复用」——这条不建议照做:projectKey 在响应前落草稿,正是为「响应丢失后重试命中同一身份」服务,砍掉会破坏自然幂等。真正该补的是一个针对无 versionId 草稿的显式重置/放弃入口。属于产品/交互决定,留给你。
    • 处理:不做迁移。按你的决定:产品尚未上线、没有历史数据,这类无 versionId 隐形草稿的显式重置 / 放弃入口暂不新增。
  • 27. server-rs/crates/spacetime-client/src/module_bindings/publish_game_distribution_version_and_return_procedure.rs:24(bug · high)

    • review 描述:新 procedure 模块成了孤儿,父模块未声明/再导出。
    • 核实:当前父 module_bindings.rs 已同时有 pub mod publish_game_distribution_version_and_return_procedure; 与对应 pub use,spacetime-client 编译通过。
    • 处理:过时,无需改动。
  • 28. server-rs/crates/spacetime-client/src/module_bindings/game_distribution_publish_version_input_type.rs:9(bug · critical)

    • review 描述:文件已改名,但父模块仍声明旧的 game_distribution_create_game_input_type,会编译失败。
    • 核实:父模块已更新为 game_distribution_publish_version_input_type / GameDistributionPublishVersionInput,旧声明计数为 0,旧文件已删,编译通过。
    • 处理:过时,无需改动。
  • 29. server-rs/crates/spacetime-module/src/game_distribution.rs:2005-2008(bug · high)

    • review 描述:确定性 gameId 下软删后重发会命中该分支并以「游戏已被删除」失败关闭,delete→re-publish 链路断裂。
    • 核实:已在后续提交修复——命中软删行时就地复活覆盖为全新作品(清 deleted_at、回未公开、publication_revision=0、清空活动版本与计数、用本次资料覆盖),并清掉该作品旧的 create_version 收据;api-server 仅在显式 gameId 等于该 projectKey 的确定性身份时走复活。
    • 处理:已修复,提交 90f4e0cfb(本轮之前完成),单测 revive_overwrites_soft_deleted_game_as_fresh_identity 锁定。
  • 30. server-rs/crates/spacetime-module/src/game_distribution.rs:2123-2125(maintainability · low)

    • 原实现:if game.owner_user_id != owner || version.owner_user_id != owner 合并判断,只回「游戏 owner 不匹配」。
    • 问题:只有版本 owner 不符时,报错指向错的对象,误导排障。
    • 建议 / 修复:拆成两条独立校验,分别报游戏 / 版本 owner 不匹配。
    • 处理:已修复,提交 9440ef037(spacetime-module game_distribution 7 passed)。
  • 31. src/components/game-distribution/gamePublishSubmission.ts:252(bug · medium)

    • 原实现:提交模块用 versionId 派生确定性的 ${versionId}:upload / ${versionId}:submit 幂等键;但恢复路径 GamePublishPage.handleResumeSubmit 仍用每会话随机的 resolvePublishKey()。
    • 问题:刷新页面后恢复,同一上传/送审会带不同 Idempotency-Key,服务端无法关联去重「其实已落地但响应丢失」的请求。
    • 建议 / 修复:统一键方案,恢复路径也改用基于 versionId 的确定性键。属于幂等行为改动,留给你。
    • 处理:已修复,提交 80e910dfe。恢复路径改用基于 versionId 的确定性键({versionId}:upload / {versionId}:submit),并补断言锁定。
  • 32. src/components/game-distribution/GamePublishPage.tsx:540-544(maintainability · low)

    • 原实现:handleSubmit 与 handleUpdateSubmit 逐字复制「prepareGamePackage → 媒体 submission → metadata/parts → submitGamePublish」,只差 target、versionNumber 与成功收尾。
    • 问题:新增字段或校验变化时要改两处,两条链路会漂移。
    • 建议 / 修复:抽组件内 runUnifiedPublish({ target, versionNumber, priceMudPoints }),成功收尾仍各模式自理。
    • 处理:已修复,提交 f3c3f279b(GamePublishPage 19 + gamePublishSubmission 7 passed)。
# 问题 提交
1 发布入口键盘焦点不可辨认 3cd6f2f11
5 「沿用上一版本截图」按钮永远可点 695db9087
6 陶泥儿封面 / 上一版本预览嵌套三元 b823b397c
7 上一版本图片查找逻辑重复 f53498516
9 buildTaonierPublishMetadata 封面嵌套三元 c800ddc19
11 AGC 发布媒体前缀与 Rust 常量无门禁 ce32f4a1b
13 冲突卡排序比较导致顺序改动被吞 1bd0a3e0b
14 publish_game_distribution_version 缺文档 5f084d0ca
22 发布回读后永远走不到的 catch 7591b1f9d
25 forkSource 改名遗留无 TODO e2931ef66
29 按号查版本在 created_at 相同时不确定 07b3dbb40
12 发布资料预览地址逐张串行解析 80a801c16
26 原生送审用客户端传入版本号 80a801c16
30 复活重置 created_at 的意图未写明(改为文档化「复活即覆盖」) 80a801c16
34 幂等摘要未归一 projectKey bc32e0b7e
35 统一发布未作废同号 pending_review 80a801c16
37 更新模式发布草稿配对校验过严 f1c864309
38 只改档位却重发整份资料(补「必须传完整投影」文档) 80a801c16
39 网页按 latestVersion 推下一版本号 80a801c16
25 附 lineage 对外字段改名 network、forkSource 改名 source 80a801c16

另有一个纯格式化提交 99a70cbfd(prettier 收敛冲突卡与测试改动)。
本轮术语对齐(lineage→network、forkSource→source)、作者侧权威 latestVersionNumber 与 #12 / #26 / #30 / #35 / #38 同批提交 80a801c16;#34 为 bc32e0b7e;#37 为 f1c864309。


详细处置

已自动修复

下面按原 review 编号列出;本轮把「留给你决定」里已按你指示完成的项也标成 - [x],仍为 - [ ] 的才是真正待你拍板的项。当前已处理 19 项 / 待定 18 项。

  • 1. AGC 发布入口 :focus-visible 只有 filter: brightness(1.05) + outline: none,键盘焦点几乎不可见。

    • 现状:.project-chat-publish-trigger 的 hover 与 focus 共用一条规则,focus 时靠 5% 亮度差表示。
    • 问题:实心主色胶囊上 5% 亮度差肉眼难辨,键盘用户定位不到焦点(可访问性缺陷)。
    • 处理:拆开 hover / focus 两条规则,focus 保留亮度反馈并补 outline: 2px solid var(--platform-button-primary-border) + outline-offset: 2px。提交 3cd6f2f11。
  • 5. FormCard.tsx 里「沿用上一版本截图」的 disabled={onlineScreenshots.length === 0} 是死代码。

    • 现状:该按钮只在 showOnline && onlineScreenshots.length > 0 分支内渲染,长度恒 > 0。
    • 问题:沿用一次后草稿仍不满足条件,按钮保持可用;重复点击会反复标 dirty、触发无意义的自动保存 / 冲突往返。
    • 处理:改算 screenshotsSame(草稿 screenshotPaths 与上一版本 objectKey 逐槽相同),相同即停用;补了「相同停用 / 不同可用并触发沿用」的测试。提交 695db9087。
  • 6. FormCard.tsx 封面预览用嵌套三元(coverOnlineImage 计算与封面 JSX 链)。

    • 现状:计算里是 coverIsObjectKey ? (a ? b : c) : d,JSX 里再套「有图 / 读失败 / 占位」三层。
    • 问题:可读性差,且封面、上一版本封面、上一版本截图三处各写一套回退样式,容易漂移。
    • 处理:新增 OnlineImageSlot 组件统一三态渲染;封面预览改为 renderCoverPreview() 用 if 提前返回;上一版本封面与截图预览复用同一组件。提交 b823b397c。
  • 7. ConflictCard.tsx 的 onlineCover → onlineScreenshots 查找与 FormCard 重复。

    • 现状:两处都是「先比 cover 的 objectKey,再在 screenshots 里找」。
    • 问题:查找规则(前缀 / trim / 回退)一旦变化,两处会各自漂移。
    • 处理:useTaonierExport 导出 findOnlineImage(objectKey, cover, screenshots),ConflictCard 的 ImageThumb 与 FormCard 封面预览统一改用它。提交 f53498516。
  • 9. buildTaonierPublishMetadata 的 coverObjectKey 用嵌套三元表达三态。

    • 现状:localCover ? (是 objectKey ? 用它 : null) : (online?.cover?.objectKey ?? null)。
    • 问题:同一份三态语义(空=沿用 / objectKey=沿用键 / 其余=本地图)写得难读,也和仓库「不再用嵌套三元」的约定相悖。
    • 处理:改为显式变量 + if。提交 c800ddc19。
  • 11. AGC 的 GAME_DISTRIBUTION_MEDIA_OBJECT_KEY_PREFIX 与 Rust GAME_DISTRIBUTION_MEDIA_PREFIX 是两个手写常量。

    • 现状:两边都是 "agc/project-snapshots/v1/game-distribution/media/";用它区分「沿用 objectKey」与「本地路径」。
    • 问题:前缀漂移会让 UI 把 objectKey 当本地路径(宿主读盘失败)或反之;现有 DTO parity 脚本不校验常量。
    • 处理:在 scripts/check-game-distribution-dto-parity.mjs 里加一条从 Rust 读常量、与 AGC 常量逐字比对的断言(解析不到也会失败)。提交 ce32f4a1b。
  • 13. ConflictCard.tsx 的 isSameFieldValue 先排序再比较截图数组。

    • 现状:[...left].sort() / [...right].sort() 后逐槽比。
    • 问题:截图顺序就是发布顺序(buildTaonierPublishMetadata 原样保序、sameForm 也按槽位比),排序比较会把「只换了顺序」判成没改;冲突卡不展示,保存时默认留「我的」,等于静默丢掉项目里的新顺序。
    • 处理:改为按槽位逐个比较;补「只换顺序也算项目改动」的回归测试。提交 1bd0a3e0b。
  • 14. spacetime_client::publish_game_distribution_version 返回裸 bool,无文档。

    • 现状:返回 (game, version, replayed),replayed 的语义只藏在模块实现里。
    • 问题:调用方无法从签名看出布尔值含义与幂等键、请求摘要的关系。
    • 处理:补文档注释,说明 replayed = 命中同一 (game_id, idempotency_key, request_digest)、未新建版本,以及 project_key / version_number 的角色。提交 5f084d0ca。
  • 22. useTaonierTab.ts 回读后的 catch 不可达。

    • 现状:refreshOnline 在任何失败路径都返回 null(内部 try/catch 吞错),从不 reject。
    • 问题:try/catch 是死代码,误导读者以为回读会抛;rewritePublishedMediaToObjectKeys 也不依赖回读结果。
    • 处理:去掉 try/catch,保留「换项目不改写」与「同项目改写后收起进度」的语义。提交 7591b1f9d。
  • 25. GameDistributionSourceResponse.fork_source 字段名与 GameDistributionSource 类型不一致且无 TODO。

    • 现状:类型已改名 Source,线上字段仍是 forkSource。
    • 问题:改名遗留会变成静默技术债(同类 lineage 字段已有 TODO)。
    • 处理:按 lineage 的口径补 TODO(rename-refactor),注明是公开契约破坏性改名、待读面同批切换。提交 e2931ef66。
    • 后续(80a801c16):你授权后已把 TODO 兑现为实际改名——forkSource → source、lineage → network,覆盖 shared-contracts DTO、api-server 手拼响应、TS 契约与读取方、测试夹具、DTO parity 脚本与文档,clean cut 无别名。
  • 29. get_game_distribution_version_by_number_tx 用 max_by_key(created_at),同号同时间不确定。

    • 现状:同号多行(历史收敛前)取 created_at 最新。
    • 问题:created_at 相等时胜者依赖索引迭代顺序,同一 REST 查询可能返回不同 version_id。
    • 处理:改 max_by,created_at 相同再用 version_id 兜底成全序。提交 07b3dbb40。

留给你决定

  • 2. GameMetadata.category 生成成 string,丢掉 GameDistributionCategory 联合类型。(与 4 重复)

    • 现状:Rust category: String,服务端按 GAME_DISTRIBUTION_CATEGORIES(休闲/益智/动作/冒险/模拟/策略/其他)运行时校验;生成的 TS 是 category: string。

    • 问题:TS 调用方可以传任意字符串,编译期不再拦住非法分类(运行时仍会 400)。

    • 建议:想恢复联合类型,最干净是把 category 做成 Rust 枚举并 derive ts-rs,再改 api-server 校验与 parity 清单;直接 ts(type = "GameDistributionCategory") 不会自动补 import,而把 7 个字面量抄进 ts(type = "'休闲' | ...") 会引入第二份会漂移的清单。属于契约设计决策,留给你。

    • 无领域术语版:现在“游戏分类”这个框子在后端只允许 7 个固定选项,但发给前端代码的说明书里写成了“任意文字”。前端写错字只有在提交时才会被服务器挡回来,写代码时不会报错。要修好,最好让“7 个选项”只定义一次并自动同步到前端,否则就是在两处各抄一份、早晚不一致。

    • 你的决定:采纳「Rust 枚举」方案。处置(4c7ab9934):新增 GameDistributionCategory 枚举(7 个中文取值用 #[serde(rename)] 保持线上 JSON 不变,未知分类在 DTO 反序列化即 400),GameMetadata.category 与 GameDistributionUpdateGameMetadataRequest.category 由 String 改成它;ts-rs 导出 generated/GameDistributionCategory.ts,GAME_DISTRIBUTION_CATEGORIES 运行时数组用 satisfies + 双向条件类型断言与生成联合逐值对齐;api-server 删除运行时字符串白名单校验;parity 脚本把该类型从 TS_ONLY_TYPES 移入 GENERATED_RUST_TYPES。

  • 3. NewGameVersionRequest.packageEntryPath 生成成 string,丢掉 'index.html' 字面量。

    • 现状:Rust package_entry_path: String,服务端 validate_version_declaration 只接受 index.html。

    • 问题:TS 编译期不再阻止提交别的入口文件名。

    • 建议:可加 ts(type = "'index.html'");但这是把 TS 收窄,若有用变量拼请求体的调用方会立刻编译失败,需你确认没有这种用法。留给你决定。

    • 无领域术语版:发布包的入口文件名实际上只能是 index.html,但前端说明书里写成了“随便什么字符串”。想让它写代码时就能发现错误,可以把说明改成“只能是 index.html”;不过这样某些用变量传文件名的老代码会编译不过,所以先确认一下影响面。

    • 处置(91d06846a):按「契约不收窄」补文档注释,明确 packageEntryPath 刻意保持 string;不改类型。

  • 4. GameDistributionUpdateGameMetadataRequest.category 同上(与 2 重复)。

    • 现状 / 问题 / 建议:同 2,只是改的是资料编辑请求体。
    • 无领域术语版:同 2。
    • 你的决定:采纳。处置同 2(4c7ab9934):category 由 String 改为 Option<GameDistributionCategory>,TS 生成成可选联合。
  • 8. 空数组语义从「整组沿用线上截图」改成「没有截图」,但 .export/taonier.json 旧草稿没有迁移。

    • 现状:buildTaonierPublishMetadata 把 screenshotPaths: [] 映射成 screenshots: [];测试也明确钉住「空数组 = 没有截图、不再隐式沿用」。

    • 问题:作者本地若残留旧的空数组草稿,下一次发布会把原本沿用的整套截图悄悄发成 0 张。

    • 建议:这属于本地草稿迁移决策。要么给注册表加 schema / 版本标记并一次性迁移,要么在读到「更新模式 + 空数组 + 线上有截图」时提示用户确认;但任何「空 = 沿用」的兜底都会把 clean cut 刚去掉的隐式语义加回来。留给你判断是否值得为存量草稿引入迁移。

    • 无领域术语版:以前“截图列表为空”这句话的意思是“沿用上一版所有截图”;现在改成了“没有截图”。如果有人电脑上还存着按老意思写的草稿,重新发布时那批截图会莫名消失。要不要为老草稿做一次自动转换,是个取舍:做转换就要在文件里记版本号并写迁移逻辑,不做就得接受这个边角情况。

    • 你的决定:不采纳迁移,旧草稿不做兼容;本项作为已记录的历史风险关闭。

  • 10. module-game-distribution::create_version 校验 idempotency.key 非空却丢弃它。

    • 现状:scope key 由 (game_id, version_number) 现算(无 project_key),但 normalize_idempotency 仍要求调用方传非空 key。

    • 问题:调用方必须传一个对幂等毫无影响的值;且内存参考模型的收据键形如 {game_id}::v{n},与生产统一发布路径的 {game_id}:{project_key}:v{n} 对不上。

    • 建议:二选一——要么 documented 地忽略 key(并写清为什么),要么把 project_key 穿进 CreateVersionInput 让自然键与生产一致。留给你定参考模型的定位。

    • 无领域术语版:这个命令要求调用方填一个“防重复编号”,但实际算编号时又不用它,等于白填;而且参考实现里算出来的编号格式,和真正线上用的不是一套。要么干脆别要求填,要么把缺的那段信息补齐,让两边算法一致。

    • 处置(222993af9):create_version 不再要求幂等 key,自然键只由 (game_id, version_number) 派生。

  • 12. build_publication_draft 逐张 await 解析封面 / 截图预览地址。

    • 现状:封面 1 次 + 每张截图 1 次,串行 await resolve_preview_fields。
    • 问题:打开更新面板要等最多 7 次独立网络往返,延迟叠加。
    • 建议:这些请求彼此独立,可用 futures::future::join_all 并发并保持顺序。注意原 review 示例里的 zip_pairing 并不存在,需要自己保留 (object_key, asset_id) 配对与规范化,属于需要小心的改动。
    • 无领域术语版:打开面板时要一张一张地去问服务器“这张图的预览地址是什么”,一张问完才问下一张,最多要排队 7 次。其实可以同时问、一起等,面板能更快打开;只是代码要自己保证“哪张图配哪个结果”不乱。
    • 处置(80a801c16):已用 futures::join! 把封面与 join_all 的截图预览并发出去,join_all 按输入顺序返回、再与 (asset_id, object_key) 槽位 zip,顺序不变。
  • 15. 统一发布事务拿到的 project_key 与幂等键用的规范值可能不一致。

    • 现状:idempotency_key 用 canonical_project_key(优先作品行里已存的权威值),但传给事务的仍是请求里的原值,会写进版本行的 project_key。

    • 问题:更新既有作品时若请求省略或传了不同的 projectKey,版本行存的 key 会和作品行、自然键都不一致。

    • 建议:把规范值传进事务(或明确决定版本行的 project_key 只记请求值),并补一条「省略 / 异值 projectKey 的更新发布」测试。属于身份语义决策。

    • 无领域术语版:同一次发布里,“防重复用的项目编号”取的是权威值,但真正写进数据库的是用户这次发来的值。用户如果没发或发得不一样,数据库里就会留下两个互相矛盾的编号。统一成一份权威值更稳,但改的是数据落库口径,先由你拍板。

    • 处置(685eb16ec):api-server 只在 POST /games 传 projectKey,路径端点一律传 None。

  • 16. 资料 PATCH 里「改资料」与「提升共创授权」是两次独立事务。

    • 现状:先提交 update_game_distribution_game_metadata(已提交、已 bump revision),再单独跑 set_game_distribution_fork_authorization。

    • 问题:第二步失败(例如降级被拒 / 并发改动)时,资料已经落库,但请求返回错误;用户以为整体失败,重试又因 revision 变了而 409,形成半写状态。

    • 建议:把方向和前置校验提前到资料事务里,或把两次写入合并进同一个事务 / procedure。留给你评估事务成本。

    • 无领域术语版:一次“保存资料”其实偷偷分成两步:先存资料,再改共创许可。如果第二步失败,资料已经存进去了,可页面却提示失败;用户再点一次又因为“版本号变了”被拒绝。要么先检查清楚再一起提交,要么两步合成一步。

    • 处置(bb90ad588):共创授权提升并入 update_game_distribution_game_metadata_and_return 同一事务,删除第二事务与独立 procedure。

  • 17. 按号装载作者版本时不再过滤已软删作品(回归)。

    • 现状:load_owner_version_by_number_or_404 只校验 owner_user_id;旧的 get_owner_game_distribution_version_tx 还会在作品缺失 / 软删时返回 None。

    • 问题:包上传、工程包上传、送审、撤回、版本详情等入口对软删作品的版本仍然可操作。

    • 建议:在 by-number 装载路径补回作品 deleted_at 判断(对应事务里 join 一次作品行即可)。属于授权边界回归,建议尽快修。

    • 无领域术语版:作品被删掉后,本来它的各个版本也应该跟着“看不见”,但新的查法只确认“这是你的版本”,没确认“这个作品还在”。结果是被删作品的版本还能继续上传、送审。修法是查询时顺手看一眼作品是否已被删除。

    • 处置(09be5f1d3):get_game_distribution_version_by_number_tx join 作品行并拒绝软删作品。

  • 18. GameMetadata 里带 #[serde(default)] 的字段在 TS 里仍是必填。(与 24 重复)

    • 现状:Rust description / cover_object_key(Option + default)与 tags / screenshots(default)都可省略,但生成 TS 是必填(description: string | null 等)。

    • 问题:TS 比线上契约更严,依赖服务端默认值的调用方被迫传 null / [],合法省略反而编译不过;相对删掉的手写镜像是一处回归。

    • 建议:给 description / cover_object_key 加 ts(optional = nullable)、给 tags / screenshots 加 ts(optional),重跑 cargo test -p shared-contracts --features ts-bindings export_bindings 并提交生成文件。因为改的是公开 TS 契约,留给你统一做并跑 check:generated-bindings。

    • 无领域术语版:server 允许“这些字段不发”,但前端说明书把它们标成“必须发”。结果本来可以不填的地方,前端不填就报错。修法是在后端声明这些字段可选,再重新生成一次前端类型。

    • 处置(bb90ad588):GameMetadata 字段保持必填;局部更新走独立更新 DTO。

  • 19. GameMetadata.fork 允许显式 null,生成的 TS 只写 fork?: {...}。(与 31 重复)

    • 现状:Rust 是 Option<...> + #[serde(default)],文档也说「显式 null 与省略等价」;TS 类型没有 | null。

    • 问题:TS 调用方无法表达服务端允许的 null 形式。

    • 建议:ts(optional, type = "{ ... } | null")(与 forkAuthorization 的写法一致),重新生成绑定。属公开 TS 契约,留给你。

    • 无领域术语版:后端说“这个来源可以空着,也可以明确写 null”,两句话意思一样;但前端说明书只允许“空着”,不允许写 null。改一句话就能对齐。

    • 处置(94c8a6bda):GameMetadata.fork 的 TS 接受显式 null。

  • 20. GameDistributionUpdateGameMetadataRequest.tags / screenshots 在 TS 里必填。

    • 现状:Rust 两者都有 #[serde(default)],生成 TS 却是 tags: string[] / screenshots: ...[] 必填;同结构体的 description / coverObjectKey / forkAuthorization 已正确标可选。

    • 问题:资料编辑器被迫恒发这两个数组,省略不再合法。

    • 建议:加 ts(optional) 后重跑绑定生成。属公开 TS 契约,留给你。

    • 无领域术语版:同 18:后端允许不发标签和截图,前端说明书却要求必须发。

    • 处置(bb90ad588):更新 DTO 除 CAS 外全部可选,缺省 / null = 不动该字段。

  • 21. adoptOnlineScreenshots 会把旧版 assetId 当 objectKey 写进草稿槽位。

    • 现状:readOnlineImageKey 在 objectKey 缺失时回退到旧合同字段 assetId(迁移窗口兼容);adoptOnlineScreenshots 直接把解析结果写进 screenshotPaths。

    • 问题:下游只用「OSS 前缀」判定是不是线上键,旧 assetId 会被当成项目内本地路径,发布时宿主去读盘失败。

    • 建议:要么在媒体合同迁移完成后删掉 assetId 回退,要么在沿用前显式拒绝 / 标记非 OSS 键。

    • 无领域术语版:为了兼容旧格式,代码会把一种老编号也当成“线上图片编号”存下来;可后面判断“是不是线上图”只看新格式前缀,于是老编号被误当成电脑里的文件路径,上传时找不到文件。等旧格式彻底退役就把兼容删掉,或者在沿用前先判一下格式。

    • 处置(496737759):readOnlineImageKey 只读 objectKey,删除 assetId 回退。

  • 23. 建议给 projectKey 加 localProjectId 旧名 serde(alias)。(建议不采纳)

    • 现状:统一发布请求体字段是 projectKey,旧 GameDistributionCreateGameRequest.local_project_id 及旧路由已按 ADR 删除。

    • 问题:review 担心仍在运行的老客户端带旧字段。但 ADR 明确是 clean cut:旧 DTO / 旧路由一律删除,旧客户端会收到 404/405,不留别名、不双跑。

    • 建议:不建议加别名,除非你确实要为某个已部署客户端开兼容窗口;若开,需同时补兼容测试、灰度与退役计划。

    • 无领域术语版:老版本客户端用的字段名已经按项目决定彻底删掉了,旧接口也会直接报“不存在”。review 担心老客户端会因此失败,但保留旧名字本身就违反这次“一刀切、不留旧包袱”的决定。除非确认线上真还有老客户端在跑,否则不改。

    • 你的结论:不采纳,不加 localProjectId serde alias。

  • 26. 原生发布送审仍用客户端传入的 version_number_text,而非服务端权威的 version.version_number。

    • 现状:同一流程里包上传与工程包上传都用 version.version_number,只有 submit 用请求值。
    • 问题:新建作品走 POST /games、服务端固定首版为 1;若哪天调用方传了非 1 的版本号,包传到了 v1,送审却打到 /versions/{version_number_text}/submit 而 404。当前调用方都传 1,所以暂未暴露。
    • 建议:submit 路径改用 version.version_number(一行改动)并补一条测试。因为它目前是潜在问题、且该文件此前有并发改动,我没有动。
    • 无领域术语版:同一次发布里,上传文件用的是服务器回执里的真实版本号,最后“提交审核”那一步却用客户端自己传的版本号。现在客户端传的恰好都对,所以没出错;万一传错,文件传上去了但审核提交会找不到版本。统一用服务器回执的版本号即可。
    • 处置(80a801c16):submit 前的路径段改用 version.version_number.to_string();请求体里的版本号仍只用于发布路径。
  • 27. 统一 bootstrap 插入作品行时未做空值校验。

    • 现状:get_or_create_game_distribution_game_for_publish_tx 直接把 title/summary/category/orientation 写库;旧的 create_game_distribution_game_tx 会用 required_game_distribution_text 校验非空。

    • 问题:这条路径完全依赖 api-server 校验;若将来有第二个调用方(procedure),可能写进空标题等脏行。

    • 建议:在事务里补一份非空校验(最后一道防线),或显式文档化「api-server 是唯一校验点」。

    • 无领域术语版:新建作品时后端直接把用户给的名字等写进数据库,没有自己再检查一遍是否为空,靠上一层接口把关。以后如果绕过这层接口直接调用,就可能存进空名字。要么自己再查一遍,要么写明“只有这一条入口,别绕过”。

    • 处置(122230e25):统一 bootstrap 在事务内补 title / summary / category / orientation 非空校验。

  • 28. (owner, project_key) 兜底命中时未断言 game.game_id == input.game_id。

    • 现状:按 owner + project_key 找到任一未删作品就复用;版本行随后按 input.game_id 插入。

    • 问题:若历史行用了同一 project_key 但 game_id 不同,会写出「版本属于 A、作品却是 B」的孤儿版本,而幂等键 / 媒体前缀用的是派生 game_id。

    • 建议:断言 id 相等并失败关闭,或明确按 game.game_id 落版本。

    • 无领域术语版:创建版本时,系统先按“作者 + 项目编号”找一个已有作品来挂靠。如果历史数据里同一个项目编号曾挂在另一个作品上,新版本会被塞进一个跟作品对不上的编号,产生错位数据。加一句“两者编号必须一致,否则报错”最稳。

    • 处置:先按你的口径在 e04032b7d 给兜底加了 game_id 断言;随后按 #34 的决定整体删除 (owner, project_key) 兜底(125849512),该分支不再存在。

  • 30. 软删作品复活时把 created_at 重置为当前时间。(你的结论:复活就是覆盖,旧的一去不回;已按此写清文档与注释)

    • 现状:revived_game_distribution_game_for_publish 清空公开态 / 指标并 created_at = now。
    • 问题:作品会在所有按创建时间倒序的列表 / 游标分页里被排到最前,看起来是全新作品,与「保留 game_id 与历史版本行」的语义不一致。
    • 建议:保留原 created_at、只更新 updated_at;若确实想让它像新作品,就明确写进文档 / 注释。
    • 无领域术语版:一个被删掉的作品在同名重新发布时会“复活”。复活时代码把它的“创建时间”改成了现在,于是它在列表里被当成刚出的新作品排到最前面。到底是希望它保持原来的资历还是当成新品,需要你定,然后要么改时间、要么把意图写清楚。
    • 处置(80a801c16):你确认「republish 实际是 overwrite,上一份永远没了」,因此保留 created_at = now,并在 revived_game_distribution_game_for_publish 注释与实施计划里写明「复活 = 覆盖,不是复原:上一世代的资料 / 价格 / 指标 / 活动版本不可找回,created_at 重置让它在所有创建时间排序里按新作品出现」。
  • 32. SpacetimeDB 自动生成绑定里出现只改空格的重排,且不同步。

    • 现状:约 229 个 *_procedure.rs 的回调用 16 空格缩进,约 32 个仍是 12 空格;文件头明确写着「EDITS TO THIS FILE WILL NOT BE SAVED」。

    • 问题:手工改生成代码会造成 diff / 重新生成时来回抖动,且一半新一半旧很难看。

    • 建议:不要手改;重新跑官方 codegen 让全部文件按同一版本输出并整体提交(或干脆把这类纯空格改动排除在 review 之外)。

    • 无领域术语版:有些“自动生成的代码”被人工改了缩进,但只改了一部分文件。因为这类文件下次生成时会被覆盖,这种改动没有意义还添乱。正确做法是统一重新生成一遍。

    • 处置(d025ec4ba):你的决定是整批重跑 codegen;本地 CLI 2.8.3 把 31 个 procedure 文件统一为 16 空格,纯缩进变化、无语义改动。

  • 33. 统一发布路径接收 fork / fork_authorization,却从不传给事务,静默丢弃。

    • 现状:GameMetadata 含这两个字段,GameDistributionPublishVersionRecordInput 没有对应字段,统一事务也没读;事务里还留着注释「统一发布路径暂不解析共创声明……派生作品走旧两步路径」,但旧两步路径已按 ADR 删除。注意:review 说它们「validated in validate_game_metadata」并不准确,该函数只校验标题 / 分类等,根本不看 fork。

    • 问题:AGC 从 .agent/fork-source.json 读出的改编声明、以及母版请求的 full 档位都会被丢掉:作品行落成 forbidden、没有血缘行,冻结资料也不含它们。而现在没有任何 HTTP 路径能实现这些字段。

    • 建议:在统一事务支持它们之前,先失败关闭——收到 fork 声明或非默认 forkAuthorization 时返回明确 400,而不是“成功但结果不同”。属于高优先级领域行为,需要你决定是补事务还是先拦。

    • 无领域术语版:作者可以声明“我这个作品改编自别人的哪个版本”,也可以选“允许别人改编我”。新接口收下了这两个信息,却根本没往数据库里放,最后作品既没有来源记录、也不允许改编,但接口仍返回成功——客户端以为生效了。目前也没有别的入口能补救。稳妥做法是先直接报错说“暂不支持”,别假装成功。

    • 你的问题(GameMetadata 里为什么有 fork / fork_authorization?是不是该放进 ForkMetadata?):是,这确实是建模错位。GameMetadata 描述的是「随版本冻结的展示资料」,而改编声明是一次性的身份事实、共创档位是游戏级策略,两者都不属于展示资料。现在的 GameDistributionForkMetadata 只装了 { parentGameId, parentVersionId }(声明),档位另在一个字段上,概念被拆散又混进 GameMetadata。

    • 我的判断:建议把「改编声明 + 从父作品继承的档位」收进发布请求上独立的 fork: ForkMetadata(内部含 parentGameId / parentVersionId / forkAuthorization),再让统一事务消费它写血缘行与档位;GameMetadata 只留展示字段。这是领域行为改动(要定「谁在什么时机能声明改编、档位继承与禁止收窄」),建议先补 / 改 ADR 与里程碑再动代码,因此本轮没有改,仍留给你。若决定短期先拦,则在统一发布入口对非默认 fork / forkAuthorization 失败关闭(400)。

    • 处置(125849512):你的决定是先失败关闭;统一发布入口收到 fork 声明或非默认 forkAuthorization 返回 400,补事务支持需先补 ADR。

  • 34. request_digest 覆盖原始 projectKey,与规范化的自然键口径不一致。

    • 现状:自然键用 canonical_project_key(优先作品行已存值),摘要却对原始 NewGameVersionRequest 序列化(projectKey 缺失时还会变成 "projectKey":null)。

    • 问题:POST /games(带 projectKey)与 POST /games/{game_id}/versions/1(省略 projectKey)命中同一自然键但摘要不同,模块返回 请求摘要不一致 → 409,破坏“同版本重发即重放”的文档语义。

    • 建议:对规范化后的 payload 取摘要(把 project_key 归一到 canonical_project_key,或统一从摘要里剔除它)。属幂等语义,留给你连同 15 一起定。

    • 无领域术语版:系统用“内容指纹”判断两次请求是不是同一件事。但计算指纹时用的是一个可能被省略的字段原样值,而“防重复编号”用的是规范化后的值,两者对不上。于是同一件事换个写法提交,会被误判成“内容不一样”而拒绝。统一成规范化后的内容再算指纹即可。

    • 你的问题(/games/{game_id}/versions/{n} 本该完全不认识 projectKey,现在到底怎么用的?):现状是更新路径仍允许(但不要求)请求带 projectKey,只在「路径 gameId 指向已软删 / 不存在、且该 gameId 恰好等于请求 projectKey 的确定性身份」时才用它走复活;自然键与作品行的权威值都来自作品行已存的 projectKey。真正的不一致是摘要仍按请求原值算。

    • 处置(125849512):你的决定是不采纳摘要归一、并直接删掉凭 projectKey 复活 / 认领的参数。最终:路径端点 body 出现 projectKey 直接 400;幂等摘要按请求原样序列化(删除 publish_digest_payload);模块删除 (owner, project_key) 兜底查找;软删复活只发生在 POST /games(同一派生 gameId),复活不再读写 project_key。

    • 后续(4c7ab9934):按你「从类型上彻底拆开」的决定,把两条路由的请求类型分开——追加版本用 NewGameVersionRequest(类型上没有 projectKey),新作品首版用新增的 NewGameRequest(projectKey + #[serde(flatten)]/#[ts(flatten)] 的版本内容)。projectKey 从「可选字段 + 入口拒绝」变成「类型上只属于创建路由」;路径端点仍对原始 body 显式 400。摘要在类型上自然只覆盖版本内容,与身份锚彻底解耦。

  • 35. 统一发布事务插入新版本前,没有取消同 (game_id, version_number) 的 pending_review 旧版本。

    • 现状:旧 create_game_distribution_version_tx 会把同号待审行置为 cancelled、review_reason = "被同一项目版本的新提交替代";统一事务没有这段。
    • 问题:作者对版本 N 送审后再重发同号 N(新 version_id),旧行仍是 pending_review、仍在后台审核队列里,管理员可能批准旧内容。
    • 建议:在插入前镜像旧逻辑补上取消循环。属核心发布事务行为,建议你改并补模块测试。
    • 无领域术语版:同一个版本号被重新提交后,前一次还在排队等审核的记录没有作废,管理员可能把旧内容审过并上线。修法是在插入新记录前,先把同号的旧待审记录标记为“已被替代”。
    • 处置(80a801c16):在 publish_game_distribution_version_tx 插入新版本前镜像旧两步路径的取消循环(cancelled + review_reason = "被同一项目版本的新提交替代")。
  • 36. publish_game_distribution_version_tx 把 required_game_distribution_text 的 trim 结果丢掉,继续用原始 input.game_id。(你的结论:不采纳)

    • 现状:let _game_id = required(...),后续查找 / 插入 / 媒体前缀都用 input.game_id;旧事务则绑定 trim 后的值。

    • 问题:带首尾空格的 game_id 能通过校验,却按未规范化 id 存 / 查,两条路径行为分叉。

    • 建议:绑定 trim 后的值并全程使用,或明确文档化「game_id 必须已规范化」。

    • 无领域术语版:校验函数会顺手去掉编号前后的空格并返回干净值,但代码把干净值扔了,继续用带空格的原值去查和存。一旦真有人传带空格的编号,就会出现“校验通过但存进去的编号不干净”。把返回值用起来即可。

    • 处置(125849512):你的决定是不要保留这个 trim;publish_game_distribution_version_tx 直接删除对 game_id 的 required_game_distribution_text 调用,game_id 由服务端生成、无 trim 来源,也不再出现「校验后丢弃结果」。

  • 37. gamePublishDraft.isDraft 要求 gameId 与 versionId 同时存在或同时缺失。

    • 现状:hasGameId === hasVersionId,并有测试钉住「只有 versionId 的半写草稿不可恢复」。
    • 问题:update 模式在发请求前就写 gameId、versionId 要等响应;理论上会留下“有 gameId 没 versionId”的草稿,被 readPublishDraft 丢弃。不过 update 模式的页面本来就不从草稿恢复版本号(它按 updateGameId 重新读线上),所以 review 把“版本号 +1 建出第二个版本”完全归因于这里并不准确;而配对校验是有意为之且有测试的。是否要放宽,需要你结合恢复路径整体确认(尤其 projectKey 会不会被重新生成、进而让摘要变化导致 409)。
    • 建议:先确认 update 模式的恢复链路到底用不用这份草稿;若用,再放宽配对并补测试,别只删校验。
    • 无领域术语版:草稿里有两个由服务器分配的编号,代码要求它们要么都有、要么都没有。但“更新已有作品”时,其中一个编号会在请求前就先存进去,另一个要等服务器回执。review 认为这会导致草稿被当成损坏而丢掉。不过“更新”页面实际上不靠这份草稿决定版本号,所以结论没这么直接。改之前先确认这条恢复路径是否真的用到草稿。
    • 你的问题(为什么「更新已有作品」会改本地草稿?“恢复链路”是什么?):submitGamePublish 在发请求之前就写草稿(gamePublishSubmission.ts 先落自然键再发请求,响应丢失后重试才能命中同一版本);更新模式此时会写入 gameId,versionId 要等响应,于是留下「有 gameId 没 versionId」的正常中间态。“恢复链路”指 readPublishDraft → resolveOwnedPublishDraft → resolveDraftIdentity(提交时复用草稿冻结的 versionNumber),以及 GamePublishPage 的草稿回读 effect。
    • 核实结论:GamePublishPage 的更新模式不从草稿恢复版本号(if (updateGameId) return),但 submitGamePublish 会读草稿复用冻结的 versionNumber;projectKey 在更新模式不会进请求体(buildPublishVersionRequest 只在 create 带),所以不会因重新生成 projectKey 让摘要漂移。
    • 处置(f1c864309):isDraft 只校验各 id 的类型、不再要求配对;补「gameId-only 草稿可恢复」与「更新模式沿用冻结版本号」两条回归测试。配合 #34 的摘要归一化,响应丢失后重载会重放原版本而不是新建第二个。
    • 你的追问(「有 gameId 没 versionId」是指新作品端点吗?草稿为什么不学后端:新作品用 projectKey 当 key、新版本用 (gameId, versionNumber) 当 key?):不是新作品端点,是追加版本(POST /games/{game_id}/versions/{n})。新作品请求发出前 gameId/versionId 都没有(只有 projectKey),响应回来才一起回填;追加版本请求前就有 gameId(路径身份),versionId 要等响应,所以正常中间态就是「有 gameId、没 versionId」。submitGamePublish 也确实按后端自然键组织草稿:create 用 projectKey + 冻结的 versionNumber(固定 1),update 用 gameId + 冻结的 versionNumber。唯一冗余是 GamePublishDraft.projectKey 是必填字段,update 模式没有匹配草稿时会生成一个从不进请求体的随机 projectKey。是否把草稿类型也拆成 create(projectKey)| update(gameId)的 discriminated union、让 update 不再生成 projectKey——等你确认后我再动。
  • 38. updateGameForkAuthorization 用「整份资料重发」来实现只改一个档位。(已补文档警告;你提议的「全字段可选 PATCH」见下方评估,未做)

    • 现状:从 GameDistributionGame 投影拼出完整 GameDistributionUpdateGameMetadataRequest,screenshots ?? []、coverObjectKey ?? null。
    • 问题:只有调用方传的投影携带完整当前资料时才正确;传了裁剪过的投影(例如列表项没带 screenshots)就会把截图清空,老作品没封面时还会因「必须提供封面」而失败。
    • 建议:显式文档化「必须传完整投影」,或给授权单独设计更窄的请求形状。当前 owner 投影是完整的,所以今天能跑通。
    • 无领域术语版:本来只想改“是否允许别人改编”这一个开关,代码却把整份作品资料读出来、原样再提交一遍。只要读到的资料是全的就没问题;一旦哪次拿到的是精简版(比如少了截图),就会把截图一并清掉。要么写清楚“必须拿全量资料来调”,要么给这个开关单独开个只改一项的接口。
    • 处置(80a801c16):已在 updateGameForkAuthorization 文档写明「必须传当前资料的完整投影」,并说明缺 screenshots / coverObjectKey 的后果。
    • 你的提议(把 PATCH 每个字段做成可选,表达更细粒度的 patch)评估:方向对,但比看起来难,暂未做,原因有三——(1) 现有 description / coverObjectKey 用 Option<T>,「没带」与「显式 null」反序列化后不可区分,要真正细粒度得引入 Option<Option<T>> 之类语义;(2) 媒体「不改」与「清空」也会撞车(screenshots: [] 到底是不改还是清空);(3) 幂等摘要、修订号 CAS 与模块 update_game_distribution_game_metadata_tx 的整行覆盖都要改成「按提供字段合并」。更小、更稳的替代是给档位单独开一条窄入口(PATCH .../fork-authorization,只收 expectedPublicationRevision + forkAuthorization),updateGameForkAuthorization 改用它;在本轮 rename/refactor 授权范围内我没有擅自改公开 DTO 形状,留给你定。
    • 结论(你的批复 + 实际落地):上面 3 条理由作废,按你说的「just like bb90ad588」直接走通用细粒度 PATCH。(1) 清空不用 null,统一用 ""——ab4244e98 已把 GameDistributionUpdateGameMetadataRequest.description / coverObjectKey 的 TS 契约去掉 | null(description: "" = 清空,coverObjectKey 不允许清空,缺省 = 不动);(2) 媒体语义已是「覆盖」(缺省 = 不动、[] = 清空),不存在「不改 vs 清空」撞车;(3) 幂等摘要 / expectedPublicationRevision CAS / 模块按 Some 合并已由 bb90ad588 落地,复杂度可接受。bb90ad588 同时把 updateGameForkAuthorization 改成只提交 { expectedPublicationRevision, forkAuthorization },不再重拼整份投影;窄入口 PATCH .../fork-authorization 不再需要。
  • 39. 网页发布用 latestVersion 推下一个版本号,而不是最大 versionNumber。

    • 现状:GamePublishPage 取 game.latestVersion?.versionNumber + 1;服务端 versions 按 updated_at desc 排序并截断到 10 条,所以 latestVersion 是“最近被改动的版本”。
    • 问题:与 ADR「max(versionNumber) + 1」不符;同号重提、或作品超过 10 个版本时,可能算出旧号 / 撞号。注意:review 说 listMyGames() 返回完整 versions 也不准确(同样被截断到 10 条),所以它的改法不能完全解决问题。
    • 建议:正确做法是让服务端在作者投影里直接给出权威的 nextVersionNumber(AGC 侧已有类似 resolve_owner_next_version_number),网页端消费它;仅在客户端取 max 仍会受截断影响。
    • 无领域术语版:发布新版本前要算“下一个版本号”,网页现在按“最近改动的那个版本号加一”来算。但“最近改动”不等于“号码最大”:把老版本重新提交一次,就会算错。更糟的是接口只返回最近 10 条版本记录,客户端自己找最大值也不可靠。最好让服务器直接告诉客户端“下一个该用几号”。
    • 你的结论:不要服务端算好的 nextVersionNumber,而是下发权威的 latestVersionNumber(全部版本最大号)让客户端自己 +1。
    • 处置(80a801c16):GameDistributionOwnerGameSnapshot 新增 latest_version_number(截断前取全量 max(version_number)),贯通生成绑定、spacetime-client::GameDistributionOwnerGameRecord、owner_game_entry_payload 的 latestVersionNumber;网页 GamePublishPage / GameDistributionMyGame 与 AGC resolve_owner_next_version_number 都优先读它再 +1,旧服务端缺键时回退到已下发版本取最大(AGC)或预填版本号(网页)。
- [x] 1. `apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx:78-79`(maintainability · low) - 原实现:JSDoc 已写成「发布入口」,但 prop 仍叫 `onRequestExport`,经 `DirectProjectChatBox`、`DirectProjectChatHeader`、`App.tsx` 一路透传,测试也按旧名断言。 - 问题:语义早已是「打开 AGC 发布面板」而非导出,旧名字误导后续读者。 - 建议 / 修复:`onRequestExport` → `onRequestPublish`,机械改 5 个文件;按钮文案本来就是「发布」,行为不变。 - 处理:已修复,提交 `199026617`。 - [x] 2. `packages/shared/src/contracts/gameDistributionPublish.ts:32`(maintainability · medium) - 原实现:`gameMetadata: GameDistributionCreateGameRequest`,该类型含 `projectKey`;同一请求里顶层还有一个 `projectKey`。 - 问题:服务端只读顶层 `projectKey` 解析身份,嵌套的 `projectKey` 既不参与身份、也不进冻结快照,却会进 `publish_request_digest`——同一个逻辑发布带不同嵌套值会算出不同摘要,重试变成摘要冲突。一个 payload 能携带两个不同 `projectKey`,契约有歧义。 - 建议 / 修复:建议用专用类型 `Omit<GameDistributionCreateGameRequest, 'projectKey'>`(Rust 侧同样拆一个不含 `project_key` 的 metadata 类型)并同步 parity 检查;退而求其次是在两侧注释明确「嵌套值被忽略」。这是契约形状变更,留给你定。 - 处理:已修复,提交 `8a42d20ee`。采用方案 A:外层改名 `NewGameVersionRequest`,内层改名 `GameMetadata`。`GameMetadata` 由 Rust 经 ts-rs 生成 TS 绑定(`packages/shared/src/contracts/generated/GameMetadata.ts`),TS 侧删除手写镜像,且该类型不再含 `projectKey`——同一 payload 不再能携带两个身份值,摘要歧义消失。 - [x] 3. `packages/shared/src/contracts/index.ts:6`(style · low) - 原实现:`export * from './gameDistributionPublish'`。 - 问题:该模块只导出类型,`export *` 会把它当运行时模块再导出,且与同文件 `./creator`、`./editorAudio`、`./hyper3d` 的 `export type *` 不一致。 - 建议 / 修复:改为 `export type *`。 - 处理:已修复,提交 `12ee915c0`(TS 5.8.2 支持该语法,`tsc` 与 parity 均通过)。 - [x] 4. `packages/shared/src/index.ts:10`(style · low) - 原实现:`export * from './contracts/gameDistributionPublish'`。 - 问题:同 #3,type-only 模块不该进运行时模块图。 - 建议 / 修复:改为 `export type *`。 - 处理:已修复,提交 `1af94272d`。 - [x] 5. `.../export/tabs/taonier/FormCard.tsx:352-353`(style · low) - 原实现:截图槽位用 `reusedKey ? (reusedPreview ? <img/> : <span/>) : <IconPreview/>` 的嵌套三元。 - 问题:违反仓库「禁止嵌套三元」约定;同文件 `inputSlot` 已刻意用显式分支。 - 建议 / 修复:在 `return` 前用 `let screenshotSlot: ReactNode` + `if / else if / else` 赋值。 - 处理:已修复,提交 `2ed5b9265`(`ReactNode` 已在该文件导入)。 - [x] 6. `.../export/tabs/taonier/FormCard.tsx:420`(bug · medium) - 原实现:「沿用」截图按钮 `disabled={onlineScreenshots.length === 0}`,只要上一版本有截图就可点。 - 问题:点一下会把整个 `screenshotPaths` 替换成上一版本 key,静默丢掉用户已选的本地截图;草稿已完全沿用(顺序与值都一致)时按钮仍可点,不镜像文字字段「已相同则禁用」的行为。 - 建议 / 修复:`disabled` 增加「草稿截图槽位与 `onlineScreenshots` 逐槽相等」的条件。属于交互行为变动,留给你。 - 处理:**不改**。按你的决定:UI 允许该字段承载 OSS objectKey(用于和线上键对照),「沿用上一版本」按钮整组替换截图的交互保留。 - [x] 7. `server-rs/crates/shared-contracts/src/game_distribution_publish.rs:18-23`(bug · medium) - 原实现:DTO 允许 `game_id` 与 `project_key` 同时非空;服务端不拒绝。 - 问题:`publish_version` 用 `game_id` 定位游戏,但幂等锚 `game_distribution_version_key` 优先取 `projectKey`。同时带两者时,版本写在 `gameId` 名下、收据却记成 `{projectKey}:v{n}`;之后真有一次同 `projectKey/versionNumber` 的首次发布会撞上这张收据(摘要不同 → 409,或错误重放)。 - 建议 / 修复:在 DTO / handler 强制二选一(同时非空就 400),或让幂等锚在 `gameId` 存在时一律取 `gameId`,并把优先级写进契约。属于契约语义收紧,留给你。 - 处理:已修复,提交 `5b7dd3e4b`。按你的决定,幂等键改为覆盖 `gameId` + `projectKey` + `versionNumber`:`game_distribution_version_key(game_id, project_key, version_number)`,不再依赖「优先取谁」的歧义优先级;首次发布(只有 `projectKey`)与更新(带 `gameId`)都落到同一把自然键上。 - [x] 8. `server-rs/crates/shared-contracts/src/game_distribution_publish.rs:37-38`(maintainability · low) - 原实现:嵌套的 `GameDistributionCreateGameRequest` 自身带 `project_key`(由 `local_project_id` 改名而来)。 - 问题:与 #2 同根因的 Rust 侧。服务端只认顶层 `payload.project_key`,嵌套值被忽略却不进冻结快照、只进摘要,导致「改嵌套值就摘要冲突」。 - 建议 / 修复:与 #2 一起处理(专用不含 `project_key` 的 metadata 类型,或两侧注释明确忽略)。与 #2 同属契约变更,留给你。 - 处理:已修复,提交 `8a42d20ee`(与 #2 同一次变更)。Rust 侧内层改为不含 `project_key` 的 `GameMetadata`,服务端只从外层请求读身份。 - [x] 9. `.../export/state/useTaonierExport.ts:136-140`(bug · medium) - 原实现:草稿为空截图列表时不再回填 `online.screenshots`,空列表被按字面理解为「提交 0 张截图」。 - 问题:旧语义下写的历史草稿 `screenshotPaths: []` 会被原样提交,更新发布时静默丢掉已发布的截图;仓库里没有标记区分「历史空」与「用户主动清空」。 - 建议 / 修复:在加载时对 `online.state === 'update'` 且 `online.screenshots` 非空的空草稿做一次性迁移回填,而不是只靠用户点「沿用上一版本截图」。涉及历史草稿迁移,留给你。 - 处理:**保持现状**。你确认这是预期行为;后端 API 也一致——`screenshots` 空数组就是字面「本次提交 0 张截图」,没有「空 = 未填」的隐式回填。旧草稿兼容问题因产品尚未上线、无历史数据,不做迁移。 - [x] 10. `server-rs/crates/module-game-distribution/src/commands.rs:53-54`(maintainability · low) - 原实现:`PublishVersionInput` 在定义与 `lib.rs` 再导出出现,全仓库无构造 / 反序列化;真实路径用 `spacetime-module` 里字段更全的 `GameDistributionPublishVersionInput`。 - 问题:两个描述同一命令的结构体会静默漂移,且文档注释让人误以为它是在役契约。 - 建议 / 修复:删除无调用方的结构体与再导出(仓库约定「无现役调用方直接清理」)。 - 处理:已修复,提交 `c90f14b9b`(`module-game-distribution` 32 passed)。 - [x] 11. `server-rs/crates/module-game-distribution/src/domain.rs:73`(maintainability · low) - 原实现:`derive_game_distribution_game_id` 直接哈希原始字节,不 trim;兄弟函数 `game_distribution_version_key` 会 trim。 - 问题:调用方若忘了预归一化,`" proj-1"` 与 `"proj-1"` 派生出两个不同且已持久化的身份,事后难恢复。 - 建议 / 修复:函数内 `project_key.trim()`(`owner_user_id` 来自鉴权、本就稳定,未动以免改变既有 ID),与幂等键口径一致。 - 处理:已修复,提交 `87dc80c75`,并补单测断言两侧空白归一化。 - [x] 12. `server-rs/crates/api-server/src/modules/game_distribution_publish.rs:110-114`(bug · medium) - 原实现:幂等键 `game_distribution_version_key(project_key.as_deref(), game_id, version_number)`;首次发布只带 `projectKey` 时锚是 `projectKey`,更新只带 `gameId` 时锚退回 `game_id`。 - 问题:同一次发布的同 `versionNumber` 若从 `projectKey` 形态切到返回的 `gameId` 形态重试,会算出不同键、错过既有收据;而 `game_distribution_version` 在 `(game_id, version_number)` 上没有唯一索引,于是插入重复版本行。 - 建议 / 修复:`current` 已经带游戏行存储的 `project_key`,请求没带 `projectKey` 时用它补锚: `let anchor = project_key.clone().or_else(|| current.as_ref().and_then(|g| g.project_key.clone()));` 再传给 `game_distribution_version_key`。属于幂等行为改动,留给你。 - 处理:已修复,提交 `5b7dd3e4b`(与 #7 同一处)。键改成 `{gameId}:{projectKey}:v{n}` 后,`projectKey` 形态与 `gameId` 形态不再算出两把键,「错过收据 → 重复版本行」的路径随之消失。`(game_id, version_number)` 唯一索引未加;若要数据库级兜底可另开一项。 - [x] 13. `.../export/tabs/taonier/ConflictCard.tsx:34-38`(bug · medium) - 原实现:`differing` 过滤用 `formatFieldValue` 的输出比较。 - 问题:该格式化器把任何 objectKey 折叠成「沿用上一版本」。两个不同的上一版本 key(或同 key 不同顺序)会格式化成相同字符串而判为一致,真实冲突被隐藏,「按选择保存」会不问就保留用户值。 - 建议 / 修复:展示与相等分离——`differing` 里对截图数组逐槽 `trim()` 比较,`formatFieldValue` 只用于渲染 `Choice`。属于冲突判定行为改动,留给你。 - 处理:**待你确认后实施**。你的方向:diff resolver 展示精确值、不做预处理。**示例**:线上截图是 `game-media/shot-A.png`,用户本地槽位是 `game-media/shot-B.png`;两者都命中「媒体 objectKey」,`formatFieldValue` 把它们都折叠成 `沿用上一版本`,于是 `differing` 判为相等、整行不显示,真实的线上键差异被隐藏。实施方式(相等判定直接比较原始值、截图逐槽比较,`formatFieldValue` 只用于渲染)已确定;唯一待定的是展示口径:按你说的给精确 objectKey,还是保留「沿用上一版本」这种友好文案?确认后我再改。 - [x] 14. `.../export/tabs/taonier/Page.tsx:18-20`(other · low) - 原实现:灰度未命中(含读取中 / 读取失败)时渲染 `EmptyTab`,文案固定是「陶泥儿导出页尚未接入」。 - 问题:功能其实已实现,只是当前账号灰度没命中;「尚未接入」会让用户以为功能不存在。 - 建议 / 修复:`EmptyTab` 增加可选 `message`,陶泥儿灰度未命中传「陶泥儿发布正在灰度中,暂未对你开放」。 - 处理:已修复,提交 `a347d9282`,同步 `exportGamePublishAvailability` 断言并移除不再使用的导入。 - [x] 15. `server-rs/crates/spacetime-client/src/game_distribution.rs:704`(bug · critical) - 原实现(review 描述):方法引用了 `GameDistributionPublishVersionInput` 与新 procedure,但 `module_bindings.rs` 未重新生成。 - 问题:review 生成时点的状态。当前 `module_bindings.rs` 已重新生成:旧 `create_game_distribution_*` 声明为 0、新 `publish_game_distribution_version_and_return_procedure` / `game_distribution_publish_version_input_type` 已声明并再导出,`cargo check -p spacetime-client --all-targets` 通过。 - 处理:**过时**,无需改动(绑定在 `c7c99567b` / `8a38ce35a` 已重生成)。 - [x] 16. `server-rs/crates/spacetime-client/src/game_distribution.rs:742-745`(maintainability · low) - 原实现:`match (game, version)` 把「缺作品快照」与「缺版本快照」都收敛成 `missing_snapshot("游戏发布结果")`。 - 问题:mapper 本就分别返回两个 `Option`,合并后无法判断缺的是哪一半,排障变难。 - 建议 / 修复:按 `(Some,None)`、`(None,Some)`、`(None,None)` 给三种不同文案。 - 处理:已修复,提交 `c9190a26f`。 - [x] 17. `.../export/state/useTaonierTab.ts:287-291`(bug · medium) - 原实现:发布后 `await taonier.refreshOnline()`,回来后 `rewriteScreenshotsToOnline(refreshed)` 无条件把 `screenshotPaths` 覆盖成回读 key。 - 问题:await 期间用户仍可增删换截图;回读解析后覆盖会静默吞掉这些编辑,且 `updateImageFields` 会标 dirty 把被覆盖的状态自动存盘。 - 建议 / 修复:发布时快照 `effectiveForm.screenshotPaths`,回读后只有当前槽位仍与快照逐槽相等才改写。属于行为改动,留给你。 - 处理:已修复,提交 `6fdca1f8c`。按你的方向把「回读线上媒体」并进发布进度弹窗:发布成功后弹窗保持 `running`(“正在回读线上媒体…”),等 `refreshOnline` 与回写都完成再关闭;弹窗开着时页面不可操作,用户没法在 await 窗口改截图,回写不会静默覆盖。回读失败单独 `catch`,不把已成功的发布误报成「发布失败」。 - [x] 18. `.../export/state/useTaonierExport.ts:754-756`(maintainability · low) - 原实现:`adoptOnlineScreenshots` 与 `rewriteScreenshotsToOnline` 各自写一遍 `map(image => image.objectKey.trim())` + 丢空值。 - 问题:同一归一化口径重复两处,新增调用点会继续复制。 - 建议 / 修复:抽模块级 helper 复用。 - 处理:已修复,提交 `aecb5a1ae`(新增 `onlineScreenshotObjectKeys`,`taonierExport` 30 passed)。 - [x] 19. `server-rs/crates/spacetime-module/src/game_distribution.rs:2145-2150`(bug · high) - 原实现:统一发布路径删掉了旧 `create_game_distribution_version_tx` 里「把同 `game_id` + `version_number` 的 `pending_review` 版本取消替换」的循环。 - 问题:`version_number` 现在是客户端可复用的标签;同一标签重新提交会留下多个同标签 `pending_review` 版本,而 `approve_game_distribution_version_tx` 不带「有无更新版本」的守卫,运营可能激活过期包。 - 建议 / 修复:插入新版本前,对同 `game_id` 同 `version_number` 的 `pending_review` 版本重新执行取消(status → cancelled,reason「被同一项目版本的新提交替代」)。属于审核语义,留给你。 - 说明(无术语):同一款游戏、同一个版本号(比如都叫「1.2」)可以一前一后提交两份待审包。审核台若按版本号打开,可能点开先提交的旧包上线,而后来提交的新包还躺在待审列表。旧流程在插入新包时会把同号旧待审包标成「已取消」,统一发布后这一步没了。 - 处理:**保持现状(按你的要求只解释)**;要恢复就在插入新版本前,把同 `game_id` + 同 `version_number` 的 `pending_review` 置为 `cancelled`。 - [x] 20. `server-rs/crates/spacetime-module/src/game_distribution.rs:2084`(bug · medium) - 原实现:`let _game_id = required_game_distribution_text(input.game_id.clone(), "game_id")?;` 校验并 trim 了 `game_id`,却把它丢进 `_game_id`;后续查找、插入与收据写仍用原始 `input.game_id`。 - 问题:带首尾空白的 `game_id` 能过校验但绕过归一化,可能创建出与后续查找所用值不一致的行。 - 建议 / 修复:保留校验后的值并贯穿整条事务,而不是丢弃。属于行为修正,留给你。 - 说明(无术语):`game_id` 是作品编号。这个编号既可能是客户端显式传的(更新既有作品),也可能是服务端为首次发布派生的;模块对编号做了一次「去掉首尾空格」的校验,但校验结果被丢进 `_game_id`,后面查找、写入、收据还是用原始没去空格的编号。所以这次 trim 没有任何实际效果,是一段没接上的防御代码。 - 处理:**保持现状(按你的要求只解释)**;要么把校验后的值贯穿整条事务,要么删掉这行无效校验,当前无害。 - [x] 21. `server-rs/crates/spacetime-module/src/game_distribution.rs:2018-2028`(bug · medium) - 原实现:`game_id` 命中分支会拒绝 `SUSPENDED` 游戏,但 `(owner, project_key)` 复用回退只过滤软删。 - 问题:请求的 `game_id` 不存在、而存在同 `project_key` 的被暂停游戏时,会静默复用它并为它建新版本,绕过了暂停限制。 - 建议 / 修复:这里也补 `GAME_DISTRIBUTION_VISIBILITY_SUSPENDED` 检查,与 `game_id` 分支一致。属于行为修正,留给你。 - 处理:已修复,提交 `db0d99fc8`。`(owner, project_key)` 回退分支补上 `SUSPENDED` 检查,与 `game_id` 命中分支一致,并补单测 `suspended_game_is_rejected_for_publish`。 - [x] 22. `server-rs/crates/spacetime-module/src/game_distribution.rs:2037-2040`(maintainability · low) - review 描述:旧的 create-game 事务通过 `required_game_distribution_text` 校验并 trim `title/summary/category/orientation`,新 insert 原样落库。 - 核实:**误报**。用 Git 取 `7a710bb2b^` 的旧实现核对,旧 `create_game_distribution_game_tx` 与旧 api-server 都是 `input.title.clone()` / `payload.title.clone()` 原样透传,从未 trim。现实现没有回归。 - 说明:确实存在「作品行未 trim、而版本快照 `resolve_version_metadata_json` 里 `title/summary/tags` 会 trim」的口径差,但这是既有状态、且属于会改持久化数据的变更。若要对齐再单独决定。 - 处理:**误报,未改动**。 - [x] 23. `src/components/game-distribution/gamePublishSubmission.ts:219`(bug · medium) - 原实现:更新模式指向与既有草稿不同的游戏时,`resolveDraftIdentity` 会新铸 `projectKey/versionNumber`,但下一行仍无条件把旧草稿的 `versionId` 带进新草稿。 - 问题:新草稿出现 `gameId` 指新游戏、`versionId` 属旧游戏的错配;恢复时 `GamePublishPage` 直接 `getGameVersion(stored.versionId)` 不校验归属,会加载并可能重提旧游戏的版本。 - 建议 / 修复:只有持久化身份仍指向同一游戏(或 create 模式)时才带 `versionId`。属于恢复行为修正,留给你。 - 处理:已修复,提交 `f96842960`。只有持久化身份仍指向同一游戏时才附带旧草稿 `versionId`,并补单测「更新目标换成另一款游戏时丢弃旧草稿的 versionId」。 - [x] 24. `src/components/game-distribution/gamePublishDraft.ts:45`(bug · medium) - 原实现:schema 现在要求 `projectKey`,但存储键仍是 `...publish-draft.v1`。 - 问题:旧流程写的草稿(有 `gameId`+`versionId`、无 `projectKey`)会被 `isDraft` 判否、被 `readPublishDraft` 静默丢弃,用户无法续上在途发布;重试会新铸 `projectKey`,可能新建重复游戏/版本并孤立服务端旧版本。 - 建议 / 修复:接受旧形状并在读取时回填本地 `projectKey`,或一次性迁移旧键。属于持久化兼容,留给你。 - 处理:**不做迁移**。按你的决定:产品尚未上线、没有历史草稿数据,旧存储键兼容与一次性迁移都不做。 - [x] 25. `src/components/game-distribution/gamePublishDraft.ts:49-50`(maintainability · low) - 原实现:两个独立检查允许只带 `gameId` 或只带 `versionId` 的半写草稿进入恢复路径。 - 问题:半成品服务端身份会流到恢复流程。 - 建议 / 修复:要求 `gameId`/`versionId` 同时有或同时无(`hasGameId === hasVersionId`)。 - 处理:已修复,提交 `a4bd72887`,新增 `gamePublishDraft.test.ts` 覆盖 4 例。 - [x] 26. `src/components/game-distribution/gamePublishSubmission.ts:149-154`(maintainability · low) - 原实现:create 模式只要存在 owned 草稿就无条件复用其 `projectKey/versionNumber`;publish 阶段失败留下的草稿没有 `versionId`,页面只在 `draftDetail` 可用时渲染恢复提示与清除按钮。 - 问题:这种「隐形草稿」无法放弃。若原发布其实成功但响应丢失,之后内容变了再提交会 409,用户没有开始全新发布的入口。 - 建议 / 修复:review 建议「只在草稿有 `versionId` 时才复用」——**这条不建议照做**:projectKey 在响应前落草稿,正是为「响应丢失后重试命中同一身份」服务,砍掉会破坏自然幂等。真正该补的是一个针对无 `versionId` 草稿的显式重置/放弃入口。属于产品/交互决定,留给你。 - 处理:**不做迁移**。按你的决定:产品尚未上线、没有历史数据,这类无 `versionId` 隐形草稿的显式重置 / 放弃入口暂不新增。 - [x] 27. `server-rs/crates/spacetime-client/src/module_bindings/publish_game_distribution_version_and_return_procedure.rs:24`(bug · high) - review 描述:新 procedure 模块成了孤儿,父模块未声明/再导出。 - 核实:当前父 `module_bindings.rs` 已同时有 `pub mod publish_game_distribution_version_and_return_procedure;` 与对应 `pub use`,`spacetime-client` 编译通过。 - 处理:**过时**,无需改动。 - [x] 28. `server-rs/crates/spacetime-client/src/module_bindings/game_distribution_publish_version_input_type.rs:9`(bug · critical) - review 描述:文件已改名,但父模块仍声明旧的 `game_distribution_create_game_input_type`,会编译失败。 - 核实:父模块已更新为 `game_distribution_publish_version_input_type` / `GameDistributionPublishVersionInput`,旧声明计数为 0,旧文件已删,编译通过。 - 处理:**过时**,无需改动。 - [x] 29. `server-rs/crates/spacetime-module/src/game_distribution.rs:2005-2008`(bug · high) - review 描述:确定性 `gameId` 下软删后重发会命中该分支并以「游戏已被删除」失败关闭,delete→re-publish 链路断裂。 - 核实:已在后续提交修复——命中软删行时**就地复活覆盖**为全新作品(清 `deleted_at`、回未公开、`publication_revision=0`、清空活动版本与计数、用本次资料覆盖),并清掉该作品旧的 `create_version` 收据;api-server 仅在显式 `gameId` 等于该 `projectKey` 的确定性身份时走复活。 - 处理:**已修复**,提交 `90f4e0cfb`(本轮之前完成),单测 `revive_overwrites_soft_deleted_game_as_fresh_identity` 锁定。 - [x] 30. `server-rs/crates/spacetime-module/src/game_distribution.rs:2123-2125`(maintainability · low) - 原实现:`if game.owner_user_id != owner || version.owner_user_id != owner` 合并判断,只回「游戏 owner 不匹配」。 - 问题:只有版本 owner 不符时,报错指向错的对象,误导排障。 - 建议 / 修复:拆成两条独立校验,分别报游戏 / 版本 owner 不匹配。 - 处理:已修复,提交 `9440ef037`(`spacetime-module game_distribution` 7 passed)。 - [x] 31. `src/components/game-distribution/gamePublishSubmission.ts:252`(bug · medium) - 原实现:提交模块用 `versionId` 派生确定性的 `${versionId}:upload` / `${versionId}:submit` 幂等键;但恢复路径 `GamePublishPage.handleResumeSubmit` 仍用每会话随机的 `resolvePublishKey()`。 - 问题:刷新页面后恢复,同一上传/送审会带不同 `Idempotency-Key`,服务端无法关联去重「其实已落地但响应丢失」的请求。 - 建议 / 修复:统一键方案,恢复路径也改用基于 `versionId` 的确定性键。属于幂等行为改动,留给你。 - 处理:已修复,提交 `80e910dfe`。恢复路径改用基于 `versionId` 的确定性键(`{versionId}:upload` / `{versionId}:submit`),并补断言锁定。 - [x] 32. `src/components/game-distribution/GamePublishPage.tsx:540-544`(maintainability · low) - 原实现:`handleSubmit` 与 `handleUpdateSubmit` 逐字复制「`prepareGamePackage` → 媒体 submission → metadata/parts → `submitGamePublish`」,只差 `target`、`versionNumber` 与成功收尾。 - 问题:新增字段或校验变化时要改两处,两条链路会漂移。 - 建议 / 修复:抽组件内 `runUnifiedPublish({ target, versionNumber, priceMudPoints })`,成功收尾仍各模式自理。 - 处理:已修复,提交 `f3c3f279b`(`GamePublishPage` 19 + `gamePublishSubmission` 7 passed)。 | # | 问题 | 提交 | |---|------|------| | 1 | 发布入口键盘焦点不可辨认 | `3cd6f2f11` | | 5 | 「沿用上一版本截图」按钮永远可点 | `695db9087` | | 6 | 陶泥儿封面 / 上一版本预览嵌套三元 | `b823b397c` | | 7 | 上一版本图片查找逻辑重复 | `f53498516` | | 9 | `buildTaonierPublishMetadata` 封面嵌套三元 | `c800ddc19` | | 11 | AGC 发布媒体前缀与 Rust 常量无门禁 | `ce32f4a1b` | | 13 | 冲突卡排序比较导致顺序改动被吞 | `1bd0a3e0b` | | 14 | `publish_game_distribution_version` 缺文档 | `5f084d0ca` | | 22 | 发布回读后永远走不到的 `catch` | `7591b1f9d` | | 25 | `forkSource` 改名遗留无 TODO | `e2931ef66` | | 29 | 按号查版本在 `created_at` 相同时不确定 | `07b3dbb40` | | 12 | 发布资料预览地址逐张串行解析 | `80a801c16` | | 26 | 原生送审用客户端传入版本号 | `80a801c16` | | 30 | 复活重置 `created_at` 的意图未写明(改为文档化「复活即覆盖」) | `80a801c16` | | 34 | 幂等摘要未归一 `projectKey` | `bc32e0b7e` | | 35 | 统一发布未作废同号 `pending_review` | `80a801c16` | | 37 | 更新模式发布草稿配对校验过严 | `f1c864309` | | 38 | 只改档位却重发整份资料(补「必须传完整投影」文档) | `80a801c16` | | 39 | 网页按 `latestVersion` 推下一版本号 | `80a801c16` | | 25 附 | `lineage` 对外字段改名 `network`、`forkSource` 改名 `source` | `80a801c16` | > 另有一个纯格式化提交 `99a70cbfd`(prettier 收敛冲突卡与测试改动)。 > 本轮术语对齐(`lineage`→`network`、`forkSource`→`source`)、作者侧权威 `latestVersionNumber` 与 #12 / #26 / #30 / #35 / #38 同批提交 `80a801c16`;#34 为 `bc32e0b7e`;#37 为 `f1c864309`。 --- ## 详细处置 ### 已自动修复 > 下面按原 review 编号列出;本轮把「留给你决定」里已按你指示完成的项也标成 `- [x]`,仍为 `- [ ]` 的才是真正待你拍板的项。当前已处理 **19** 项 / 待定 **18** 项。 - [x] 1. AGC 发布入口 `:focus-visible` 只有 `filter: brightness(1.05)` + `outline: none`,键盘焦点几乎不可见。 - 现状:`.project-chat-publish-trigger` 的 hover 与 focus 共用一条规则,focus 时靠 5% 亮度差表示。 - 问题:实心主色胶囊上 5% 亮度差肉眼难辨,键盘用户定位不到焦点(可访问性缺陷)。 - 处理:拆开 hover / focus 两条规则,focus 保留亮度反馈并补 `outline: 2px solid var(--platform-button-primary-border)` + `outline-offset: 2px`。提交 `3cd6f2f11`。 - [x] 5. `FormCard.tsx` 里「沿用上一版本截图」的 `disabled={onlineScreenshots.length === 0}` 是死代码。 - 现状:该按钮只在 `showOnline && onlineScreenshots.length > 0` 分支内渲染,长度恒 > 0。 - 问题:沿用一次后草稿仍不满足条件,按钮保持可用;重复点击会反复标 dirty、触发无意义的自动保存 / 冲突往返。 - 处理:改算 `screenshotsSame`(草稿 `screenshotPaths` 与上一版本 `objectKey` 逐槽相同),相同即停用;补了「相同停用 / 不同可用并触发沿用」的测试。提交 `695db9087`。 - [x] 6. `FormCard.tsx` 封面预览用嵌套三元(`coverOnlineImage` 计算与封面 JSX 链)。 - 现状:计算里是 `coverIsObjectKey ? (a ? b : c) : d`,JSX 里再套「有图 / 读失败 / 占位」三层。 - 问题:可读性差,且封面、上一版本封面、上一版本截图三处各写一套回退样式,容易漂移。 - 处理:新增 `OnlineImageSlot` 组件统一三态渲染;封面预览改为 `renderCoverPreview()` 用 `if` 提前返回;上一版本封面与截图预览复用同一组件。提交 `b823b397c`。 - [x] 7. `ConflictCard.tsx` 的 `onlineCover → onlineScreenshots` 查找与 `FormCard` 重复。 - 现状:两处都是「先比 cover 的 objectKey,再在 screenshots 里找」。 - 问题:查找规则(前缀 / trim / 回退)一旦变化,两处会各自漂移。 - 处理:`useTaonierExport` 导出 `findOnlineImage(objectKey, cover, screenshots)`,`ConflictCard` 的 `ImageThumb` 与 `FormCard` 封面预览统一改用它。提交 `f53498516`。 - [x] 9. `buildTaonierPublishMetadata` 的 `coverObjectKey` 用嵌套三元表达三态。 - 现状:`localCover ? (是 objectKey ? 用它 : null) : (online?.cover?.objectKey ?? null)`。 - 问题:同一份三态语义(空=沿用 / objectKey=沿用键 / 其余=本地图)写得难读,也和仓库「不再用嵌套三元」的约定相悖。 - 处理:改为显式变量 + `if`。提交 `c800ddc19`。 - [x] 11. AGC 的 `GAME_DISTRIBUTION_MEDIA_OBJECT_KEY_PREFIX` 与 Rust `GAME_DISTRIBUTION_MEDIA_PREFIX` 是两个手写常量。 - 现状:两边都是 `"agc/project-snapshots/v1/game-distribution/media/"`;用它区分「沿用 objectKey」与「本地路径」。 - 问题:前缀漂移会让 UI 把 objectKey 当本地路径(宿主读盘失败)或反之;现有 DTO parity 脚本不校验常量。 - 处理:在 `scripts/check-game-distribution-dto-parity.mjs` 里加一条从 Rust 读常量、与 AGC 常量逐字比对的断言(解析不到也会失败)。提交 `ce32f4a1b`。 - [x] 13. `ConflictCard.tsx` 的 `isSameFieldValue` 先排序再比较截图数组。 - 现状:`[...left].sort()` / `[...right].sort()` 后逐槽比。 - 问题:截图顺序就是发布顺序(`buildTaonierPublishMetadata` 原样保序、`sameForm` 也按槽位比),排序比较会把「只换了顺序」判成没改;冲突卡不展示,保存时默认留「我的」,等于静默丢掉项目里的新顺序。 - 处理:改为按槽位逐个比较;补「只换顺序也算项目改动」的回归测试。提交 `1bd0a3e0b`。 - [x] 14. `spacetime_client::publish_game_distribution_version` 返回裸 `bool`,无文档。 - 现状:返回 `(game, version, replayed)`,`replayed` 的语义只藏在模块实现里。 - 问题:调用方无法从签名看出布尔值含义与幂等键、请求摘要的关系。 - 处理:补文档注释,说明 `replayed` = 命中同一 `(game_id, idempotency_key, request_digest)`、未新建版本,以及 `project_key` / `version_number` 的角色。提交 `5f084d0ca`。 - [x] 22. `useTaonierTab.ts` 回读后的 `catch` 不可达。 - 现状:`refreshOnline` 在任何失败路径都返回 `null`(内部 `try/catch` 吞错),从不 reject。 - 问题:`try/catch` 是死代码,误导读者以为回读会抛;`rewritePublishedMediaToObjectKeys` 也不依赖回读结果。 - 处理:去掉 `try/catch`,保留「换项目不改写」与「同项目改写后收起进度」的语义。提交 `7591b1f9d`。 - [x] 25. `GameDistributionSourceResponse.fork_source` 字段名与 `GameDistributionSource` 类型不一致且无 TODO。 - 现状:类型已改名 `Source`,线上字段仍是 `forkSource`。 - 问题:改名遗留会变成静默技术债(同类 `lineage` 字段已有 TODO)。 - 处理:按 `lineage` 的口径补 `TODO(rename-refactor)`,注明是公开契约破坏性改名、待读面同批切换。提交 `e2931ef66`。 - 后续(`80a801c16`):你授权后已把 TODO 兑现为实际改名——`forkSource` → `source`、`lineage` → `network`,覆盖 shared-contracts DTO、api-server 手拼响应、TS 契约与读取方、测试夹具、DTO parity 脚本与文档,clean cut 无别名。 - [x] 29. `get_game_distribution_version_by_number_tx` 用 `max_by_key(created_at)`,同号同时间不确定。 - 现状:同号多行(历史收敛前)取 `created_at` 最新。 - 问题:`created_at` 相等时胜者依赖索引迭代顺序,同一 REST 查询可能返回不同 `version_id`。 - 处理:改 `max_by`,`created_at` 相同再用 `version_id` 兜底成全序。提交 `07b3dbb40`。 --- ### 留给你决定 - [x] 2. `GameMetadata.category` 生成成 `string`,丢掉 `GameDistributionCategory` 联合类型。(与 4 重复) - 现状:Rust `category: String`,服务端按 `GAME_DISTRIBUTION_CATEGORIES`(休闲/益智/动作/冒险/模拟/策略/其他)运行时校验;生成的 TS 是 `category: string`。 - 问题:TS 调用方可以传任意字符串,编译期不再拦住非法分类(运行时仍会 400)。 - 建议:想恢复联合类型,最干净是把 category 做成 Rust 枚举并 derive ts-rs,再改 api-server 校验与 parity 清单;直接 `ts(type = "GameDistributionCategory")` 不会自动补 import,而把 7 个字面量抄进 `ts(type = "'休闲' | ...")` 会引入第二份会漂移的清单。属于契约设计决策,留给你。 - 无领域术语版:现在“游戏分类”这个框子在后端只允许 7 个固定选项,但发给前端代码的说明书里写成了“任意文字”。前端写错字只有在提交时才会被服务器挡回来,写代码时不会报错。要修好,最好让“7 个选项”只定义一次并自动同步到前端,否则就是在两处各抄一份、早晚不一致。 - 你的决定:采纳「Rust 枚举」方案。处置(`4c7ab9934`):新增 `GameDistributionCategory` 枚举(7 个中文取值用 `#[serde(rename)]` 保持线上 JSON 不变,未知分类在 DTO 反序列化即 400),`GameMetadata.category` 与 `GameDistributionUpdateGameMetadataRequest.category` 由 `String` 改成它;ts-rs 导出 `generated/GameDistributionCategory.ts`,`GAME_DISTRIBUTION_CATEGORIES` 运行时数组用 `satisfies` + 双向条件类型断言与生成联合逐值对齐;api-server 删除运行时字符串白名单校验;parity 脚本把该类型从 `TS_ONLY_TYPES` 移入 `GENERATED_RUST_TYPES`。 - [x] 3. `NewGameVersionRequest.packageEntryPath` 生成成 `string`,丢掉 `'index.html'` 字面量。 - 现状:Rust `package_entry_path: String`,服务端 `validate_version_declaration` 只接受 `index.html`。 - 问题:TS 编译期不再阻止提交别的入口文件名。 - 建议:可加 `ts(type = "'index.html'")`;但这是把 TS 收窄,若有用变量拼请求体的调用方会立刻编译失败,需你确认没有这种用法。留给你决定。 - 无领域术语版:发布包的入口文件名实际上只能是 `index.html`,但前端说明书里写成了“随便什么字符串”。想让它写代码时就能发现错误,可以把说明改成“只能是 index.html”;不过这样某些用变量传文件名的老代码会编译不过,所以先确认一下影响面。 - 处置(`91d06846a`):按「契约不收窄」补文档注释,明确 `packageEntryPath` 刻意保持 `string`;不改类型。 - [x] 4. `GameDistributionUpdateGameMetadataRequest.category` 同上(与 2 重复)。 - 现状 / 问题 / 建议:同 2,只是改的是资料编辑请求体。 - 无领域术语版:同 2。 - 你的决定:采纳。处置同 2(`4c7ab9934`):`category` 由 `String` 改为 `Option<GameDistributionCategory>`,TS 生成成可选联合。 - [x] 8. 空数组语义从「整组沿用线上截图」改成「没有截图」,但 `.export/taonier.json` 旧草稿没有迁移。 - 现状:`buildTaonierPublishMetadata` 把 `screenshotPaths: []` 映射成 `screenshots: []`;测试也明确钉住「空数组 = 没有截图、不再隐式沿用」。 - 问题:作者本地若残留旧的空数组草稿,下一次发布会把原本沿用的整套截图悄悄发成 0 张。 - 建议:这属于本地草稿迁移决策。要么给注册表加 schema / 版本标记并一次性迁移,要么在读到「更新模式 + 空数组 + 线上有截图」时提示用户确认;但任何「空 = 沿用」的兜底都会把 clean cut 刚去掉的隐式语义加回来。留给你判断是否值得为存量草稿引入迁移。 - 无领域术语版:以前“截图列表为空”这句话的意思是“沿用上一版所有截图”;现在改成了“没有截图”。如果有人电脑上还存着按老意思写的草稿,重新发布时那批截图会莫名消失。要不要为老草稿做一次自动转换,是个取舍:做转换就要在文件里记版本号并写迁移逻辑,不做就得接受这个边角情况。 - 你的决定:不采纳迁移,旧草稿不做兼容;本项作为已记录的历史风险关闭。 - [x] 10. `module-game-distribution::create_version` 校验 `idempotency.key` 非空却丢弃它。 - 现状:scope key 由 `(game_id, version_number)` 现算(无 `project_key`),但 `normalize_idempotency` 仍要求调用方传非空 key。 - 问题:调用方必须传一个对幂等毫无影响的值;且内存参考模型的收据键形如 `{game_id}::v{n}`,与生产统一发布路径的 `{game_id}:{project_key}:v{n}` 对不上。 - 建议:二选一——要么 documented 地忽略 key(并写清为什么),要么把 `project_key` 穿进 `CreateVersionInput` 让自然键与生产一致。留给你定参考模型的定位。 - 无领域术语版:这个命令要求调用方填一个“防重复编号”,但实际算编号时又不用它,等于白填;而且参考实现里算出来的编号格式,和真正线上用的不是一套。要么干脆别要求填,要么把缺的那段信息补齐,让两边算法一致。 - 处置(`222993af9`):`create_version` 不再要求幂等 key,自然键只由 `(game_id, version_number)` 派生。 - [x] 12. `build_publication_draft` 逐张 `await` 解析封面 / 截图预览地址。 - 现状:封面 1 次 + 每张截图 1 次,串行 `await resolve_preview_fields`。 - 问题:打开更新面板要等最多 7 次独立网络往返,延迟叠加。 - 建议:这些请求彼此独立,可用 `futures::future::join_all` 并发并保持顺序。注意原 review 示例里的 `zip_pairing` 并不存在,需要自己保留 `(object_key, asset_id)` 配对与规范化,属于需要小心的改动。 - 无领域术语版:打开面板时要一张一张地去问服务器“这张图的预览地址是什么”,一张问完才问下一张,最多要排队 7 次。其实可以同时问、一起等,面板能更快打开;只是代码要自己保证“哪张图配哪个结果”不乱。 - 处置(`80a801c16`):已用 `futures::join!` 把封面与 `join_all` 的截图预览并发出去,`join_all` 按输入顺序返回、再与 `(asset_id, object_key)` 槽位 `zip`,顺序不变。 - [x] 15. 统一发布事务拿到的 `project_key` 与幂等键用的规范值可能不一致。 - 现状:`idempotency_key` 用 `canonical_project_key`(优先作品行里已存的权威值),但传给事务的仍是请求里的原值,会写进版本行的 `project_key`。 - 问题:更新既有作品时若请求省略或传了不同的 `projectKey`,版本行存的 key 会和作品行、自然键都不一致。 - 建议:把规范值传进事务(或明确决定版本行的 `project_key` 只记请求值),并补一条「省略 / 异值 projectKey 的更新发布」测试。属于身份语义决策。 - 无领域术语版:同一次发布里,“防重复用的项目编号”取的是权威值,但真正写进数据库的是用户这次发来的值。用户如果没发或发得不一样,数据库里就会留下两个互相矛盾的编号。统一成一份权威值更稳,但改的是数据落库口径,先由你拍板。 - 处置(`685eb16ec`):api-server 只在 `POST /games` 传 `projectKey`,路径端点一律传 `None`。 - [x] 16. 资料 PATCH 里「改资料」与「提升共创授权」是两次独立事务。 - 现状:先提交 `update_game_distribution_game_metadata`(已提交、已 bump revision),再单独跑 `set_game_distribution_fork_authorization`。 - 问题:第二步失败(例如降级被拒 / 并发改动)时,资料已经落库,但请求返回错误;用户以为整体失败,重试又因 revision 变了而 409,形成半写状态。 - 建议:把方向和前置校验提前到资料事务里,或把两次写入合并进同一个事务 / procedure。留给你评估事务成本。 - 无领域术语版:一次“保存资料”其实偷偷分成两步:先存资料,再改共创许可。如果第二步失败,资料已经存进去了,可页面却提示失败;用户再点一次又因为“版本号变了”被拒绝。要么先检查清楚再一起提交,要么两步合成一步。 - 处置(`bb90ad588`):共创授权提升并入 `update_game_distribution_game_metadata_and_return` 同一事务,删除第二事务与独立 procedure。 - [x] 17. 按号装载作者版本时不再过滤已软删作品(回归)。 - 现状:`load_owner_version_by_number_or_404` 只校验 `owner_user_id`;旧的 `get_owner_game_distribution_version_tx` 还会在作品缺失 / 软删时返回 `None`。 - 问题:包上传、工程包上传、送审、撤回、版本详情等入口对软删作品的版本仍然可操作。 - 建议:在 by-number 装载路径补回作品 `deleted_at` 判断(对应事务里 join 一次作品行即可)。属于授权边界回归,建议尽快修。 - 无领域术语版:作品被删掉后,本来它的各个版本也应该跟着“看不见”,但新的查法只确认“这是你的版本”,没确认“这个作品还在”。结果是被删作品的版本还能继续上传、送审。修法是查询时顺手看一眼作品是否已被删除。 - 处置(`09be5f1d3`):`get_game_distribution_version_by_number_tx` join 作品行并拒绝软删作品。 - [x] 18. `GameMetadata` 里带 `#[serde(default)]` 的字段在 TS 里仍是必填。(与 24 重复) - 现状:Rust `description` / `cover_object_key`(`Option` + default)与 `tags` / `screenshots`(default)都可省略,但生成 TS 是必填(`description: string | null` 等)。 - 问题:TS 比线上契约更严,依赖服务端默认值的调用方被迫传 `null` / `[]`,合法省略反而编译不过;相对删掉的手写镜像是一处回归。 - 建议:给 `description` / `cover_object_key` 加 `ts(optional = nullable)`、给 `tags` / `screenshots` 加 `ts(optional)`,重跑 `cargo test -p shared-contracts --features ts-bindings export_bindings` 并提交生成文件。因为改的是公开 TS 契约,留给你统一做并跑 `check:generated-bindings`。 - 无领域术语版:server 允许“这些字段不发”,但前端说明书把它们标成“必须发”。结果本来可以不填的地方,前端不填就报错。修法是在后端声明这些字段可选,再重新生成一次前端类型。 - 处置(`bb90ad588`):`GameMetadata` 字段保持必填;局部更新走独立更新 DTO。 - [x] 19. `GameMetadata.fork` 允许显式 `null`,生成的 TS 只写 `fork?: {...}`。(与 31 重复) - 现状:Rust 是 `Option<...>` + `#[serde(default)]`,文档也说「显式 null 与省略等价」;TS 类型没有 `| null`。 - 问题:TS 调用方无法表达服务端允许的 `null` 形式。 - 建议:`ts(optional, type = "{ ... } | null")`(与 `forkAuthorization` 的写法一致),重新生成绑定。属公开 TS 契约,留给你。 - 无领域术语版:后端说“这个来源可以空着,也可以明确写 null”,两句话意思一样;但前端说明书只允许“空着”,不允许写 null。改一句话就能对齐。 - 处置(`94c8a6bda`):`GameMetadata.fork` 的 TS 接受显式 `null`。 - [x] 20. `GameDistributionUpdateGameMetadataRequest.tags` / `screenshots` 在 TS 里必填。 - 现状:Rust 两者都有 `#[serde(default)]`,生成 TS 却是 `tags: string[]` / `screenshots: ...[]` 必填;同结构体的 `description` / `coverObjectKey` / `forkAuthorization` 已正确标可选。 - 问题:资料编辑器被迫恒发这两个数组,省略不再合法。 - 建议:加 `ts(optional)` 后重跑绑定生成。属公开 TS 契约,留给你。 - 无领域术语版:同 18:后端允许不发标签和截图,前端说明书却要求必须发。 - 处置(`bb90ad588`):更新 DTO 除 CAS 外全部可选,缺省 / `null` = 不动该字段。 - [x] 21. `adoptOnlineScreenshots` 会把旧版 `assetId` 当 objectKey 写进草稿槽位。 - 现状:`readOnlineImageKey` 在 `objectKey` 缺失时回退到旧合同字段 `assetId`(迁移窗口兼容);`adoptOnlineScreenshots` 直接把解析结果写进 `screenshotPaths`。 - 问题:下游只用「OSS 前缀」判定是不是线上键,旧 `assetId` 会被当成项目内本地路径,发布时宿主去读盘失败。 - 建议:要么在媒体合同迁移完成后删掉 `assetId` 回退,要么在沿用前显式拒绝 / 标记非 OSS 键。 - 无领域术语版:为了兼容旧格式,代码会把一种老编号也当成“线上图片编号”存下来;可后面判断“是不是线上图”只看新格式前缀,于是老编号被误当成电脑里的文件路径,上传时找不到文件。等旧格式彻底退役就把兼容删掉,或者在沿用前先判一下格式。 - 处置(`496737759`):`readOnlineImageKey` 只读 `objectKey`,删除 `assetId` 回退。 - [x] 23. 建议给 `projectKey` 加 `localProjectId` 旧名 `serde(alias)`。(**建议不采纳**) - 现状:统一发布请求体字段是 `projectKey`,旧 `GameDistributionCreateGameRequest.local_project_id` 及旧路由已按 ADR 删除。 - 问题:review 担心仍在运行的老客户端带旧字段。但 ADR 明确是 clean cut:旧 DTO / 旧路由一律删除,旧客户端会收到 404/405,不留别名、不双跑。 - 建议:不建议加别名,除非你确实要为某个已部署客户端开兼容窗口;若开,需同时补兼容测试、灰度与退役计划。 - 无领域术语版:老版本客户端用的字段名已经按项目决定彻底删掉了,旧接口也会直接报“不存在”。review 担心老客户端会因此失败,但保留旧名字本身就违反这次“一刀切、不留旧包袱”的决定。除非确认线上真还有老客户端在跑,否则不改。 - 你的结论:不采纳,不加 `localProjectId` serde alias。 - [x] 26. 原生发布送审仍用客户端传入的 `version_number_text`,而非服务端权威的 `version.version_number`。 - 现状:同一流程里包上传与工程包上传都用 `version.version_number`,只有 submit 用请求值。 - 问题:新建作品走 `POST /games`、服务端固定首版为 1;若哪天调用方传了非 1 的版本号,包传到了 v1,送审却打到 `/versions/{version_number_text}/submit` 而 404。当前调用方都传 1,所以暂未暴露。 - 建议:submit 路径改用 `version.version_number`(一行改动)并补一条测试。因为它目前是潜在问题、且该文件此前有并发改动,我没有动。 - 无领域术语版:同一次发布里,上传文件用的是服务器回执里的真实版本号,最后“提交审核”那一步却用客户端自己传的版本号。现在客户端传的恰好都对,所以没出错;万一传错,文件传上去了但审核提交会找不到版本。统一用服务器回执的版本号即可。 - 处置(`80a801c16`):submit 前的路径段改用 `version.version_number.to_string()`;请求体里的版本号仍只用于发布路径。 - [x] 27. 统一 bootstrap 插入作品行时未做空值校验。 - 现状:`get_or_create_game_distribution_game_for_publish_tx` 直接把 `title/summary/category/orientation` 写库;旧的 `create_game_distribution_game_tx` 会用 `required_game_distribution_text` 校验非空。 - 问题:这条路径完全依赖 api-server 校验;若将来有第二个调用方(procedure),可能写进空标题等脏行。 - 建议:在事务里补一份非空校验(最后一道防线),或显式文档化「api-server 是唯一校验点」。 - 无领域术语版:新建作品时后端直接把用户给的名字等写进数据库,没有自己再检查一遍是否为空,靠上一层接口把关。以后如果绕过这层接口直接调用,就可能存进空名字。要么自己再查一遍,要么写明“只有这一条入口,别绕过”。 - 处置(`122230e25`):统一 bootstrap 在事务内补 title / summary / category / orientation 非空校验。 - [x] 28. `(owner, project_key)` 兜底命中时未断言 `game.game_id == input.game_id`。 - 现状:按 owner + project_key 找到任一未删作品就复用;版本行随后按 `input.game_id` 插入。 - 问题:若历史行用了同一 project_key 但 game_id 不同,会写出「版本属于 A、作品却是 B」的孤儿版本,而幂等键 / 媒体前缀用的是派生 game_id。 - 建议:断言 id 相等并失败关闭,或明确按 `game.game_id` 落版本。 - 无领域术语版:创建版本时,系统先按“作者 + 项目编号”找一个已有作品来挂靠。如果历史数据里同一个项目编号曾挂在另一个作品上,新版本会被塞进一个跟作品对不上的编号,产生错位数据。加一句“两者编号必须一致,否则报错”最稳。 - 处置:先按你的口径在 `e04032b7d` 给兜底加了 `game_id` 断言;随后按 #34 的决定整体删除 `(owner, project_key)` 兜底(`125849512`),该分支不再存在。 - [x] 30. 软删作品复活时把 `created_at` 重置为当前时间。(**你的结论:复活就是覆盖,旧的一去不回;已按此写清文档与注释**) - 现状:`revived_game_distribution_game_for_publish` 清空公开态 / 指标并 `created_at = now`。 - 问题:作品会在所有按创建时间倒序的列表 / 游标分页里被排到最前,看起来是全新作品,与「保留 game_id 与历史版本行」的语义不一致。 - 建议:保留原 `created_at`、只更新 `updated_at`;若确实想让它像新作品,就明确写进文档 / 注释。 - 无领域术语版:一个被删掉的作品在同名重新发布时会“复活”。复活时代码把它的“创建时间”改成了现在,于是它在列表里被当成刚出的新作品排到最前面。到底是希望它保持原来的资历还是当成新品,需要你定,然后要么改时间、要么把意图写清楚。 - 处置(`80a801c16`):你确认「republish 实际是 overwrite,上一份永远没了」,因此保留 `created_at = now`,并在 `revived_game_distribution_game_for_publish` 注释与实施计划里写明「复活 = 覆盖,不是复原:上一世代的资料 / 价格 / 指标 / 活动版本不可找回,`created_at` 重置让它在所有创建时间排序里按新作品出现」。 - [x] 32. SpacetimeDB 自动生成绑定里出现只改空格的重排,且不同步。 - 现状:约 229 个 `*_procedure.rs` 的回调用 16 空格缩进,约 32 个仍是 12 空格;文件头明确写着「EDITS TO THIS FILE WILL NOT BE SAVED」。 - 问题:手工改生成代码会造成 diff / 重新生成时来回抖动,且一半新一半旧很难看。 - 建议:不要手改;重新跑官方 codegen 让全部文件按同一版本输出并整体提交(或干脆把这类纯空格改动排除在 review 之外)。 - 无领域术语版:有些“自动生成的代码”被人工改了缩进,但只改了一部分文件。因为这类文件下次生成时会被覆盖,这种改动没有意义还添乱。正确做法是统一重新生成一遍。 - 处置(`d025ec4ba`):你的决定是整批重跑 codegen;本地 CLI 2.8.3 把 31 个 procedure 文件统一为 16 空格,纯缩进变化、无语义改动。 - [x] 33. 统一发布路径接收 `fork` / `fork_authorization`,却从不传给事务,静默丢弃。 - 现状:`GameMetadata` 含这两个字段,`GameDistributionPublishVersionRecordInput` 没有对应字段,统一事务也没读;事务里还留着注释「统一发布路径暂不解析共创声明……派生作品走旧两步路径」,但旧两步路径已按 ADR 删除。**注意**:review 说它们「validated in `validate_game_metadata`」并不准确,该函数只校验标题 / 分类等,根本不看 fork。 - 问题:AGC 从 `.agent/fork-source.json` 读出的改编声明、以及母版请求的 `full` 档位都会被丢掉:作品行落成 `forbidden`、没有血缘行,冻结资料也不含它们。而现在没有任何 HTTP 路径能实现这些字段。 - 建议:在统一事务支持它们之前,先失败关闭——收到 `fork` 声明或非默认 `forkAuthorization` 时返回明确 400,而不是“成功但结果不同”。属于高优先级领域行为,需要你决定是补事务还是先拦。 - 无领域术语版:作者可以声明“我这个作品改编自别人的哪个版本”,也可以选“允许别人改编我”。新接口收下了这两个信息,却根本没往数据库里放,最后作品既没有来源记录、也不允许改编,但接口仍返回成功——客户端以为生效了。目前也没有别的入口能补救。稳妥做法是先直接报错说“暂不支持”,别假装成功。 - 你的问题(`GameMetadata` 里为什么有 `fork` / `fork_authorization`?是不是该放进 `ForkMetadata`?):是,这确实是建模错位。`GameMetadata` 描述的是「随版本冻结的展示资料」,而改编声明是**一次性的身份事实**、共创档位是**游戏级策略**,两者都不属于展示资料。现在的 `GameDistributionForkMetadata` 只装了 `{ parentGameId, parentVersionId }`(声明),档位另在一个字段上,概念被拆散又混进 `GameMetadata`。 - 我的判断:建议把「改编声明 + 从父作品继承的档位」收进发布请求上独立的 `fork: ForkMetadata`(内部含 `parentGameId` / `parentVersionId` / `forkAuthorization`),再让统一事务消费它写血缘行与档位;`GameMetadata` 只留展示字段。这是领域行为改动(要定「谁在什么时机能声明改编、档位继承与禁止收窄」),建议先补 / 改 ADR 与里程碑再动代码,因此本轮没有改,仍留给你。若决定短期先拦,则在统一发布入口对非默认 `fork` / `forkAuthorization` 失败关闭(400)。 - 处置(`125849512`):你的决定是先失败关闭;统一发布入口收到 `fork` 声明或非默认 `forkAuthorization` 返回 400,补事务支持需先补 ADR。 - [x] 34. `request_digest` 覆盖原始 `projectKey`,与规范化的自然键口径不一致。 - 现状:自然键用 `canonical_project_key`(优先作品行已存值),摘要却对原始 `NewGameVersionRequest` 序列化(`projectKey` 缺失时还会变成 `"projectKey":null`)。 - 问题:`POST /games`(带 projectKey)与 `POST /games/{game_id}/versions/1`(省略 projectKey)命中同一自然键但摘要不同,模块返回 `请求摘要不一致` → 409,破坏“同版本重发即重放”的文档语义。 - 建议:对规范化后的 payload 取摘要(把 `project_key` 归一到 `canonical_project_key`,或统一从摘要里剔除它)。属幂等语义,留给你连同 15 一起定。 - 无领域术语版:系统用“内容指纹”判断两次请求是不是同一件事。但计算指纹时用的是一个可能被省略的字段原样值,而“防重复编号”用的是规范化后的值,两者对不上。于是同一件事换个写法提交,会被误判成“内容不一样”而拒绝。统一成规范化后的内容再算指纹即可。 - 你的问题(`/games/{game_id}/versions/{n}` 本该完全不认识 `projectKey`,现在到底怎么用的?):现状是更新路径仍允许(但不要求)请求带 `projectKey`,只在「路径 gameId 指向已软删 / 不存在、且该 gameId 恰好等于请求 `projectKey` 的确定性身份」时才用它走复活;自然键与作品行的权威值都来自作品行已存的 `projectKey`。真正的不一致是摘要仍按请求原值算。 - 处置(`125849512`):你的决定是不采纳摘要归一、并直接删掉凭 `projectKey` 复活 / 认领的参数。最终:路径端点 body 出现 `projectKey` 直接 400;幂等摘要按请求原样序列化(删除 `publish_digest_payload`);模块删除 `(owner, project_key)` 兜底查找;软删复活只发生在 `POST /games`(同一派生 `gameId`),复活不再读写 `project_key`。 - 后续(`4c7ab9934`):按你「从类型上彻底拆开」的决定,把两条路由的请求类型分开——追加版本用 `NewGameVersionRequest`(类型上没有 `projectKey`),新作品首版用新增的 `NewGameRequest`(`projectKey` + `#[serde(flatten)]`/`#[ts(flatten)]` 的版本内容)。`projectKey` 从「可选字段 + 入口拒绝」变成「类型上只属于创建路由」;路径端点仍对原始 body 显式 400。摘要在类型上自然只覆盖版本内容,与身份锚彻底解耦。 - [x] 35. 统一发布事务插入新版本前,没有取消同 `(game_id, version_number)` 的 `pending_review` 旧版本。 - 现状:旧 `create_game_distribution_version_tx` 会把同号待审行置为 `cancelled`、`review_reason = "被同一项目版本的新提交替代"`;统一事务没有这段。 - 问题:作者对版本 N 送审后再重发同号 N(新 version_id),旧行仍是 `pending_review`、仍在后台审核队列里,管理员可能批准旧内容。 - 建议:在插入前镜像旧逻辑补上取消循环。属核心发布事务行为,建议你改并补模块测试。 - 无领域术语版:同一个版本号被重新提交后,前一次还在排队等审核的记录没有作废,管理员可能把旧内容审过并上线。修法是在插入新记录前,先把同号的旧待审记录标记为“已被替代”。 - 处置(`80a801c16`):在 `publish_game_distribution_version_tx` 插入新版本前镜像旧两步路径的取消循环(`cancelled` + `review_reason = "被同一项目版本的新提交替代"`)。 - [x] 36. `publish_game_distribution_version_tx` 把 `required_game_distribution_text` 的 trim 结果丢掉,继续用原始 `input.game_id`。(**你的结论:不采纳**) - 现状:`let _game_id = required(...)`,后续查找 / 插入 / 媒体前缀都用 `input.game_id`;旧事务则绑定 trim 后的值。 - 问题:带首尾空格的 `game_id` 能通过校验,却按未规范化 id 存 / 查,两条路径行为分叉。 - 建议:绑定 trim 后的值并全程使用,或明确文档化「game_id 必须已规范化」。 - 无领域术语版:校验函数会顺手去掉编号前后的空格并返回干净值,但代码把干净值扔了,继续用带空格的原值去查和存。一旦真有人传带空格的编号,就会出现“校验通过但存进去的编号不干净”。把返回值用起来即可。 - 处置(`125849512`):你的决定是不要保留这个 trim;`publish_game_distribution_version_tx` 直接删除对 `game_id` 的 `required_game_distribution_text` 调用,`game_id` 由服务端生成、无 trim 来源,也不再出现「校验后丢弃结果」。 - [x] 37. `gamePublishDraft.isDraft` 要求 `gameId` 与 `versionId` 同时存在或同时缺失。 - 现状:`hasGameId === hasVersionId`,并有测试钉住「只有 versionId 的半写草稿不可恢复」。 - 问题:update 模式在发请求前就写 `gameId`、`versionId` 要等响应;理论上会留下“有 gameId 没 versionId”的草稿,被 `readPublishDraft` 丢弃。不过 update 模式的页面本来就不从草稿恢复版本号(它按 `updateGameId` 重新读线上),所以 review 把“版本号 +1 建出第二个版本”完全归因于这里并不准确;而配对校验是**有意为之且有测试**的。是否要放宽,需要你结合恢复路径整体确认(尤其 `projectKey` 会不会被重新生成、进而让摘要变化导致 409)。 - 建议:先确认 update 模式的恢复链路到底用不用这份草稿;若用,再放宽配对并补测试,别只删校验。 - 无领域术语版:草稿里有两个由服务器分配的编号,代码要求它们要么都有、要么都没有。但“更新已有作品”时,其中一个编号会在请求前就先存进去,另一个要等服务器回执。review 认为这会导致草稿被当成损坏而丢掉。不过“更新”页面实际上不靠这份草稿决定版本号,所以结论没这么直接。改之前先确认这条恢复路径是否真的用到草稿。 - 你的问题(为什么「更新已有作品」会改本地草稿?“恢复链路”是什么?):`submitGamePublish` 在**发请求之前**就写草稿(`gamePublishSubmission.ts` 先落自然键再发请求,响应丢失后重试才能命中同一版本);更新模式此时会写入 `gameId`,`versionId` 要等响应,于是留下「有 gameId 没 versionId」的正常中间态。“恢复链路”指 `readPublishDraft` → `resolveOwnedPublishDraft` → `resolveDraftIdentity`(提交时复用草稿冻结的 `versionNumber`),以及 `GamePublishPage` 的草稿回读 effect。 - 核实结论:`GamePublishPage` 的更新模式**不**从草稿恢复版本号(`if (updateGameId) return`),但 `submitGamePublish` 会读草稿复用冻结的 `versionNumber`;`projectKey` 在更新模式不会进请求体(`buildPublishVersionRequest` 只在 create 带),所以不会因重新生成 `projectKey` 让摘要漂移。 - 处置(`f1c864309`):`isDraft` 只校验各 id 的类型、不再要求配对;补「gameId-only 草稿可恢复」与「更新模式沿用冻结版本号」两条回归测试。配合 #34 的摘要归一化,响应丢失后重载会重放原版本而不是新建第二个。 - 你的追问(「有 gameId 没 versionId」是指新作品端点吗?草稿为什么不学后端:新作品用 projectKey 当 key、新版本用 (gameId, versionNumber) 当 key?):不是新作品端点,是**追加版本**(`POST /games/{game_id}/versions/{n}`)。新作品请求发出前 gameId/versionId 都没有(只有 projectKey),响应回来才一起回填;追加版本请求前就有 gameId(路径身份),versionId 要等响应,所以正常中间态就是「有 gameId、没 versionId」。`submitGamePublish` 也确实按后端自然键组织草稿:create 用 `projectKey` + 冻结的 `versionNumber`(固定 `1`),update 用 `gameId` + 冻结的 `versionNumber`。唯一冗余是 `GamePublishDraft.projectKey` 是必填字段,update 模式没有匹配草稿时会生成一个**从不进请求体**的随机 `projectKey`。是否把草稿类型也拆成 `create`(projectKey)| `update`(gameId)的 discriminated union、让 update 不再生成 projectKey——等你确认后我再动。 - [x] 38. `updateGameForkAuthorization` 用「整份资料重发」来实现只改一个档位。(已补文档警告;你提议的「全字段可选 PATCH」见下方评估,未做) - 现状:从 `GameDistributionGame` 投影拼出完整 `GameDistributionUpdateGameMetadataRequest`,`screenshots ?? []`、`coverObjectKey ?? null`。 - 问题:只有调用方传的投影携带完整当前资料时才正确;传了裁剪过的投影(例如列表项没带 screenshots)就会把截图清空,老作品没封面时还会因「必须提供封面」而失败。 - 建议:显式文档化「必须传完整投影」,或给授权单独设计更窄的请求形状。当前 owner 投影是完整的,所以今天能跑通。 - 无领域术语版:本来只想改“是否允许别人改编”这一个开关,代码却把整份作品资料读出来、原样再提交一遍。只要读到的资料是全的就没问题;一旦哪次拿到的是精简版(比如少了截图),就会把截图一并清掉。要么写清楚“必须拿全量资料来调”,要么给这个开关单独开个只改一项的接口。 - 处置(`80a801c16`):已在 `updateGameForkAuthorization` 文档写明「必须传当前资料的完整投影」,并说明缺 `screenshots` / `coverObjectKey` 的后果。 - 你的提议(把 PATCH 每个字段做成可选,表达更细粒度的 patch)评估:方向对,但比看起来难,暂未做,原因有三——(1) 现有 `description` / `coverObjectKey` 用 `Option<T>`,「没带」与「显式 null」反序列化后不可区分,要真正细粒度得引入 `Option<Option<T>>` 之类语义;(2) 媒体「不改」与「清空」也会撞车(`screenshots: []` 到底是不改还是清空);(3) 幂等摘要、修订号 CAS 与模块 `update_game_distribution_game_metadata_tx` 的整行覆盖都要改成「按提供字段合并」。更小、更稳的替代是给档位单独开一条窄入口(`PATCH .../fork-authorization`,只收 `expectedPublicationRevision` + `forkAuthorization`),`updateGameForkAuthorization` 改用它;在本轮 rename/refactor 授权范围内我没有擅自改公开 DTO 形状,留给你定。 - 结论(你的批复 + 实际落地):上面 3 条理由作废,按你说的「just like `bb90ad588`」直接走通用细粒度 PATCH。(1) 清空不用 `null`,统一用 `""`——`ab4244e98` 已把 `GameDistributionUpdateGameMetadataRequest.description` / `coverObjectKey` 的 TS 契约去掉 `| null`(`description: ""` = 清空,`coverObjectKey` 不允许清空,缺省 = 不动);(2) 媒体语义已是「覆盖」(缺省 = 不动、`[]` = 清空),不存在「不改 vs 清空」撞车;(3) 幂等摘要 / `expectedPublicationRevision` CAS / 模块按 `Some` 合并已由 `bb90ad588` 落地,复杂度可接受。`bb90ad588` 同时把 `updateGameForkAuthorization` 改成只提交 `{ expectedPublicationRevision, forkAuthorization }`,不再重拼整份投影;窄入口 `PATCH .../fork-authorization` 不再需要。 - [x] 39. 网页发布用 `latestVersion` 推下一个版本号,而不是最大 `versionNumber`。 - 现状:`GamePublishPage` 取 `game.latestVersion?.versionNumber + 1`;服务端 `versions` 按 `updated_at desc` 排序并截断到 10 条,所以 `latestVersion` 是“最近被改动的版本”。 - 问题:与 ADR「`max(versionNumber) + 1`」不符;同号重提、或作品超过 10 个版本时,可能算出旧号 / 撞号。**注意**:review 说 `listMyGames()` 返回完整 `versions` 也不准确(同样被截断到 10 条),所以它的改法不能完全解决问题。 - 建议:正确做法是让服务端在作者投影里直接给出权威的 `nextVersionNumber`(AGC 侧已有类似 `resolve_owner_next_version_number`),网页端消费它;仅在客户端取 max 仍会受截断影响。 - 无领域术语版:发布新版本前要算“下一个版本号”,网页现在按“最近改动的那个版本号加一”来算。但“最近改动”不等于“号码最大”:把老版本重新提交一次,就会算错。更糟的是接口只返回最近 10 条版本记录,客户端自己找最大值也不可靠。最好让服务器直接告诉客户端“下一个该用几号”。 - 你的结论:不要服务端算好的 `nextVersionNumber`,而是下发权威的 `latestVersionNumber`(全部版本最大号)让客户端自己 `+1`。 - 处置(`80a801c16`):`GameDistributionOwnerGameSnapshot` 新增 `latest_version_number`(截断前取全量 `max(version_number)`),贯通生成绑定、`spacetime-client::GameDistributionOwnerGameRecord`、`owner_game_entry_payload` 的 `latestVersionNumber`;网页 `GamePublishPage` / `GameDistributionMyGame` 与 AGC `resolve_owner_next_version_number` 都优先读它再 `+1`,旧服务端缺键时回退到已下发版本取最大(AGC)或预填版本号(网页)。
k88936 added 37 commits 2026-10-07 21:03:45 +08:00
- 截图槽位改为「项目内相对路径或上一版本 objectKey」二选一:带发布媒体前缀按 key 沿用,其余非空路径上传新图;退役「空数组 = 整组沿用线上」
- 发布成功后回读最新版本冻结资料,把草稿里的本地截图路径就地改写成 objectKey,下一次发布直接沿用、不重复上传
- 原生 screenshotPaths 只传本地路径那一组,objectKey 槽位留在 metadata.screenshots 里沿用
- 表单字段对照「线上值」改称「上一版本」:标签更深、取值更浅
- 「沿用」改为无边框下划线 + 向上箭头的内联文字动作,PaneButton 新增 link 语气
- objectKey 截图槽位按上一版本预览展示,拿不到预览时给占位;冲突卡与 agent 注册表示意同步
- Rust DTO 文档与 ts-rs 生成绑定同步;补 isGameDistributionMediaObjectKey、沿用与发布后改写单测
- resolve_version_number 删除 max_existing 与自增分支,只校验 versionNumber > 0
- 新增领域纯函数 game_distribution_version_key(自然幂等键)与 derive_game_distribution_game_id(确定性 gameId)
- GameDistributionCreateVersionInput、CreateVersionInput、CreateVersionRecordInput 的 versionNumber 改 u64 必填
- SpacetimeDB 删除「同号 pending 标 cancelled」,同号即同一版本身份
- local_project_id 全链路改名 project_key:两张表、shared-contracts、spacetime-client、api-server 与生成绑定
- 重新生成 module_bindings;migration.rs 与后端数据契约记录改名
- AGC 发布命令要求版本号必填;网页首次发布补 versionNumber=1、更新补最新版本 +1;packages/shared DTO versionNumber 必填
- 主规范、实施计划、里程碑与 pitfalls 同步 projectKey 口径
- 注:AGC 前端 gameDistributionPublish.ts 与 decision-log 的 projectKey 改名与并发中的陶泥儿槽位改动同文件,另随后续提交落地
- DirectProject 聊天头入口文案与 aria/title 由「导出/导出产物」改为「发布」,图标由 Package 换为 Rocket
- 悬停/聚焦时火箭图标 translateY(-2px) 轻微上移,160ms ease,并在 prefers-reduced-motion 下关闭
- 同步更新聊天头入口相关注释与 directProjectChatHeader 单测
- spacetime-module 新增 publish_game_distribution_version_and_return:一次 try_with_tx 内按 (owner, projectKey) get-or-create 作品行、写版本、写 create_version 收据,收据同时落 outcome_game_id/outcome_version_id
- 新增 GameDistributionPublishVersionInput 与 game_distribution_publish_result 复合结果
- spacetime-client 新增 publish_game_distribution_version 方法与记录输入,重新生成 module_bindings
- AGC publish_local_project_game 合并两次 POST 为一次 POST /api/game-distribution/versions,封面/截图只提交一遍
- AGC 移除已退役的 VERSION_NUMBER_CONFLICT 错误映射与专属测试
- 实施计划风险节记录确定性 gameId 下软删重发的已知边界
- api-server 新增 game_distribution_publish::publish_version 统一 handler:multipart metadata 必填 versionNumber,有 gameId 走 owner 校验、无则确定性派生 gameId,封面/截图只解析一次
- 删除 POST /games 与 POST /games/{gameId}/versions;自然幂等键 = (owner, projectKey | gameId, versionNumber),不读 Idempotency-Key
- shared-contracts 新增 GameDistributionPublishVersionRequest/Response,packages/shared 镜像并登记 parity 脚本
- 网页 publishGameVersion 单次调用替换 createGame + createGameVersion;新增 gamePublishSubmission 深模块承载「定 key → 发布 → 传包 → 送审」
- gamePublishDraft 在调接口前持久化 projectKey/versionNumber;响应丢失重试命中同一自然键,不新建游戏或版本
- 首次发布送 projectKey + versionNumber=1,更新送 gameId + 冻结 max+1;送审 CAS 用响应 game.publicationRevision
- 更新 web 单测、发布页测试、草稿夹具与 parity 门禁
- 网页发布服务 localProjectId 全部改名 projectKey,与共享契约、后端 project_key 列名对齐
- 网页发布单测与联调脚本同步改名
- 硬切不改兼容:模块未上线,无存量字段与调用方
- api-server 删除 VERSION_NUMBER_CONFLICT → 409 映射与常量,已无调用方的退役对象直接清理实现
- shared-contracts 删除无调用方的 GameDistributionCreateVersionRequest 及其单测,packages/shared 同步移除,parity 条目删除
- spacetime-module 删除 create_game_distribution_game_and_return / create_game_distribution_version_and_return 两个 procedure、对应 tx 与输入结构,以及只服务旧路径的 create_game 收据动作常量
- spacetime-client 删除两个旧 facade 方法与记录输入,重新生成绑定并补入 M2 新增的两个 procedure/input 文件
- 实施计划新增第 9 节实际提交与验证证据,里程碑标记已实现,decision-log/pitfalls 回写验证与清理结论
- 第 9 节里程碑表把 M5 占位「本次提交」替换为实际提交 8a38ce35a
- 模块按确定性 gameId 命中已软删行时就地复活覆盖为全新作品:清 deleted_at、回到未公开、publication_revision=0、清空活动版本与 play_count/price_mud_points、用本次资料覆盖作品级字段
- 复活时按 owner 索引删除该作品旧的 create_version 收据;发布事务收据命中但指向已软删作品时只删该收据并继续复活,旧版本号不再被当成重放或 409
- api-server 显式 gameId 命中不存在或已软删、且恰好等于该 projectKey 的确定性身份时按首次发布继续,其余不存在或非本身份的 gameId 仍 404
- 新增 spacetime-module 单测 revive_overwrites_soft_deleted_game_as_fresh_identity
- 同步玩法链路、后端数据契约、实施计划风险节与 decision-log/pitfalls
- 第 9 节里程碑表新增软删复活行 90f4e0cfb,记录复活覆盖与清旧收据
- M5 删除 VERSION_NUMBER_CONFLICT 后 crate 内 cargo fmt 重排 shared_contracts::game_distribution 导入
- resolve_publish_media 的沿用判定由「必须命中当前行媒体」放宽为「当前行媒体或该作品媒体命名空间内的历史媒体」
- 软删后重发时 current 为空,上一世代传过的 objectKey 仍能沿用,不必强制重新上传
- 同步玩法链路、后端数据契约与 pitfalls 的软删重发口径
- 聊天头发布按钮由透明描边改为 platform-button-primary-fill 实心填充,文字与火箭图标用反色 primary-text
- 悬停/聚焦由切 ghost 底色改为 brightness(1.05),保留实心观感
- 该模块只导出类型,按同仓库 type-only 约定改为 `export type *`,避免进入运行时模块图
- 与 contracts/index.ts 及其他 type-only 契约模块保持一致,改为 `export type *`
- 原 `reusedKey ? (reusedPreview ? img : span) : IconPreview` 违反禁止嵌套三元约定
- 改为显式 if/else if/else 赋值 ReactNode,与同文件 inputSlot 的写法一致
- 该命令结构体只在定义与 lib 再导出出现,spacetime-module 用的是自己的 GameDistributionPublishVersionInput
- 按「无现役调用方的退役对象直接清理」删除结构体、文档注释与再导出
- derive_game_distribution_game_id 原先直接哈希原始字节," proj-1 " 与 "proj-1" 会派生出两个身份
- 与 game_distribution_version_key 的 trim 口径对齐,并补单测断言两侧空白归一化
- 原先作品/版本任一缺失都报同一条「游戏发布结果」,排障时分不清缺的是哪一半
- 按 (Some,None)/(None,Some)/(None,None) 三种情况给出不同文案
- adoptOnlineScreenshots 与 rewriteScreenshotsToOnline 各自重复 map(trim).filter(非空)
- 抽成模块级 onlineScreenshotObjectKeys,两处共用同一口径,新增调用点不再复制
- isDraft 原先允许只有 gameId 或只有 versionId 的半写草稿,恢复流程会拿到不完整的版本标识
- 两个 id 由同一次发布响应一起回填,收紧为 hasGameId === hasVersionId
- 新增 gamePublishDraft.test.ts 覆盖都缺席/都回填/两种半写共 4 例
- 原先游戏/版本任一 owner 不匹配都回「游戏 owner 不匹配」,排障时指向错的对象
- 拆成两条独立校验,分别报「游戏 owner 不匹配」与「版本 owner 不匹配」
- handleSubmit 与 handleUpdateSubmit 原先逐字复制 压包→媒体→metadata→parts→submitGamePublish
- 抽成组件内 runUnifiedPublish(target, versionNumber, priceMudPoints),成功收尾仍各模式自理
- 新增字段或校验变化不再需要改两处
- 游戏发布媒体读地址改走内部前缀签名:platform-oss 新增 sign_agc_internal_object_url,object_read 引入 ObjectReadKeyScope,素材库仍用公开签名,发布媒体两条读路由改用 AgcInternal
- 平台错误把 OSS 原因写进顶层 message;AGC external_asset_http_detail 追加读取 error.details 的 provider/message/objectKey,不再只剩「请求参数不合法」
- native 草稿图片条目新增 previewError,换签失败不再并进空预览
- FormCard 对上一版本封面/截图渲染「预览读取失败:原因」,不再把读失败伪装成「(没有封面)」
- PaneButton 的 link 语气固定内联小尺寸并禁止换行,「沿用」动作更紧凑
- 补 platform-oss 内部前缀签名用例与陶泥儿换签失败显示原因用例,pitfalls 记录内部前缀签名口径
- 该 prop 语义早已是「打开发布面板」而非导出,onRequestExport 名不符实
- 全链路 5 个文件机械改名,按钮文案仍是「发布」,行为不变
- EmptyTab 默认文案「尚未接入」适合真的没实现的目标,灰度未命中却会误导成功能不存在
- EmptyTab 增加可选 message 覆盖文案;陶泥儿命中灰度把关时传「正在灰度中,暂未对你开放」
- 同步 exportGamePublishAvailability 断言并移除不再使用的 Target/TARGET_META 导入
- 发布响应 DTO 带回 coverObjectKey 与 screenshotObjectKeys:Rust CreatedGame 增加 cover_object_key/screenshots,GameDistributionPublishResult 增加 cover_object_key/screenshot_object_keys,TS GameDistributionPublishResult 同步补字段
- 草稿媒体改用发布响应回写:新增 rewritePublishedMediaToObjectKeys 取代 rewriteScreenshotsToOnline,封面/截图本地路径改写成平台返回的键,槽位数对不上或没给键就保持原值;发布成功先回读上一版本拿到换签预览再回写
- 封面槽位按前缀分流:buildTaonierPublishMetadata 认出 objectKey 时直接沿用、useTaonierTab 传空 coverPath,AGC resolve_publish_media 也按 GAME_DISTRIBUTION_MEDIA_PREFIX 认出回写键、不再当本地文件读
- 上一版本封面与截图预览从 object-cover 改为 object-contain 并补底色,当前槽位与上一版本走同一 OSS 来源、保持原图比例便于逐张对照;草稿封面是键时按同一 key 取上一版本换签地址,取不到也显示「已上传封面」
- 补测试:AGC resolve_publish_media_treats_a_rewritten_cover_object_key_as_reuse_not_a_local_file,taonierExport 覆盖封面/截图回写、已是键不重复改写与封面键分支
- handleResumeSubmit 原先用每会话随机的 resolvePublishKey(),与提交模块的 `${versionId}:upload`/`:submit` 不一致,刷新页面后重试无法被服务端去重
- 统一成同一组 versionId 派生键,并删除不再使用的 createPublishKey / publishKeyRef / publishKeySeed
- 补 GamePublishPage 断言恢复上传与送审确实发送 ${versionId}:upload / :submit
- get_or_create_game_distribution_game_for_publish_tx 的 (owner, project_key) 兜底分支补上暂停校验,与 game_id 命中分支同口径,避免换个身份写法就绕过暂停
- 抽出 ensure_game_distribution_game_publishable,去掉两处重复的 visibility 判断与错误文案
- 补模块单测覆盖暂停拒绝与未公开放行
- 提交模块先落草稿时,versionId 只在草稿 gameId 与本次目标 gameId 一致时才带走
- 避免从游戏 A 的未完成更新切到游戏 B 时,草稿仍带 A 的 versionId,恢复路径据此查到并重提 A 的版本
- 补单测覆盖「更新目标换成另一款游戏」的场景
- game_distribution_version_key 由「projectKey 优先、否则 gameId」改为 `{gameId}:{projectKey}:v{n}`,projectKey 缺失时留空段
- api-server 更新路径取作品行已存的 project_key 作锚,首次发布回退请求值,两种身份写法收敛到同一个键,堵住换写法重复建版本的潜在路径
- 抽出 resolve_publish_project_key 并补单测:三元组稳定性 + 行内值优先于请求值
- 同键不同请求摘要仍按「幂等键对应的请求摘要不一致」拒绝,不会静默重复建版本
- shared-contracts:GameDistributionCreateGameRequest 改名 GameMetadata 并删掉 project_key(身份字段只留外层),统一发布请求改名 NewGameVersionRequest
- GameMetadata 用 ts-rs 生成 TS 绑定到 generated/GameMetadata.ts,TS 侧删除手写镜像;GameDistributionPublishVersionRequest 改名 NewGameVersionRequest 并引用生成类型
- AGC 命令把 projectKey 拆成独立参数,gameMetadata 不再重复携带;网页/AGC 调用点同步改名
- parity 脚本登记 NewGameVersionRequest;GameMetadata 交给 check:generated-bindings 逐字节校验,不再手写字段清单
- 发布成功后进度弹窗保持 running(“正在回读线上媒体…”),等 refreshOnline 与 rewritePublishedMediaToObjectKeys 都完成再关闭
- 弹窗开着时页面不可操作,用户无法在 await 窗口增删换截图,回读后的回写不会静默覆盖这些编辑(#17)
- 回读失败单独 catch,不把已经成功的发布误报成“发布失败”
- differing 改用原始值比较:截图排序后逐槽比较,同一组图换顺序不再算冲突;不再用 formatFieldValue 比较,避免不同 objectKey 被折叠成「沿用上一版本」而漏判真实冲突
- 图片字段(封面 / 截图)直接渲染缩略图:项目内路径走 IconPreview,上一版本 objectKey 按换签地址显示,不再显示路径文本
- ConflictCard 新增 projectPath / onlineCover / onlineScreenshots 入参,ArtifactsPane 透传
Merge remote-tracking branch 'origin/master' into style/polish-taonier-publish
Project CI / Backend tests (pull_request) Failing after 40s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m46s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m39s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m57s
Project CI / Repository checks (pull_request) Failing after 1m32s
Project CI / Frontend tests (pull_request) Successful in 2m27s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m38s
Project CI / Native shell tests (pull_request) Successful in 9m10s
e99b473f9a
# Conflicts:
#	docs/project-memory/shared-memory/pitfalls.md
Author
Member

我们在大量的冲突中发现了少量pr

我们在大量的冲突中发现了少量pr
k88936 changed title from WIP: Style/优化陶泥平台发布 to WIP: refactor/优化陶泥平台发布 2026-10-07 23:33:51 +08:00
k88936 added 23 commits 2026-10-08 09:28:14 +08:00
- 保留统一 POST /api/game-distribution/versions 与旧两步 POST /games、POST /games/{gameId}/versions 两支发布路径并存。
- GameMetadata 恢复 forkAuthorization/fork;fork 标为可选,渲染层不提交,改由原生链路从项目内 .agent/fork-source.json 读取。
- NewGameVersionRequest 增加可选 changeSummary,统一发布事务按血缘行判定衍生后校验并落库,母版忽略;网页 gamePublishSubmission 与 AGC 原生发布均透传。
- 恢复 resolve_version_number(max_existing, requested) 与 VersionNumberExhausted,以及旧两步 procedure、路由、DTO 与 spacetime-client facade 方法。
- 恢复 GAME_DISTRIBUTION_ACTION_CREATE_GAME 常量与旧 create_version 收据 action 常量。
- 恢复 GameDistributionCreateGameRequest、GameDistributionCreateVersionRequest 与 From<&...> for GameMetadata,并在 DTO parity 中登记两组。
- local_project_id → project_key 为模块未上线前的硬切改名,继续按 SPACETIME_SCHEMA_GUARD_ALLOW_BREAKING=1 走窗口。
- 同步 decision-log 合并处理记录。
- 验证:cargo check --workspace --all-targets;cargo test -p module-game-distribution、shared-contracts、spacetime-client、spacetime-module game_distribution、api-server game_distribution;AGC cargo test -- game_fork;check:generated-bindings、check:game-distribution-dto-parity、check:spacetime-schema、check:encoding、git diff --check;网页与 AGC 定向 vitest 全绿。
- 新增 ADR:对外版本身份改用客户端冻结的 versionNumber,version_id 保留为 SpacetimeDB 内部主键,clean cut 不迁移/不兜底。
- 记录完整端点表:POST /games(新作品+首版)与 /games/{game_id}/versions/{version_number} 全生命周期;后台族仍用 version_id。
- 记录 GitHub 术语对齐改名(derived→forks、lineage→network、contribution→network/stats、fork-source→source、project-bundle→source)及对应 DTO/记录类型改名清单。
- 记录 ts-rs 生成绑定范围、fork 字段可写位置与 projectKey 语义/必填状态。
- docs/README.md 登记该 ADR 入口。
- decision-log 追加 2026-10-07 条目:ADR 决策、路由拓扑反转(POST /games + /games/{game_id}/versions/{version_number})、clean cut 删除清单。
- 记录 GitHub 术语改名与 DTO/记录类型改名、ts-rs 类型收敛、fork 字段可写位置、AGC 本地绑定与 projectKey 语义、新增 (game_id, version_number) 索引与落地顺序。
- 玩法链路新增「游戏分发发布面 clean cut 与术语对齐合同(2026-10-07)」:对外端点表、版本身份(versionNumber 为 URL 身份、version_id 为内部主键、新增 (game_id, version_number) 索引)、projectKey 语义与必填状态、GitHub 术语改名、DTO/记录改名表、ts-rs 生成规则与 clean cut 删除清单。
- 旧「游戏分发统一发布接口与版本号自然幂等合同」的对外路由拓扑标为 superseded,版本号必填与自然幂等键口径保留。
- 新增【里程碑】与【实施计划】游戏分发发布面 clean cut 与术语对齐:M1 类型改名与 ts-rs、M2 索引与按号查找、M3 路由收敛(新代码拆小文件)、M4 客户端换路径、M5 收口,含逐文件范围与门禁命令。
- 修正旧路由表:POST /versions 与 /versions/{versionId} 族改为 POST /games 与 /games/{gameId}/versions/{versionNumber}。
- ADR 补齐 GameDistributionLineage/Record 改名与 lineage→network 字段改口径;docs/README.md 登记新合同并标注旧契约被取代。
- GameDistributionLineage 只改类型名为 GameDistributionNetwork,公开读面的 JSON 字段名保持 lineage,避免破坏网页读面。
- 同步 ADR、玩法链路合同与实施计划的风险说明。
- shared-contracts:GameDistributionDerivedResponse→GameDistributionForksResponse、GameDistributionDerivedGamesRecord→GameDistributionForksRecord、GameDistributionLineage→GameDistributionNetwork、GameDistributionLineage{Response,Node,Record,NodeRecord,TreeRecord}→GameDistributionNetwork*。
- TS 手写契约与读面(gameDistributionClient、GameLineagePage、LineageRelationLists、GameDetailPage、MyGamesPage)同步改名;JSON 字段名 lineage 保持不变。
- spacetime-client facade/mapper 记录同步改名;parity 注册表登记新名;handler get_game_derived_games→get_game_forks、get_game_lineage→get_game_network。
- module_bindings 与 spacetime-module 的 game_distribution_lineage 表行类型不动:表名与生成绑定不参与 DTO 改名,只按词边界改手写文件。
- 按 cargo fmt 结果重整导入顺序与函数签名换行。
- GameDistributionContribution{Totals,Generation,Child,Response} 与对应 *Record 全部改名 GameDistributionNetworkStats*。
- spacetime-client facade/mapper 与 parity 注册表同步;handler get_game_contribution→get_game_network_stats。
- 路由路径与 JSON 字段名本步不动,路由收敛在 M3 统一处理。
- GameDistributionForkSourceKind/ForkSource/ForkSourceResponse/ForkSourceRecord 全部改名 GameDistributionSource*(kind=package|project 与 JSON 字段 forkSource 不动)。
- GameDistributionForkDeclaration→GameDistributionForkMetadata;AGC game_fork/desktop.rs 与 game_distribution_publish.rs 同步。
- api-server handler get_fork_source{,_package,_project}→get_source{,_package,_project};parity 注册表登记新名。
- 路由路径 /fork-source 保持不变,路径收敛在 M3 统一处理。
- 记录 forks/network、contribution→network/stats、fork-source→source 三步提交与对应门禁结果。
- 标注 M1 ts-rs 生成待下一轮,并说明 GameDistributionPublishVersionResponse 的 TS 级联依赖。
- shared-contracts 的 GameDistributionGameSummary.lineage 与 TS 契约同名字段各留 TODO(rename-refactor)。
- 待网页读面 .lineage 用法与测试夹具同批切换后,把该 JSON 字段改名 network(公开契约破坏性改名)。
- 实施计划同步标注该待办。
- 明确规则:路径里有 {game_id}/{version_number} 时,body 不得重复同名字段;handler 只从 Path 取值,不存在“路径优先还是 body 优先”的校验分支。
- NewGameVersionRequest 删除 gameId 与 versionNumber:POST /games 只带 projectKey、首版号固定 1;嵌套路由身份全在路径。
- 复核其余写端点(submit/cancel/purchase/reviews/theme members/admin review/fork-authorization)请求体均不含路径身份字段;唯二重复的是待删 legacy GameDistributionCreateVersionRequest.versionNumber。
- 端点表 Response 列同步到改名后的新 DTO 名;clean cut 删除清单补 GameDistributionCreateGameRequest / GameDistributionCreateVersionRequest。
- game_distribution_version 追加 by_game_distribution_version_game_and_number 多列 btree 索引,把按号查找收窄到单个作品。
- 新增 GameDistributionVersionNumberLookupInput 与 get_game_distribution_version_by_number_and_return:对外 {version_number} 身份由 Path 提供,不必先知道 version_id;旧数据同号多行时取 created_at 最新一行。
- spacetime-client 暴露 get_game_distribution_version_by_number(game_id, version_number),并同步新生成绑定(2 个文件)。
- 只追加索引与 procedure,未改任何表字段;check:spacetime-schema 在 SPACETIME_SCHEMA_GUARD_ALLOW_BREAKING=1 下通过(字段告警是分支上既有的 local_project_id→project_key 改名,与本次无关)。
- §4 恢复只服务旧两步的 DTO 删除清单:GameDistributionCreateGameRequest / GameDistributionCreateVersionRequest / GameDistributionSetForkAuthorizationRequest
- 不保留兼容路由与旧 DTO 双写,历史由 Git 保存
- 删除统一 POST /versions 与旧两步 POST /games/{game_id}/versions,改由 POST /games(新作品 + 首版,首版号固定 1)与 POST /games/{game_id}/versions/{version_number}(追加 / 重放)承担
- NewGameVersionRequest 去掉 gameId / versionNumber:身份只在路径,body 不重复路径参数
- 删除 GameDistributionCreateGameRequest / GameDistributionCreateVersionRequest 及 From<..> for GameMetadata,并同步 DTO parity 注册表
- 作者版本族路由(package / project-bundle / submit / cancel / 版本详情)迁到 {game_id}/versions/{version_number},按 (game_id, version_number) 取最新一条并校验归属
- 删除只服务旧两步的 validate_game_creation_metadata / validate_legacy_version_declaration / normalize_local_project_id
- web 客户端 publishGameVersion / getGameVersion / cancelGameVersion / submitGameVersion / uploadGamePackage 改按新路径签名,调用方与测试同步
- 同步 packages/shared 手写契约与 api-server 路由挂载测试
- 删除 PUT /games/{game_id}/fork-authorization 与 GameDistributionSetForkAuthorizationRequest,档位并入 PATCH /my-games/{game_id}(forkAuthorization 可选,缺省不动档位)
- 资料 PATCH 在 metadata 事务成功后串行调用提升事务(派生幂等键 {key}:fork,自有收据可重放),只升不降与衍生终态仍由同一领域纯函数裁决
- web 客户端 updateGameForkAuthorization 改为用当前投影资料组装资料 PATCH,去掉 expectedForkAuthorization 入参
- 公开读路由改名:/lineage→/network、/derived→/forks、/contribution→/network/stats、/fork-source→/source(含 /package /project),同步取件 downloadUrl 与 AGC 取件 URL
- 同步 packages/shared 契约、DTO parity 注册表、生成绑定注释与测试
发布写请求改为新作品 POST /games、追加版本 POST /games/{game_id}/versions/{version_number},body 去掉 gameId 与 versionNumber
冻结资料回读改用 GET /games/{game_id}/versions/{version_number}
送审与发行包 / 工程源包分片续传 URL 统一到版本路径,上传请求增加 game_id 与 version_number
gameDistributionPublishLive 契约测试跟随新路由,发布响应按 {game, version} 解析
GameDistributionForkMetadata / GameDistributionNetworkNode / GameDistributionForksResponse 增加 ts-rs 导出
GameDistributionUpdateGameMetadataRequest / NewGameVersionRequest 增加 ts-rs 导出,字段按可空语义标注
packages/shared 删除 5 份手写镜像,改为从 contracts/generated 再导出
DTO 对齐检查把这 5 个类型移入 GENERATED_RUST_TYPES,结构一致性交给 check:generated-bindings
ADR 修订记录回写:版本级上行保留 project-bundle、PATCH .../versions/{version_number} 未落地、血缘与发布 DTO 改由 ts-rs 生成
玩法链路与技术方案文档的路由表、授权 PATCH、DTO 名同步到新口径,旧段落标注为历史记录
数据契约文档改写 fork-authorization、统一发布接口与 /lineage 段落
共享记忆 decision-log 修正 project-bundle 与 ts-rs 清单,并回写本轮落地证据
实施计划 §9 回填 M1 ts-rs / M2 / M3 / M4 / M5 证据,里程碑标记已实现
记录发布 DTO 同时带路径参数与 body 身份字段导致第二个真源的问题
写明现行口径:handler 只从 Path 取值,body 不重复 gameId / versionNumber
Pingora MAIN_SPA_PATHS 与三份 nginx 模板补上合并后新增的 /games/co-creation
修好 check:pingora-route-parity 与 check:nginx-spa-routes 的路由缺项
删掉仍写着「合并成一次 POST /versions / gameId 有则带」的两行旧注释
修复 gamePublishDraft 测试的 import 排序
Project CI / Backend tests (pull_request) Failing after 26s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 6m14s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 7m12s
Project CI / Repository checks (pull_request) Failing after 18s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m27s
Project CI / Frontend tests (pull_request) Successful in 2m23s
Project CI / Native shell tests (pull_request) Successful in 9m7s
Project CI / AI game creator shell web tests (pull_request) Failing after 2m32s
2b04743deb
跑 eslint --fix 收敛既有 simple-import-sort/imports 报错,让 lint:eslint 对跟踪文件恢复绿色
k88936 added 27 commits 2026-10-08 13:45:02 +08:00
解决 game_distribution_publish.rs 冲突:保留本分支统一发布重构,并移植 master 的 Rust warning 门禁修复(OwnerGameEntry.project_key 加 #[cfg(test)],注释改为仅测试态编译)
解决 gameDistributionPublish.test.ts 冲突:保留本分支断言(metadata.coverObjectKey 期望 null),不采用 master 的 'generated/cover.png'
核对 pitfalls.md 冲突:保留本分支 owner 媒体按作品命名空间判定与 projectKey 口径,并入 master 新增条目
- :focus-visible 保留 brightness 反馈并补 2px 可见 outline 与 2px 偏移
- hover 与 focus-visible 拆成独立规则,避免 outline:none 覆盖焦点环
- isSameFieldValue 对数组改为按槽位逐个比较,不再排序后比较
- 补充「只换截图顺序也算项目改动」的回归测试
- useTaonierExport 导出 findOnlineImage(objectKey, cover, screenshots)
- ConflictCard 的 ImageThumb 改用该 helper,去掉重复的 cover→screenshots 回退查找
- 新增 OnlineImageSlot 统一「有图 / 读失败 / 占位」三态渲染
- coverOnlineImage 改用 findOnlineImage,封面预览改用 if/else 提前返回
- 上一版本封面与截图预览复用同一组件,不再各自写嵌套三元
- 该按钮只在已读到上一版本截图时渲染,原 disabled 判断恒为 false
- 改为逐槽比较草稿与上一版本 objectKey,相同即停用,避免重复标 dirty
- 补充相同停用 / 不同可用并触发沿用的测试
- buildTaonierPublishMetadata 用显式变量与 if/else 表达封面三态
  (空=沿用 / objectKey=沿用键 / 其余=本地图)
- refreshOnline 内部吞掉所有失败、不 reject,原 catch 为死代码
- 去掉 try/catch,保留「换项目不改写」与「同项目改写后收起进度」语义
- check-game-distribution-dto-parity 增加发布媒体 objectKey 前缀的逐字比对
- 前缀漂移会让 UI 把沿用键当本地路径(或反之),此前无门禁覆盖
- get_game_distribution_version_by_number_tx 的 max_by_key 改为 max_by
- created_at 相同再用 version_id 兜底成全序,同一查询不再随索引顺序返回不同版本
- 说明返回三元组第三个值的含义与自然幂等键、请求摘要的关系
- 字段名与 GameDistributionSource 类型不一致,待读面同批切换到 source
- 与 lineage 字段同样的 rename-refactor TODO 口径
- 按 prettier 收敛 import 与 fireEvent.click 换行
- 删除 project/bootstrap.rs 整模块及 project.rs 的 mod 与 glob 再导出
- command_exec.rs 删除 run_project_bootstrap_command_at,resolve_project_bootstrap_spec_at 按真实使用面收窄为 Linux 测试专用
- project/verification.rs 删除仅用例调用的薄包装 run_project_verification_with_commit_at 并修正 doc 链接
- game_package_upload 删除死常量 GAME_PACKAGE_UPLOAD_PROGRESS_EVENT 及其再导出
- game_package_upload 的 ensure_staging_path_in_dir 与 NpmScript.package_json_relative 按真实使用面收窄为 test-only
- export/draft/taonier 移除多余再导出 TaonierArtifactPackage 并删除无调用方的 read_form
- 前端 gamePackageUploadProgress.ts 注释不再指向已删的 Rust 常量
- scripts/warning-baseline.json 收缩:agc-windows 17→3、agc-linux 72→58
- 同步开发运维文档的告警存量记述
对外字段 `lineage` 改为 `network`、Fork 取件响应体 `forkSource` 改为 `source`(shared-contracts、api-server 手拼响应、TS 契约与读取方、DTO parity 脚本、相关文档)
作者侧投影新增 `latestVersionNumber`(全部版本最大号,不受列表 10 条截断影响),贯通 module 快照、spacetime-client 记录、api-server 手拼与生成绑定
网页发布与 AGC 更新发布改用 `latestVersionNumber` 推下一个版本号,不再按 `latestVersion`(最近更新)推
统一发布事务插入同号版本前先作废旧的 `pending_review` 行,与旧两步路径同一口径
AGC 发布资料封面/截图预览改为并发解析,保持截图槽位顺序
AGC 送审改用响应回执的权威版本号构造提交路径
补充软删作品复活即覆盖(`created_at` 一并重置)的模块注释与实施计划说明
补充 `updateGameForkAuthorization` 必须传完整投影的文档
取摘要前先把请求 `projectKey` 换成作品行已存的权威值,避免 `POST /games`(带 key)与 `POST /games/{game_id}/versions/{n}`(省略 key)身份写法不同导致同一逻辑发布重发时 409 摘要冲突
新增摘要跨 projectKey 来源一致的单元测试
`isDraft` 不再要求两个服务端 id 同时存在或缺失:更新模式在请求前只会冻结 `gameId`,`versionId` 要等响应,丢弃该草稿会在重载后把冻结版本号换成「线上最近版本 +1」而重发成第二个版本
补充草稿读取与更新模式沿用冻结版本号的回归测试
记录 `lineage` → `network`、`forkSource` → `source` 改名落地(不动 DB schema 字段)与作者侧 `latestVersionNumber` 决策
记录统一发布摘要按权威 projectKey 归一、同号旧待审行作废、发布草稿配对放宽与复活即覆盖语义
在 `NewGameVersionRequest.package_entry_path` 注明当前固定 `index.html`、契约刻意保持 `string`,避免把当前实现写死进公开 TS 类型
`ts(optional, type = "{ parentGameId: string, parentVersionId: string } | null")`,与 `forkAuthorization` 同写法,对齐服务端「null 与省略等价」
`readOnlineImageKey` 只读 `objectKey`:旧合同的 assetId 不是 OSS 键,沿用到草稿槽位后会被当成项目内本地路径导致发布读盘失败;迁移窗口结束,按 clean cut 移除
在 `get_or_create_game_distribution_game_for_publish_tx` 落库前校验 title/summary/category/orientation 非空,与旧 create_game 同口径,不假设 api-server 是唯一调用方
新增源码结构回归测试
在 `get_game_distribution_version_by_number_tx` join 作品行并拒绝 `deleted_at`,与旧 `get_owner_game_distribution_version_tx` 同口径:作品删除后其版本不能再上传 / 送审 / 撤回 / 读详情
新增源码结构回归测试
`get_or_create_game_distribution_game_for_publish_tx` 按 owner + projectKey 兜底命中时核对 `game_id` 相等,否则失败关闭,避免版本挂到与派生身份不一致的作品
软删复活不再用请求的 projectKey 覆盖既有作品的身份锚(请求不带时沿用原值)
新增源码结构回归测试
api-server 只在 `POST /games`(创建)路径把 `projectKey` 传给发布事务,路径端点一律传 `None`,避免版本行的 key 与作品行、自然键三方各说各话
自然幂等键只由 `(game_id, version_number)` 派生,只校验摘要非空;与生产统一发布「版本号即身份」口径一致,去掉无意义的 Idempotency-Key 必填
统一发布自然键与摘要去掉 projectKey
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
04f9d3df6c
自然幂等键只由 `(gameId, versionNumber)` 派生;摘要把 `projectKey` 归一成 `None`(它已体现在 `gameId` 里),不再依赖作品行回填的锚点,删除 `resolve_publish_project_key` 与对应测试
k88936 added 6 commits 2026-10-08 16:06:37 +08:00
`GameDistributionUpdateGameMetadataRequest` 除 CAS 外全部可选,缺省 = 不动该字段;服务端按当前作品行补全后校验,`description: ""` 表示清空
`GameDistributionUpdateMetadataInput` / `update_game_distribution_game_metadata_tx` 按 `Some` 合并字段,并在同一事务内裁决 `fork_authorization`(衍生终态 / 只升不降 / 以事务内当前档位为 CAS)
api-server `update_owner_game_metadata` 合并媒体与展示字段后只调用一个 procedure,删除派生键 `{Idempotency-Key}:fork` 的第二事务与 `game_metadata_update_as_create_request`
删除已无调用方的独立提升 procedure `set_game_distribution_fork_authorization_and_return`、`GameDistributionSetForkAuthorizationInput` 与客户端方法,同步重生成绑定
网页 `updateGameForkAuthorization` 只提交改动档位;`GameMetadata` 仍为必填(#18),局部更新走独立更新 DTO
本地 CLI 2.8.3 对 procedure 回调统一输出 16 空格缩进,仓库里仍有 31 个文件停留在 12 空格
本次整批重跑官方 codegen,让生成产物与当前 CLI 一致;纯缩进变化、无语义改动
路径端点 `POST /games/{game_id}/versions/{version_number}` 收到 body 里的 `projectKey` 直接 400,身份只从路径来;`projectKey` 只在 `POST /games` 作为身份锚
`get_or_create_game_distribution_game_for_publish_tx` 删除 `(owner, project_key)` 兜底查找,`project_key` 只写进本次新建的作品行,不再读取别行的 key 做身份匹配
软删复活不再读取或覆盖 `project_key`:身份锚在创建那一刻写定,复活沿用既有值;路径端点也不再凭 key 认领软删作品(不存在/已软删一律 404)
幂等摘要恢复为按请求原样序列化,删除 `publish_digest_payload` 归一 helper 与对应单测
`publish_game_distribution_version_tx` 去掉「校验 `game_id` 却丢掉 trim 结果」的分叉(`game_id` 由服务端生成,无需 trim)
统一发布入口对非默认 `fork` / `forkAuthorization` 失败关闭 400,不再 200 静默丢弃共创声明
后端数据契约:`projectKey` 只在 `POST /games` 写定、路径端点拒绝它;删除 `(owner, project_key)` 兜底与凭 key 复活分支,复活只在 `POST /games`;摘要按原样序列化;统一发布对非默认 `fork` / `forkAuthorization` 失败关闭 400
后端数据契约与共创技术方案:共创授权提升并入 `update_game_distribution_game_metadata_and_return` 同一事务,独立 procedure `set_game_distribution_fork_authorization_and_return` 与输入 DTO 删除
玩法链路:`projectKey` 表改为路径端点「禁止」(400);`POST /games` 注明无版本号入参、服务端固定写 `1`
共享记忆新增当日决策条目,覆盖 #16/#18/#20/#32/#33/#34/#36
- 发布请求拆类型(#34):`NewGameVersionRequest` 去掉 `projectKey`,新增 `NewGameRequest`(`projectKey` + flatten 的版本内容),两条路由各用各的类型。
- api-server 按目标反序列化成对应类型;路径端点仍对 body 里的 `projectKey` 直接 400,避免被 serde 静默忽略。
- 游戏分类(#2/#4):`GameMetadata.category` 与资料编辑请求的 `category` 由 `String` 改为 `GameDistributionCategory` 枚举,未知分类在 DTO 反序列化即被拒绝。
- ts-rs 新增 `generated/GameDistributionCategory.ts` 与 `generated/NewGameRequest.ts`;`GAME_DISTRIBUTION_CATEGORIES` 运行时数组用 `satisfies` + 双向条件类型断言与生成联合逐值对齐。
- DTO parity 脚本把 `GameDistributionCategory` 从 `TS_ONLY_TYPES` 移入 `GENERATED_RUST_TYPES`,并登记 `NewGameRequest`。
- web 发布链路:`publishGameVersion` 重载拆分,`buildVersionContent` / `buildNewGameRequest` 分开组包。
- 同步后端数据契约、玩法链路与 decision-log。
docs: 更新 ADR 路由与路径身份表,修订为 versionNumber-centric 口径
Project CI / Backend tests (pull_request) Failing after 39s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 8m20s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m48s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m50s
Project CI / Repository checks (pull_request) Failing after 44s
Project CI / Native shell tests (pull_request) Successful in 11m40s
Project CI / Frontend tests (pull_request) Successful in 4m39s
Project CI / AI game creator shell web tests (pull_request) Successful in 4m18s
4cb1d3c1e3
k88936 force-pushed style/polish-taonier-publish from 18cecd9c6b to 4cb1d3c1e3 2026-10-08 16:06:37 +08:00 Compare
k88936 added 1 commit 2026-10-08 16:24:53 +08:00
资料 PATCH 清空语义改用空串并给草稿身份留 TODO
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
ab4244e988
- `GameDistributionUpdateGameMetadataRequest.description` / `coverObjectKey` 去掉 TS 契约里的 `| null`:清空统一用 `description: ""`,`coverObjectKey` 不允许清空;缺省仍表示不动该字段。
- 同步 ts-rs 生成绑定与后端契约文档注释,删掉「显式 null = 不动」的旧口径。
- `GamePublishDraft` 补 TODO:草稿身份按后端自然键拆成 `create`(projectKey)/ `update`(gameId)的 discriminated union,本轮不做。
- decision-log 记录 #38 收口。
k88936 added 1 commit 2026-10-08 16:27:30 +08:00
Merge remote-tracking branch 'origin/master' into style/polish-taonier-publish
Project CI / Backend tests (pull_request) Failing after 54s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 7m25s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m47s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 7m48s
Project CI / Repository checks (pull_request) Failing after 1m42s
Project CI / Native shell tests (pull_request) Successful in 9m26s
Project CI / Frontend tests (pull_request) Successful in 3m31s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m21s
1ca01e1b33
# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
#	docs/project-memory/shared-memory/pitfalls.md
k88936 marked the pull request as ready for review 2026-10-08 16:28:12 +08:00
k88936 added 2 commits 2026-10-08 17:09:24 +08:00
Merge remote-tracking branch 'origin/master' into style/polish-taonier-publish
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
0529e07d9f
k88936 added 1 commit 2026-10-08 17:18:42 +08:00
同步 SpacetimeDB 生成绑定:表列名回退 local_project_id
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m47s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 6m27s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m9s
Project CI / Backend tests (pull_request) Successful in 8m26s
Project CI / Frontend tests (pull_request) Successful in 2m33s
Project CI / AI game creator shell web tests (pull_request) Successful in 4m9s
Project CI / Repository checks (pull_request) Failing after 5m48s
Project CI / Native shell tests (pull_request) Successful in 9m17s
81a92e6052
- 表结构体列名回退为历史的 `local_project_id`(SpacetimeDB 不允许改名既有列),重新生成 module_bindings。
- 仅 `game_distribution_game_type.rs` / `game_distribution_version_type.rs` 两个生成文件随之变化。
k88936 added 2 commits 2026-10-08 18:07:08 +08:00
- 修改 `coverObjectKey` 逻辑,`null` 值收敛为 `undefined`,从而省略字段以表示“不修改”。
- 注释中阐明新图上传的行为约定与契约设计。
Merge remote-tracking branch 'origin/master' into style/polish-taonier-publish2
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
c7ea0f2908
# Conflicts:
#	docs/【技术方案】游戏共创与作品Fork-2026-10-03.md
k88936 merged commit a3e65722dd into master 2026-10-08 18:07:42 +08:00
Sign in to join this conversation.