共创发布: 衍生作品必填「本次核心改动说明」(前端同等校验 + 提交 changeSummary)
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user