From 3dfcd8d66fa1a0fc91f85ea86191ab4bfb36423c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 7 Oct 2026 19:47:10 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=91=E5=B8=83=E6=88=90=E5=8A=9F=E5=90=8E?= =?UTF-8?q?=E6=8A=8A=E8=8D=89=E7=A8=BF=E5=AA=92=E4=BD=93=E6=82=84=E6=82=84?= =?UTF-8?q?=E5=9B=9E=E5=86=99=E6=88=90=E7=BA=BF=E4=B8=8A=20objectKey?= =?UTF-8?q?=EF=BC=8C=E4=B8=8A=E4=B8=80=E7=89=88=E6=9C=AC=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E6=8C=89=E5=8E=9F=E6=AF=94=E4=BE=8B=E5=AF=B9=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 发布响应 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 覆盖封面/截图回写、已是键不重复改写与封面键分支 --- .../src/game_distribution_publish.rs | 49 ++++++++++++++++- .../src/services/gameDistributionPublish.ts | 4 ++ .../export/state/useTaonierExport.ts | 55 ++++++++++++++----- .../export/state/useTaonierTab.ts | 18 ++++-- .../export/tabs/taonier/FormCard.tsx | 53 +++++++++++++----- .../tests/taonierExport.test.tsx | 52 ++++++++++++++++-- .../shared-memory/decision-log.md | 7 +++ 7 files changed, 197 insertions(+), 41 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs index a3fc7cc4e..e413a6293 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs @@ -56,6 +56,12 @@ pub(crate) struct GameDistributionPublishResult { pub(crate) package_sha256: String, pub(crate) package_size_bytes: u64, pub(crate) file_count: u32, + /// 本次发布解析出的封面 objectKey:面板据此把草稿里的本地封面路径悄悄改写成线上键。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) cover_object_key: Option, + /// 本次发布解析出的截图 objectKey,按草稿槽位顺序一一对应(含沿用槽位)。 + #[serde(default)] + pub(crate) screenshot_object_keys: Vec, /// 发布成功并把绑定写回本地清单后的整份 manifest;渲染层据此同步两处挂载点。 #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) manifest: Option, @@ -147,6 +153,12 @@ struct CreatedGame { id: String, #[serde(default)] publication_revision: u64, + /// 本次发布解析出的封面 objectKey(新上传落桶的键,或沿用的已有键)。 + #[serde(default)] + cover_object_key: Option, + /// 本次发布解析出的截图 objectKey,按草稿槽位顺序一一对应(含沿用槽位)。 + #[serde(default)] + screenshots: Vec, } #[derive(Clone, Debug, Deserialize)] @@ -1129,6 +1141,16 @@ fn normalize_object_key(value: &Option) -> Option { .map(str::to_string) } +/// 草稿槽位里的值是不是平台发布媒体的 objectKey。 +/// +/// 本地槽位存的是项目内相对路径;发布成功后会被悄悄改写成线上键,下一次发布会把同一个键当文件 +/// 路径去读而失败关闭——所以读文件前必须先按前缀认出这种槽位。 +fn is_game_distribution_media_object_key(value: &str) -> bool { + value + .trim() + .starts_with(shared_contracts::game_distribution::GAME_DISTRIBUTION_MEDIA_PREFIX) +} + /// 扩展名 → 服务端接受的图片内容类型。未知扩展名在发请求前失败关闭,别把非 `image/*` 的 /// 声明送给只收图片的 multipart 端点。 fn image_content_type(relative_path: &str) -> Result<&'static str, String> { @@ -1221,6 +1243,9 @@ fn resolve_publish_media( return Err("请先选择游戏封面(JPG/PNG/WebP),再发布到游戏广场".to_string()); } metadata.cover_object_key = reused; + } else if is_game_distribution_media_object_key(cover_path) { + // 上一次发布成功后面板把本地封面悄悄改写成了线上键:这里按沿用处理,别再当文件路径去读。 + metadata.cover_object_key = Some(cover_path.to_string()); } else { media.cover = Some(read_publish_image(root, cover_path)?); metadata.cover_object_key = None; @@ -1502,9 +1527,13 @@ pub(crate) async fn publish_local_project_game( if published.version.version_id.trim().is_empty() { return Err("发布游戏未返回版本 ID".to_string()); } - let game_id = published.game.id; - let publication_revision = published.game.publication_revision; - let version = published.version; + let PublishedGameVersion { game, version } = published; + let game_id = game.id; + let publication_revision = game.publication_revision; + // 平台在这次响应里已经给出权威的解析后媒体键(新上传落的桶 + 沿用的旧键,顺序与槽位一致): + // 面板据此把草稿里的本地路径改写成线上键,不必再读一次线上。 + let cover_object_key = game.cover_object_key; + let screenshot_object_keys = game.screenshots; validate_session(&snapshot)?; let progress_version_id = version.version_id.clone(); let verify_version_id = version.version_id.clone(); @@ -1633,6 +1662,8 @@ pub(crate) async fn publish_local_project_game( package_sha256: staged.package_sha256, package_size_bytes: staged.package_size_bytes, file_count: staged.package_file_count, + cover_object_key, + screenshot_object_keys, manifest: Some(manifest), publication: Some(binding), }) @@ -2143,4 +2174,16 @@ mod tests { let error = resolve_publish_media(&root, &mut metadata, "", &[]).expect_err("must fail"); assert!(error.contains("请先选择游戏封面"), "{error}"); } + + #[test] + fn resolve_publish_media_treats_a_rewritten_cover_object_key_as_reuse_not_a_local_file() { + let root = fixture_root("cover-key"); + // 草稿封面已被发布链路悄悄改写成线上键,磁盘上并不存在同名文件。 + let key = "agc/project-snapshots/v1/game-distribution/media/game-1/cover-1.png"; + let mut metadata = publish_metadata(None, vec![]); + + let media = resolve_publish_media(&root, &mut metadata, key, &[]).expect("resolve media"); + assert!(media.cover.is_none(), "线上键不能再当本地文件读成二进制"); + assert_eq!(metadata.cover_object_key.as_deref(), Some(key)); + } } diff --git a/apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts b/apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts index 76c1f62ae..ff11a4581 100644 --- a/apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts +++ b/apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts @@ -86,6 +86,10 @@ export type GameDistributionPublishResult = { packageSha256: string; packageSizeBytes: number; fileCount: number; + /** 本次发布解析出的封面 objectKey(新上传落桶的键或沿用的已有键)。 */ + coverObjectKey?: string | null; + /** 本次发布解析出的截图 objectKey,按草稿槽位顺序一一对应(含沿用槽位)。 */ + screenshotObjectKeys?: string[]; /** 发布成功并回写绑定后的整份 manifest;渲染层据此同步工作台状态。 */ manifest?: GameCreationAppManifest | null; publication?: GameCreationAppPublicationBinding | null; diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/useTaonierExport.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/useTaonierExport.ts index 5e2d5995e..bbd41a286 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/useTaonierExport.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/useTaonierExport.ts @@ -144,6 +144,13 @@ export function buildTaonierPublishMetadata( online: TaonierOnlineValues | null, ): TaonierPublishFormMetadata { const localCover = form.coverPath.trim(); + // 封面槽位三态:空 = 沿用上一版本封面;线上 objectKey = 发布成功后回写的沿用键;其余 = 本地图。 + // 本地图要带二进制(`coverObjectKey` 留空),后两种都直接沿用键,不能再当文件路径读。 + const coverObjectKey = localCover + ? isGameDistributionMediaObjectKey(localCover) + ? localCover + : null + : (online?.cover?.objectKey ?? null); const screenshots: (string | null)[] = form.screenshotPaths .map((entry) => entry.trim()) // 空串是界面清空某项后的残留,不属于任何槽位;丢掉它,槽位顺序才与本地路径组一一对应。 @@ -153,7 +160,7 @@ export function buildTaonierPublishMetadata( title: form.title.trim(), summary: form.summary.trim(), category: form.category as GameDistributionCategory, - coverObjectKey: localCover ? null : (online?.cover?.objectKey ?? null), + coverObjectKey, screenshots, }; } @@ -759,20 +766,40 @@ export function useTaonierExport({ }, [online, updateImageFields]); /** - * 发布成功后把草稿里的本地截图路径改写成刚发布的 objectKey。 + * 发布成功后把草稿里刚上传的本地路径悄悄改写成平台返回的 objectKey。 * - * 发布前草稿存的是「要上传的本地图」,发布后这些图已经变成平台上的发布媒体;改写后草稿直接 - * 指向上一版本,下一次发布沿用、不重复上传。只在这次读到了非空截图列表时改写,避免平台回读 - * 暂时拿不到时把用户草稿清空。 + * 用发布响应而不是再读一次线上:响应里的键就是这次解析的权威结果(新上传落的桶 + 沿用的旧键, + * 顺序与槽位一一对应)。改写后草稿直接指向线上对象,下一次发布沿用、不重复上传;界面也按同一 + * OSS 来源显示当前图与上一版本图,可以逐张对照比例。 + * + * 只在「这次确实解析出键」时改写;响应没给键(或槽位数对不上,期间用户又改了草稿)就保持原值, + * 宁可下次再传,也不擦掉用户刚编辑的内容。 */ - const rewriteScreenshotsToOnline = useCallback( - (values: TaonierOnlineValues): void => { - const keys = onlineScreenshotObjectKeys(values.screenshots); - if (keys.length === 0) return; - updateImageFields((previous) => ({ - coverPath: previous.coverPath, - screenshotPaths: keys, - })); + const rewritePublishedMediaToObjectKeys = useCallback( + (media: { + coverObjectKey?: string | null; + screenshotObjectKeys?: readonly string[]; + }): void => { + const coverKey = (media.coverObjectKey ?? '').trim(); + const screenshotKeys = (media.screenshotObjectKeys ?? []) + .map((key) => key.trim()) + .filter((key) => key.length > 0); + if (!coverKey && screenshotKeys.length === 0) return; + updateImageFields((previous) => { + const previousCover = previous.coverPath.trim(); + const rewriteCover = + coverKey.length > 0 && + previousCover.length > 0 && + !isGameDistributionMediaObjectKey(previousCover); + return { + coverPath: rewriteCover ? coverKey : previous.coverPath, + screenshotPaths: + screenshotKeys.length > 0 && + screenshotKeys.length === previous.screenshotPaths.length + ? screenshotKeys + : previous.screenshotPaths, + }; + }); }, [updateImageFields], ); @@ -1006,7 +1033,7 @@ export function useTaonierExport({ replaceScreenshotLocal, removeScreenshotAt, adoptOnlineScreenshots, - rewriteScreenshotsToOnline, + rewritePublishedMediaToObjectKeys, adoptOnlineText, resolveConflict, packageAll, diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/useTaonierTab.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/useTaonierTab.ts index cdc011c8e..724868076 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/useTaonierTab.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/useTaonierTab.ts @@ -264,6 +264,13 @@ export function useTaonierTab({ .filter( (path) => path.length > 0 && !isGameDistributionMediaObjectKey(path), ); + // 封面槽位同样二选一:线上 objectKey 已在 metadata.coverObjectKey 里沿用,`coverPath` 传空, + // 宿主才不会把一串 OSS 键当项目内文件路径去读。 + const coverPath = isGameDistributionMediaObjectKey( + effectiveForm.coverPath, + ) + ? '' + : effectiveForm.coverPath; const next = await publishLocalProjectGame({ invoke, projectPath, @@ -271,7 +278,7 @@ export function useTaonierTab({ // 媒体走 M1 的 objectKey / multipart 直传合同:文字与本地图路径都来自面板, // 图片槽按「本地二进制 / 上一版本 objectKey」合成。 metadata, - coverPath: effectiveForm.coverPath, + coverPath, screenshotPaths: localScreenshotPaths, gameId: online.gameId, versionNumber: online.nextVersionNumber, @@ -282,12 +289,13 @@ export function useTaonierTab({ if (projectPathRef.current !== projectPath) return; setPublishResult(next); setProgress(null); - // 发布成功后重新读一次上一版本:最近版本与状态以平台为准;读到刚发布的截图后把草稿里的 - // 本地路径就地改写成 objectKey,下一次发布直接沿用、不重复上传。 + // 发布成功后把草稿里刚上传的本地路径悄悄改写成平台返回的 objectKey。键来自发布响应本身 + // (新上传落的桶 + 沿用的旧键,顺序与槽位一致),不依赖回读;先回读一次上一版本只是为了 + // 拿到刚发布媒体的换签预览,改写后当前槽位立刻能按 OSS 同一来源显示,与上一版本逐张对照。 void (async () => { - const refreshed = await taonier.refreshOnline(); + await taonier.refreshOnline(); if (projectPathRef.current !== projectPath) return; - if (refreshed) taonier.rewriteScreenshotsToOnline(refreshed); + taonier.rewritePublishedMediaToObjectKeys(next); })(); } catch (error) { // 旧项目的发布失败不该记到新项目头上。 diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/FormCard.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/FormCard.tsx index 6efab6777..9a05cdafe 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/FormCard.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/taonier/FormCard.tsx @@ -105,6 +105,15 @@ export function FormCard({ */ const showOnline = onlineState === 'ready' && online?.state === 'update'; const onlineScreenshots = showOnline ? (online?.screenshots ?? []) : []; + // 发布成功后草稿封面会从本地路径悄悄改写成线上 objectKey。按同一个 key 从上一版本里取换签预览, + // 当前封面与上一版本封面就走 OSS 同一来源,比例一致、可逐张对照。 + const coverIsObjectKey = isGameDistributionMediaObjectKey(form.coverPath); + const coverKey = form.coverPath.trim(); + const coverOnlineImage = coverIsObjectKey + ? online?.cover?.objectKey === coverKey + ? online.cover + : onlineScreenshots.find((image) => image.objectKey === coverKey) + : undefined; // 截图槽位是「本地路径或上一版本 objectKey」二选一,分开计数再摊平成一句摘要。 const reusedScreenshotCount = form.screenshotPaths.filter((path) => isGameDistributionMediaObjectKey(path), @@ -279,16 +288,34 @@ export function FormCard({
{form.coverPath.trim() ? ( - + coverIsObjectKey ? ( + coverOnlineImage?.previewUrl ? ( + 封面预览 + ) : coverOnlineImage?.previewError ? ( + + ) : ( + + 已上传封面 + + ) + ) : ( + + ) ) : ( {showOnline && online?.cover @@ -322,7 +349,7 @@ export function FormCard({ 上一版本封面预览 ) : online.cover.previewError ? ( @@ -373,7 +400,7 @@ export function FormCard({ {`沿用上一版本的第 ); } else if (reusedKey && reusedImage?.previewError) { @@ -437,7 +464,7 @@ export function FormCard({ key={`${image.objectKey}-${index}`} src={image.previewUrl} alt={`上一版本截图 ${index + 1} 预览`} - className="h-10 w-16 rounded-md object-cover" + className="h-10 w-16 rounded-md bg-(--platform-input-fill) object-contain" /> ) : image.previewError ? ( { null, ], }); + + // 发布成功回写后草稿封面存的是线上 objectKey:直接当沿用键,不再当本地文件读。 + expect( + buildTaonierPublishMetadata( + { + ...FORM, + coverPath: + 'agc/project-snapshots/v1/game-distribution/media/game_1/cover-a.png', + }, + ONLINE_VALUES, + ), + ).toEqual({ + title: '星轨防线', + summary: '一句话简介', + category: '休闲', + coverObjectKey: + 'agc/project-snapshots/v1/game-distribution/media/game_1/cover-a.png', + screenshots: [null], + }); }); }); @@ -887,7 +906,7 @@ describe('hook 状态', () => { ); }); - it('发布成功后本地截图路径改写成上一版本 objectKey,沿用按钮写 key 而不是空数组', async () => { + it('发布成功后把本地封面/截图路径悄悄改写成平台返回的 objectKey,沿用按钮写 key 而不是空数组', async () => { const invoke = createInvoke({ online: ONLINE_UPDATE }); installTauriRuntime({ core: { invoke } }); const { result } = renderHook(() => @@ -900,14 +919,35 @@ describe('hook 状态', () => { await waitFor(() => expect(result.current.online).toEqual(ONLINE_VALUES)); expect(result.current.form?.screenshotPaths).toEqual(['assets/shot-1.png']); + expect(result.current.form?.coverPath).toBe('assets/cover.png'); - // 发布成功回读到刚发布的截图后,本地路径被改写成 objectKey。 + // 发布响应带回解析后的键:本地封面与截图路径被悄悄改写成线上 objectKey。 await act(async () => { - result.current.rewriteScreenshotsToOnline(ONLINE_VALUES); + result.current.rewritePublishedMediaToObjectKeys({ + coverObjectKey: + 'agc/project-snapshots/v1/game-distribution/media/game_1/cover-1.png', + screenshotObjectKeys: [ + 'agc/project-snapshots/v1/game-distribution/media/game_1/shot-1.png', + ], + }); }); - expect(result.current.form?.screenshotPaths).toEqual(['shot/1.png']); + expect(result.current.form?.coverPath).toBe( + 'agc/project-snapshots/v1/game-distribution/media/game_1/cover-1.png', + ); + expect(result.current.form?.screenshotPaths).toEqual([ + 'agc/project-snapshots/v1/game-distribution/media/game_1/shot-1.png', + ]); - // 「沿用上一版本截图」写的是明确 key,不再是清空列表触发隐式沿用。 + // 已经是线上键的封面/截图不重复改写;「沿用上一版本截图」写的是明确 key,不再是清空列表。 + await act(async () => { + result.current.rewritePublishedMediaToObjectKeys({ + coverObjectKey: 'cover/key.png', + screenshotObjectKeys: [], + }); + }); + expect(result.current.form?.coverPath).toBe( + 'agc/project-snapshots/v1/game-distribution/media/game_1/cover-1.png', + ); await act(async () => { result.current.adoptOnlineScreenshots(); }); @@ -1216,7 +1256,7 @@ function fakeTaonier( replaceScreenshotLocal: vi.fn(), removeScreenshotAt: vi.fn(), adoptOnlineScreenshots: vi.fn(), - rewriteScreenshotsToOnline: vi.fn(), + rewritePublishedMediaToObjectKeys: vi.fn(), adoptOnlineText: vi.fn(), resolveConflict: vi.fn(), packageAll: vi.fn(async () => true), diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 9e966b2ba..4e1b57dc2 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9946,3 +9946,10 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 软删后重发(2026-10-07 追加):作品被软删后,同一 `(ownerUserId, projectKey)` 身份再次发布不再失败关闭,而是模块就地把该软删行复活覆盖为全新作品(清 `deletedAt`、回到未公开、`publicationRevision=0`、清空活动版本与 `playCount`/`priceMudPoints`、用本次资料覆盖作品级字段),并删除该作品旧的 `create_version` 收据,旧版本号可重新发布而不被当成重放或摘要冲突。api-server 仅在显式 `gameId` 恰好等于该 `projectKey` 的确定性身份时进入复活路径,其余不存在 / 非本身份的 `gameId` 仍 404;`spacetime-module` 单测 `revive_overwrites_soft_deleted_game_as_fresh_identity` 锁定覆盖语义。 - 落地与清理(2026-10-07):M1 契约与领域 `efdd29c59`、M2 procedure + AGC 单次调用 `c7c99567b`、M3 统一端点 + 网页深模块 `7a710bb2b`、AGC TS 改名 `2dca06be3`;M5 删除已无调用方的 `GAME_DISTRIBUTION_VERSION_NUMBER_CONFLICT` 映射与常量、`GameDistributionCreateVersionRequest`(Rust+TS+parity)及 `create_game_distribution_game_and_return` / `create_game_distribution_version_and_return` 两个 procedure 与 facade 方法,绑定再生成 915 文件。api-server `game_distribution` 63 passed、module 32 passed、shared-contracts `game_distribution` 8 passed、spacetime-module `game_distribution` 6 passed。 - 权威行为见玩法链路「游戏分发统一发布接口与版本号自然幂等合同(2026-10-07)」、后端数据契约同日条目与 AGC 实施计划;模块不在线,硬切无迁移。 + +## 2026-10-07:陶泥儿发布媒体回写 objectKey 与上一版本预览按原比例对照 + +- 回写来源:发布成功后草稿里的本地封面/截图路径改为用**发布响应**里的解析后 objectKey 改写(`GameDistributionPublishResult.coverObjectKey` / `screenshotObjectKeys`,取自 `game_payload` 的 `coverObjectKey`/`screenshots`;服务端在发布事务里已把这些键写到作品行)。不再依赖发布后那次线上回读来决定媒体改写——回读只负责刷新上一版本文字/状态,读不到也不清空草稿。 +- 槽位三态:封面槽位是「空 = 沿用上一版本 / 线上 objectKey = 沿用该键 / 其余 = 本地图带二进制」;截图槽位是「线上 objectKey 或本地路径」。草稿被回写成键后,下一次发布按沿用处理、不重复上传;宿主与前端都按 `GAME_DISTRIBUTION_MEDIA_PREFIX` 前缀先认出键,读文件前分流(AGC `is_game_distribution_media_object_key` + `buildTaonierPublishMetadata` 封面分支)。 +- 预览口径:当前槽位与「上一版本」槽位都按 OSS 同一来源 `object-contain` 显示(本地图走 `IconPreview` 的 `size-24 object-contain`),不再 `object-cover` 裁剪,作者能逐张对照比例;封面草稿是键时按同一 key 从上一版本列表取换签地址,取不到预览时显示「已上传封面」而不是伪装成没有封面。 +- 验证:AGC 单测 `resolve_publish_media_treats_a_rewritten_cover_object_key_as_reuse_not_a_local_file` 与既有 `resolve_publish_media_*`;`taonierExport.test.tsx` 覆盖封面/截图回写、已为键时不重复改写、`buildTaonierPublishMetadata` 封面键分支。