发布成功后把草稿媒体悄悄回写成线上 objectKey,上一版本预览按原比例对照

- 发布响应 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 覆盖封面/截图回写、已是键不重复改写与封面键分支
This commit is contained in:
2026-10-07 19:47:10 +08:00
parent a347d92822
commit 3dfcd8d66f
7 changed files with 197 additions and 41 deletions
@@ -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<String>,
/// 本次发布解析出的截图 objectKey,按草稿槽位顺序一一对应(含沿用槽位)。
#[serde(default)]
pub(crate) screenshot_object_keys: Vec<String>,
/// 发布成功并把绑定写回本地清单后的整份 manifest;渲染层据此同步两处挂载点。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) manifest: Option<GameCreationAppManifest>,
@@ -147,6 +153,12 @@ struct CreatedGame {
id: String,
#[serde(default)]
publication_revision: u64,
/// 本次发布解析出的封面 objectKey(新上传落桶的键,或沿用的已有键)。
#[serde(default)]
cover_object_key: Option<String>,
/// 本次发布解析出的截图 objectKey,按草稿槽位顺序一一对应(含沿用槽位)。
#[serde(default)]
screenshots: Vec<String>,
}
#[derive(Clone, Debug, Deserialize)]
@@ -1129,6 +1141,16 @@ fn normalize_object_key(value: &Option<String>) -> Option<String> {
.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));
}
}
@@ -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;
@@ -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,
@@ -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) {
// 旧项目的发布失败不该记到新项目头上。
@@ -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({
</span>
<div className="flex items-center gap-3">
{form.coverPath.trim() ? (
<IconPreview
projectPath={projectPath}
relativePath={form.coverPath}
labels={{
alt: '封面预览',
failedTitle: '读不到这张图,用「选择封面」导入一张就能预览',
failedAria: '封面读不到',
emptyAria: '还没有选封面',
}}
/>
coverIsObjectKey ? (
coverOnlineImage?.previewUrl ? (
<img
src={coverOnlineImage.previewUrl}
alt="封面预览"
className="size-24 shrink-0 rounded-xl border border-(--platform-surface-border) bg-(--platform-input-fill) object-contain"
/>
) : coverOnlineImage?.previewError ? (
<OnlinePreviewFailure
message={coverOnlineImage.previewError}
/>
) : (
<span className="grid size-24 shrink-0 place-items-center overflow-hidden rounded-xl border border-(--platform-surface-border) bg-(--platform-input-fill) px-2 text-center text-[11px] leading-snug text-(--platform-text-soft)">
已上传封面
</span>
)
) : (
<IconPreview
projectPath={projectPath}
relativePath={form.coverPath}
labels={{
alt: '封面预览',
failedTitle: '读不到这张图,用「选择封面」导入一张就能预览',
failedAria: '封面读不到',
emptyAria: '还没有选封面',
}}
/>
)
) : (
<span className="grid size-24 shrink-0 place-items-center overflow-hidden rounded-xl border border-dashed border-(--platform-surface-border) bg-(--platform-input-fill) text-center text-xs text-(--platform-text-soft)">
{showOnline && online?.cover
@@ -322,7 +349,7 @@ export function FormCard({
<img
src={online.cover.previewUrl}
alt="上一版本封面预览"
className="h-10 w-16 rounded-md object-cover"
className="h-10 w-16 rounded-md bg-(--platform-input-fill) object-contain"
/>
) : online.cover.previewError ? (
<OnlinePreviewFailure message={online.cover.previewError} />
@@ -373,7 +400,7 @@ export function FormCard({
<img
src={reusedPreview}
alt={`沿用上一版本的第 ${index + 1} 张截图`}
className="h-24 w-24 rounded-xl object-cover"
className="h-24 w-24 rounded-xl bg-(--platform-input-fill) object-contain"
/>
);
} 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 ? (
<OnlinePreviewFailure
@@ -493,6 +493,25 @@ describe('发布资料合成', () => {
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),
@@ -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` 封面键分支。