共创发布: 衍生作品必填「本次核心改动说明」(前端同等校验 + 提交 changeSummary)

This commit is contained in:
2026-10-06 18:14:20 +08:00
parent 6aa2f97410
commit e0e3463550
2 changed files with 249 additions and 0 deletions
@@ -789,6 +789,188 @@ test('线上封面没有冻结素材时要求重新选择封面', async () => {
expect(createGameVersion).not.toHaveBeenCalled();
});
test('衍生作品发布新版本必须填「本次核心改动说明」', async () => {
vi.mocked(listMyGames).mockResolvedValue([
{
id: 'game-1',
title: '星轨防线',
summary: '守住轨道城',
description: '旧版资料',
category: '动作',
tags: ['塔防'],
publicationRevision: 4,
// 有血缘 → 衍生作品:核心改动说明必填。
lineage: {
generation: 1,
rootGameId: 'game-root',
rootTitle: '根原创',
parentGameId: 'game-root',
parentTitle: '根原创',
parentAuthorName: '原作者',
},
latestVersion: {
versionId: 'gamever-2',
gameId: 'game-1',
versionNumber: 2,
packageSha256: 'd'.repeat(64),
packageBytes: 1024,
status: 'published',
publicationRevision: 4,
reviewReason: null,
createdAt: '2026-09-20T08:00:00Z',
updatedAt: '2026-09-20T09:00:00Z',
},
versions: [],
} as never,
]);
vi.mocked(getGameVersion).mockResolvedValue({
game: {
id: 'game-1',
title: '星轨防线',
summary: '守住轨道城',
description: '旧版资料',
category: '动作',
tags: ['塔防'],
publicationRevision: 4,
},
version: {
versionId: 'gamever-2',
versionNumber: 2,
status: 'published',
publicationRevision: 4,
recoveryAction: 'none',
priceMudPoints: 0,
frozenMetadata: {
coverAssetId: 'asset-cover-old',
coverObjectKey: 'game-distribution/cover/old.png',
screenshots: [
{
assetId: 'asset-shot-old',
objectKey: 'game-distribution/screenshot/old.png',
},
],
},
},
} as never);
vi.mocked(createGameVersion).mockResolvedValue({
gameId: 'game-1',
versionId: 'gamever-3',
versionNumber: 3,
status: 'awaiting_upload',
});
renderPublishPage(createAuthValue(), 'game-1');
await screen.findByLabelText('游戏名称');
const field = await screen.findByLabelText('本次核心改动说明');
expect(field).toBeTruthy();
const file = await buildZipFile();
fireEvent.change(screen.getByLabelText(/发行包 ZIP/u), {
target: { files: [file] },
});
// 空着提交:前端同等校验拦截(服务端同样会以 FORK_CHANGE_SUMMARY_REQUIRED 失败关闭)。
fireEvent.click(screen.getByRole('button', { name: '提交新版本审核' }));
expect(
await screen.findByText(/衍生作品必须填写「本次核心改动说明」/u),
).toBeTruthy();
expect(createGameVersion).not.toHaveBeenCalled();
const summaryText =
'把跳台改成三段,并重画全部背景与音效,同时调整了整体节奏';
fireEvent.change(field, { target: { value: summaryText } });
fireEvent.click(screen.getByRole('button', { name: '提交新版本审核' }));
await waitFor(() => expect(createGameVersion).toHaveBeenCalledTimes(1));
expect(vi.mocked(createGameVersion).mock.calls[0]?.[1]).toEqual(
expect.objectContaining({ changeSummary: summaryText }),
);
});
test('母版发布新版本不渲染改动说明字段,请求体也不带它', async () => {
vi.mocked(listMyGames).mockResolvedValue([
{
id: 'game-1',
title: '星轨防线',
summary: '守住轨道城',
description: '旧版资料',
category: '动作',
tags: ['塔防'],
publicationRevision: 4,
latestVersion: {
versionId: 'gamever-2',
gameId: 'game-1',
versionNumber: 2,
packageSha256: 'd'.repeat(64),
packageBytes: 1024,
status: 'published',
publicationRevision: 4,
reviewReason: null,
createdAt: '2026-09-20T08:00:00Z',
updatedAt: '2026-09-20T09:00:00Z',
},
versions: [],
} as never,
]);
vi.mocked(getGameVersion).mockResolvedValue({
game: {
id: 'game-1',
title: '星轨防线',
summary: '守住轨道城',
description: '旧版资料',
category: '动作',
tags: ['塔防'],
publicationRevision: 4,
},
version: {
versionId: 'gamever-2',
versionNumber: 2,
status: 'published',
publicationRevision: 4,
recoveryAction: 'none',
priceMudPoints: 0,
frozenMetadata: {
coverAssetId: 'asset-cover-old',
coverObjectKey: 'game-distribution/cover/old.png',
screenshots: [
{
assetId: 'asset-shot-old',
objectKey: 'game-distribution/screenshot/old.png',
},
],
},
},
} as never);
vi.mocked(createGameVersion).mockResolvedValue({
gameId: 'game-1',
versionId: 'gamever-3',
versionNumber: 3,
status: 'awaiting_upload',
});
vi.mocked(uploadGamePackage).mockResolvedValue({
versionId: 'gamever-3',
status: 'uploaded',
});
vi.mocked(submitGameVersion).mockResolvedValue({
version: { status: 'pending_review' },
});
renderPublishPage(createAuthValue(), 'game-1');
await screen.findByLabelText('游戏名称');
expect(screen.queryByLabelText('本次核心改动说明')).toBeNull();
const file = await buildZipFile();
fireEvent.change(screen.getByLabelText(/发行包 ZIP/u), {
target: { files: [file] },
});
fireEvent.click(screen.getByRole('button', { name: '提交新版本审核' }));
await waitFor(() => expect(createGameVersion).toHaveBeenCalledTimes(1));
expect(vi.mocked(createGameVersion).mock.calls[0]?.[1]).not.toHaveProperty(
'changeSummary',
);
});
test('更新模式读不到归属游戏时失败关闭且不提交', async () => {
vi.mocked(listMyGames).mockResolvedValue([]);
@@ -58,8 +58,19 @@ type GameUpdateContext = {
publicationRevision: number;
versionNumber: number;
title: string;
/** 衍生作品(有父作品血缘):发布新版本时必须填「本次核心改动说明」。 */
isDerivative: boolean;
};
/**
* 「本次核心改动说明」的字数口径,与 Rust 侧
* `module-game-distribution/src/domain.rs` 的 `GAME_DISTRIBUTION_CHANGE_SUMMARY_MIN_CHARS = 20` /
* `GAME_DISTRIBUTION_CHANGE_SUMMARY_MAX_CHARS = 500` 一致(按字符计,CJK 友好);服务端的失败码是
* `FORK_CHANGE_SUMMARY_REQUIRED` / `FORK_CHANGE_SUMMARY_INVALID`。
*/
const CHANGE_SUMMARY_MIN_CHARS = 20;
const CHANGE_SUMMARY_MAX_CHARS = 500;
let publishKeySeed = 0;
function createPublishKey() {
publishKeySeed += 1;
@@ -82,6 +93,7 @@ export function GamePublishPage({
const [description, setDescription] = useState('');
const [category, setCategory] = useState<GameDistributionCategory>('休闲');
const [tagsInput, setTagsInput] = useState('');
const [changeSummary, setChangeSummary] = useState('');
const [deviceSupport, setDeviceSupport] =
useState<GameDistributionDeviceSupport>({
desktop: true,
@@ -178,6 +190,8 @@ export function GamePublishPage({
publicationRevision: detail.version.publicationRevision,
versionNumber: detail.version.versionNumber,
title: detail.game.title,
// 血缘行存在(generation > 0)就是衍生作品;母版没有父作品。
isDerivative: (game.lineage?.generation ?? 0) > 0,
});
void applyVersionMediaDefaults(detail, {
setCover: setCoverAsset,
@@ -379,6 +393,27 @@ export function GamePublishPage({
setError(metadataError);
return;
}
// 衍生作品必须填「本次核心改动说明」:前端做同等校验并给可读提示(服务端同样失败关闭,
// 码为 FORK_CHANGE_SUMMARY_REQUIRED / FORK_CHANGE_SUMMARY_INVALID)。
const normalizedChangeSummary = changeSummary.trim();
if (updateContext.isDerivative) {
const charCount = [...normalizedChangeSummary].length;
if (charCount === 0) {
setError(
`衍生作品必须填写「本次核心改动说明」(不少于 ${CHANGE_SUMMARY_MIN_CHARS} 个字)。`,
);
return;
}
if (
charCount < CHANGE_SUMMARY_MIN_CHARS ||
charCount > CHANGE_SUMMARY_MAX_CHARS
) {
setError(
`「本次核心改动说明」需要 ${CHANGE_SUMMARY_MIN_CHARS}~${CHANGE_SUMMARY_MAX_CHARS} 个字(当前 ${charCount} 个字)。`,
);
return;
}
}
if (!packageFile) {
setError('请选择发行包 ZIP');
return;
@@ -413,6 +448,10 @@ export function GamePublishPage({
packageFileCount: prepared.fileCount,
packageEntryPath: 'index.html',
gameMetadata,
// 母版不传该字段(服务端可空/忽略),衍生作品传去重后的文本。
...(updateContext.isDerivative
? { changeSummary: normalizedChangeSummary }
: {}),
},
`${updateKey}:version`,
);
@@ -782,6 +821,34 @@ export function GamePublishPage({
onChange={(e) => setTagsInput(e.target.value)}
/>
</label>
{/*
「本次核心改动说明」:**衍生作品必填**(服务端 `FORK_CHANGE_SUMMARY_REQUIRED`),
母版可空(服务端忽略)。校验口径与 Rust 侧
`module-game-distribution/src/domain.rs` 的 MIN/MAX 常量一致(按字符计,CJK 友好)。
*/}
{updateContext?.isDerivative ? (
<label className="game-publish-field">
本次核心改动说明(必填)
<textarea
value={changeSummary}
placeholder={`说明这一版相对来源作品改了什么(${CHANGE_SUMMARY_MIN_CHARS}~${CHANGE_SUMMARY_MAX_CHARS} 个字)`}
disabled={isResumingDraft}
aria-label="本次核心改动说明"
onChange={(e) => setChangeSummary(e.target.value)}
/>
<span
className="game-publish-field__hint"
role="status"
aria-live="polite"
>
{[...changeSummary.trim()].length}/{CHANGE_SUMMARY_MAX_CHARS}
{[...changeSummary.trim()].length > 0 &&
[...changeSummary.trim()].length < CHANGE_SUMMARY_MIN_CHARS
? `(至少 ${CHANGE_SUMMARY_MIN_CHARS} 个字)`
: ''}
</span>
</label>
) : null}
<label className="game-publish-field">
画面方向
<select