发布成功后把草稿媒体悄悄回写成线上 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:
@@ -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;
|
||||
|
||||
+41
-14
@@ -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,
|
||||
|
||||
+13
-5
@@ -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) {
|
||||
// 旧项目的发布失败不该记到新项目头上。
|
||||
|
||||
+40
-13
@@ -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` 封面键分支。
|
||||
|
||||
Reference in New Issue
Block a user