refactor/优化陶泥平台发布 #670
Reference in New Issue
Block a user
Delete Branch "style/polish-taonier-publish"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
close #668
参考github的结构重新组织了endpoint
/api/game-distribution/gamesmetadata=NewGameVersionRequest(JSON),cover/screenshot二进制 partGameDistributionPublishVersionResponseIdempotency-Key+ 发布灰度/api/game-distribution/games/{game_id}/versions/{version_number}GameDistributionPublishVersionResponseIdempotency-Key+ 发布灰度/api/game-distribution/games/{game_id}/versions/{version_number}GameDistributionPrivateVersion+ 作品上下文)/api/game-distribution/games/{game_id}/versions/{version_number}GameMetadata+expectedPublicationRevisionCASIdempotency-Key…/versions/{version_number}/packageapplication/octet-streamIdempotency-Key…/versions/{version_number}/package/upload-state…/versions/{version_number}/package/chunkapplication/octet-stream+ 偏移头…/versions/{version_number}/package/completeIdempotency-Key…/versions/{version_number}/package/reset…/versions/{version_number}/source(/upload-state/chunk/complete/reset)…/versions/{version_number}/submitexpectedPublicationRevisionIdempotency-Key…/versions/{version_number}/cancelCancelVersionRequestIdempotency-Key/api/game-distribution/my-games/{game_id}metadata=GameDistributionUpdateGameMetadataRequest(并入forkAuthorization)+ 媒体 part{ game, replayed }Idempotency-Key/api/game-distribution/my-games/{game_id}/api/game-distribution/games/{game_id}/forksGameDistributionDerivedResponse/derived改名)/api/game-distribution/games/{game_id}/networkGameDistributionLineageResponse(整棵改编树)/lineage改名)/api/game-distribution/games/{game_id}/source(/package/project)GameDistributionForkSourceResponse/ 二进制/api/game-distribution/games/{game_id}/network/statsGameDistributionContributionResponse(子树归集)close #673
- 玩法链路新增「游戏分发统一发布接口与版本号自然幂等合同(2026-10-07)」,取代旧的 POST /games 与 POST /games/{gameId}/versions 两条路由 - 玩法链路更新 versionNumber 必填且客户端冻结、同号同版本、幂等双口径、媒体只解析一次与 API 路由表 - 后端数据契约补充统一发布接口、自然幂等键、确定性 gameId 与单事务 procedure - AGC 实施计划更新发布媒体为单次 POST /versions 并冻结版本号 - decision-log 记录统一发布接口与版本号自然幂等决策 - pitfalls 记录首次发布封面/截图重复上传的现象、根因与现行口径1.
apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx:78-79(maintainability · low)onRequestExport,经DirectProjectChatBox、DirectProjectChatHeader、App.tsx一路透传,测试也按旧名断言。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'。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逐槽相等」的条件。属于交互行为变动,留给你。7.
server-rs/crates/shared-contracts/src/game_distribution_publish.rs:18-23(bug · medium)game_id与project_key同时非空;服务端不拒绝。publish_version用game_id定位游戏,但幂等锚game_distribution_version_key优先取projectKey。同时带两者时,版本写在gameId名下、收据却记成{projectKey}:v{n};之后真有一次同projectKey/versionNumber的首次发布会撞上这张收据(摘要不同 → 409,或错误重放)。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改名而来)。payload.project_key,嵌套值被忽略却不进冻结快照、只进摘要,导致「改嵌套值就摘要冲突」。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非空的空草稿做一次性迁移回填,而不是只靠用户点「沿用上一版本截图」。涉及历史草稿迁移,留给你。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-distribution32 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的输出比较。differing里对截图数组逐槽trim()比较,formatFieldValue只用于渲染Choice。属于冲突判定行为改动,留给你。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)GameDistributionPublishVersionInput与新 procedure,但module_bindings.rs未重新生成。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("游戏发布结果")。Option,合并后无法判断缺的是哪一半,排障变难。(Some,None)、(None,Some)、(None,None)给三种不同文案。c9190a26f。17.
.../export/state/useTaonierTab.ts:287-291(bug · medium)await taonier.refreshOnline(),回来后rewriteScreenshotsToOnline(refreshed)无条件把screenshotPaths覆盖成回读 key。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())+ 丢空值。aecb5a1ae(新增onlineScreenshotObjectKeys,taonierExport30 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「被同一项目版本的新提交替代」)。属于审核语义,留给你。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)required_game_distribution_text校验并 trimtitle/summary/category/orientation,新 insert 原样落库。7a710bb2b^的旧实现核对,旧create_game_distribution_game_tx与旧 api-server 都是input.title.clone()/payload.title.clone()原样透传,从未 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)不校验归属,会加载并可能重提旧游戏的版本。versionId。属于恢复行为修正,留给你。f96842960。只有持久化身份仍指向同一游戏时才附带旧草稿versionId,并补单测「更新目标换成另一款游戏时丢弃旧草稿的 versionId」。24.
src/components/game-distribution/gamePublishDraft.ts:45(bug · medium)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)projectKey/versionNumber;publish 阶段失败留下的草稿没有versionId,页面只在draftDetail可用时渲染恢复提示与清除按钮。versionId时才复用」——这条不建议照做:projectKey 在响应前落草稿,正是为「响应丢失后重试命中同一身份」服务,砍掉会破坏自然幂等。真正该补的是一个针对无versionId草稿的显式重置/放弃入口。属于产品/交互决定,留给你。versionId隐形草稿的显式重置 / 放弃入口暂不新增。27.
server-rs/crates/spacetime-client/src/module_bindings/publish_game_distribution_version_and_return_procedure.rs:24(bug · high)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)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)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 不匹配」。9440ef037(spacetime-module game_distribution7 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(GamePublishPage19 +gamePublishSubmission7 passed)。3cd6f2f11695db9087b823b397cf53498516buildTaonierPublishMetadata封面嵌套三元c800ddc19ce32f4a1b1bd0a3e0bpublish_game_distribution_version缺文档5f084d0cacatch7591b1f9dforkSource改名遗留无 TODOe2931ef66created_at相同时不确定07b3dbb4080a801c1680a801c16created_at的意图未写明(改为文档化「复活即覆盖」)80a801c16projectKeybc32e0b7epending_review80a801c16f1c86430980a801c16latestVersion推下一版本号80a801c16lineage对外字段改名network、forkSource改名source80a801c16详细处置
已自动修复
1. AGC 发布入口
:focus-visible只有filter: brightness(1.05)+outline: none,键盘焦点几乎不可见。.project-chat-publish-trigger的 hover 与 focus 共用一条规则,focus 时靠 5% 亮度差表示。outline: 2px solid var(--platform-button-primary-border)+outline-offset: 2px。提交3cd6f2f11。5.
FormCard.tsx里「沿用上一版本截图」的disabled={onlineScreenshots.length === 0}是死代码。showOnline && onlineScreenshots.length > 0分支内渲染,长度恒 > 0。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重复。useTaonierExport导出findOnlineImage(objectKey, cover, screenshots),ConflictCard的ImageThumb与FormCard封面预览统一改用它。提交f53498516。9.
buildTaonierPublishMetadata的coverObjectKey用嵌套三元表达三态。localCover ? (是 objectKey ? 用它 : null) : (online?.cover?.objectKey ?? null)。if。提交c800ddc19。11. AGC 的
GAME_DISTRIBUTION_MEDIA_OBJECT_KEY_PREFIX与 RustGAME_DISTRIBUTION_MEDIA_PREFIX是两个手写常量。"agc/project-snapshots/v1/game-distribution/media/";用它区分「沿用 objectKey」与「本地路径」。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 重复)。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解析封面 / 截图预览地址。await resolve_preview_fields。futures::future::join_all并发并保持顺序。注意原 review 示例里的zip_pairing并不存在,需要自己保留(object_key, asset_id)配对与规范化,属于需要小心的改动。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_txjoin 作品行并拒绝软删作品。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 担心老客户端会因此失败,但保留旧名字本身就违反这次“一刀切、不留旧包袱”的决定。除非确认线上真还有老客户端在跑,否则不改。
你的结论:不采纳,不加
localProjectIdserde alias。26. 原生发布送审仍用客户端传入的
version_number_text,而非服务端权威的version.version_number。version.version_number,只有 submit 用请求值。POST /games、服务端固定首版为 1;若哪天调用方传了非 1 的版本号,包传到了 v1,送审却打到/versions/{version_number_text}/submit而 404。当前调用方都传 1,所以暂未暴露。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。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 invalidate_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 = "被同一项目版本的新提交替代";统一事务没有这段。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 的半写草稿不可恢复」。gameId、versionId要等响应;理论上会留下“有 gameId 没 versionId”的草稿,被readPublishDraft丢弃。不过 update 模式的页面本来就不从草稿恢复版本号(它按updateGameId重新读线上),所以 review 把“版本号 +1 建出第二个版本”完全归因于这里并不准确;而配对校验是有意为之且有测试的。是否要放宽,需要你结合恢复路径整体确认(尤其projectKey会不会被重新生成、进而让摘要变化导致 409)。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 的摘要归一化,响应丢失后重载会重放原版本而不是新建第二个。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。80a801c16):已在updateGameForkAuthorization文档写明「必须传当前资料的完整投影」,并说明缺screenshots/coverObjectKey的后果。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 形状,留给你定。bb90ad588」直接走通用细粒度 PATCH。(1) 清空不用null,统一用""——ab4244e98已把GameDistributionUpdateGameMetadataRequest.description/coverObjectKey的 TS 契约去掉| null(description: ""= 清空,coverObjectKey不允许清空,缺省 = 不动);(2) 媒体语义已是「覆盖」(缺省 = 不动、[]= 清空),不存在「不改 vs 清空」撞车;(3) 幂等摘要 /expectedPublicationRevisionCAS / 模块按Some合并已由bb90ad588落地,复杂度可接受。bb90ad588同时把updateGameForkAuthorization改成只提交{ expectedPublicationRevision, forkAuthorization },不再重拼整份投影;窄入口PATCH .../fork-authorization不再需要。39. 网页发布用
latestVersion推下一个版本号,而不是最大versionNumber。GamePublishPage取game.latestVersion?.versionNumber + 1;服务端versions按updated_at desc排序并截断到 10 条,所以latestVersion是“最近被改动的版本”。max(versionNumber) + 1」不符;同号重提、或作品超过 10 个版本时,可能算出旧号 / 撞号。注意:review 说listMyGames()返回完整versions也不准确(同样被截断到 10 条),所以它的改法不能完全解决问题。nextVersionNumber(AGC 侧已有类似resolve_owner_next_version_number),网页端消费它;仅在客户端取 max 仍会受截断影响。nextVersionNumber,而是下发权威的latestVersionNumber(全部版本最大号)让客户端自己+1。80a801c16):GameDistributionOwnerGameSnapshot新增latest_version_number(截断前取全量max(version_number)),贯通生成绑定、spacetime-client::GameDistributionOwnerGameRecord、owner_game_entry_payload的latestVersionNumber;网页GamePublishPage/GameDistributionMyGame与 AGCresolve_owner_next_version_number都优先读它再+1,旧服务端缺键时回退到已下发版本取最大(AGC)或预填版本号(网页)。- 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 门禁- handleResumeSubmit 原先用每会话随机的 resolvePublishKey(),与提交模块的 `${versionId}:upload`/`:submit` 不一致,刷新页面后重试无法被服务端去重 - 统一成同一组 versionId 派生键,并删除不再使用的 createPublishKey / publishKeyRef / publishKeySeed - 补 GamePublishPage 断言恢复上传与送审确实发送 ${versionId}:upload / :submit- game_distribution_version_key 由「projectKey 优先、否则 gameId」改为 `{gameId}:{projectKey}:v{n}`,projectKey 缺失时留空段 - api-server 更新路径取作品行已存的 project_key 作锚,首次发布回退请求值,两种身份写法收敛到同一个键,堵住换写法重复建版本的潜在路径 - 抽出 resolve_publish_project_key 并补单测:三元组稳定性 + 行内值优先于请求值 - 同键不同请求摘要仍按「幂等键对应的请求摘要不一致」拒绝,不会静默重复建版本我们在大量的冲突中发现了少量pr
WIP: Style/优化陶泥平台发布to WIP: refactor/优化陶泥平台发布- 保留统一 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 登记新合同并标注旧契约被取代。- 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 统一处理。- 明确规则:路径里有 {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 改名,与本次无关)。- 删除统一 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} 解析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 证据,里程碑标记已实现取摘要前先把请求 `projectKey` 换成作品行已存的权威值,避免 `POST /games`(带 key)与 `POST /games/{game_id}/versions/{n}`(省略 key)身份写法不同导致同一逻辑发布重发时 409 摘要冲突 新增摘要跨 projectKey 来源一致的单元测试`ts(optional, type = "{ parentGameId: string, parentVersionId: string } | null")`,与 `forkAuthorization` 同写法,对齐服务端「null 与省略等价」`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路径端点 `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 静默丢弃共创声明18cecd9c6bto4cb1d3c1e3project_keytolocal_project_idin schema and codebase, retaining DTO/public API name asprojectKey. Add TODOs for handling future schema-breaking changes. 23eb8c831b