实现游戏共创 M1:作品级共创授权与改编血缘
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

- game_distribution_game 表尾追加 fork_authorization(默认 forbidden,只升不降,旧行自动取默认)
- 新增 game_distribution_lineage 表,记录父作品、来源版本、根作品与代际,并按 parent/root/owner 建索引
- migration.rs 登记新表并注明血缘随迁移导出
- 新增 set_game_distribution_fork_authorization_and_return procedure 与幂等收据、期望值 CAS
- 创建作品时校验改编声明:来源存在、已公开、授权开放、来源版本等于当前公开版本;复用既有身份时拒绝携带血缘
- 公开与后台响应增量:forkAuthorization、forkCount、lineage、generation、forkedFromGameId、derivedCount
- 公开快照的作者名与头像改为读时联 user_account,不再落到「创作者」兜底文案
- api-server 新增 PUT /api/game-distribution/games/{game_id}/fork-authorization(Bearer、幂等键、期望值校验)
- 错误映射新增 FORK_ 前缀分支并置于通用子串分支之前,避免被误判成 409 或 404
- module-game-distribution 增加授权阶梯、代际计算与错误码,并补单测覆盖阶梯与错误码前缀
- 网页端详情页新增共创信息卡,我的作品页新增授权提升入口,补服务层调用与定向测试
- 重新生成 SpacetimeDB 绑定,登记 DTO 一致性脚本的共创类型
This commit is contained in:
2026-10-04 19:08:42 +08:00
parent 87b306daa2
commit 018f9d58e9
34 changed files with 2122 additions and 47 deletions
@@ -7,9 +7,11 @@ import {
import { ArrowLeft, Gamepad2, Maximize2 } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import type {
GameDistributionRecoveryAction,
GameDistributionVersionSummary,
import {
GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS,
type GameDistributionRecoveryAction,
type GameDistributionVersionSummary,
resolveGameForkAuthorization,
} from '../../../packages/shared/src/contracts/gameDistribution';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import {
@@ -316,6 +318,11 @@ function GameDetailContent({
? ''
: resolveNonPublicCoverLabel(game.status, latestVersion?.status);
const latestReviewReason = latestVersion?.reviewReason?.trim() ?? '';
const forkAuthorization = resolveGameForkAuthorization(
game.forkAuthorization,
);
const lineage = game.lineage ?? null;
const forkCount = game.forkCount ?? 0;
const mobilePlaybackBlocked = isMobileViewport && !game.deviceSupport.mobile;
const playbackBlocked = !version || mobilePlaybackBlocked;
return (
@@ -341,6 +348,46 @@ function GameDetailContent({
controlsLabel={resolveControlsLabel(version, game.inputModes)}
versionLabel={versionLabel}
rating={publiclyVisible ? <GameRatingSummary state={reviews} /> : null}
infoCards={[
{
label: '共创',
value: (
<span className="game-fork-card">
<span
className={`game-fork-badge game-fork-badge--${forkAuthorization}`}
>
{GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS[
forkAuthorization
]}
</span>
{lineage ? (
<>
{/*
父作品被软删除时服务端不再回传它的标题与作者名(见
module-game-distribution 的血缘快照),这里降级成「原作品已不可用」,
既不猜测也不回显已下线作品的信息。
*/}
<span className="game-fork-lineage">
{lineage.parentTitle
? `第 ${lineage.generation} 代作品 · 改编自《${lineage.parentTitle}》`
: `第 ${lineage.generation} 代作品 · 原作品已不可用`}
</span>
{lineage.parentAuthorName ? (
<span className="game-fork-lineage__author">
由 {lineage.parentAuthorName} 制作
</span>
) : null}
</>
) : null}
{forkCount > 0 ? (
<span className="game-fork-derived">
已被改编 {forkCount} 次
</span>
) : null}
</span>
),
},
]}
actions={
<>
<PlatformActionButton
@@ -246,6 +246,73 @@ describe('GameDetailPage', () => {
expect(await screen.findByText('暂未发布可玩版本')).toBeTruthy();
});
it('展示共创授权、代际与溯源信息,且不提供改造入口', async () => {
getGameMock.mockResolvedValueOnce(
createGame({
forkAuthorization: 'nonCommercial',
forkCount: 3,
lineage: {
generation: 2,
rootGameId: 'game-root',
rootTitle: '根作品',
parentGameId: 'game-parent',
parentTitle: '原初之光',
parentAuthorName: '原作者',
},
}),
);
render(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
expect(await screen.findByText('允许非商用共创')).toBeTruthy();
expect(screen.getByText('第 2 代作品 · 改编自《原初之光》')).toBeTruthy();
expect(screen.getByText('由 原作者 制作')).toBeTruthy();
expect(screen.getByText('已被改编 3 次')).toBeTruthy();
expect(screen.queryByRole('button', { name: /改造/u })).toBeNull();
});
it('旧数据缺少授权与血缘字段时只按禁止共创渲染', async () => {
getGameMock.mockResolvedValueOnce(createGame());
render(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
expect(await screen.findByText('禁止共创')).toBeTruthy();
expect(screen.queryByText(/代作品/u)).toBeNull();
expect(screen.queryByText(/已被改编/u)).toBeNull();
expect(screen.queryByText(/允许/u)).toBeNull();
});
it('父作品已被软删除时不展示被删作品标题与作者名', async () => {
getGameMock.mockResolvedValueOnce(
createGame({
forkAuthorization: 'full',
forkCount: 0,
lineage: {
generation: 2,
rootGameId: 'game-root',
rootTitle: null,
parentGameId: 'game-parent',
parentTitle: null,
parentAuthorName: null,
},
}),
);
render(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
expect(await screen.findByText('允许全开放共创')).toBeTruthy();
expect(
screen.getByText('第 2 代作品 · 原作品已不可用'),
).toBeTruthy();
expect(screen.queryByText(/改编自/u)).toBeNull();
// 血缘作者行(卡内 `.game-fork-lineage__author`)不该出现;hero 里的
// 「由 测试作者 制作」是作品自身署名,不在断言范围内。
expect(document.querySelector('.game-fork-lineage__author')).toBeNull();
});
});
describe('GameDetailPage 作者视角', () => {
@@ -15,6 +15,7 @@ import {
submitGameVersion,
unpublishGame,
updateMyGame,
updateGameForkAuthorization,
} from '../../services/gameDistributionClient';
import { AuthUiContext } from '../auth/AuthUiContext';
import { MyGamesPage } from './MyGamesPage';
@@ -28,6 +29,7 @@ vi.mock('../../services/gameDistributionClient', () => ({
submitGameVersion: vi.fn(),
unpublishGame: vi.fn(),
updateMyGame: vi.fn(),
updateGameForkAuthorization: vi.fn(),
}));
type AuthValue = NonNullable<ContextType<typeof AuthUiContext>>;
@@ -115,6 +117,7 @@ beforeEach(() => {
vi.mocked(submitGameVersion).mockReset();
vi.mocked(unpublishGame).mockReset();
vi.mocked(updateMyGame).mockReset();
vi.mocked(updateGameForkAuthorization).mockReset();
vi.mocked(listMyGames).mockResolvedValue([]);
vi.mocked(loadVersionRecoveryActions).mockResolvedValue({});
// 默认没有冻结素材:编辑弹窗打开时按“线上封面不可复用”处理,用例按需覆盖。
@@ -590,3 +593,73 @@ test('删除作品需二次确认并带上当前修订号', async () => {
expect(String(idempotencyKey)).toContain('game-delete');
await waitFor(() => expect(listMyGames).toHaveBeenCalledTimes(2));
});
test('把共创授权从禁止提升为非商用会携带期望档位并重新加载列表', async () => {
vi.mocked(listMyGames).mockResolvedValue([
createGame({ forkAuthorization: 'forbidden' }),
]);
vi.mocked(updateGameForkAuthorization).mockResolvedValue({
ok: true,
game: { id: 'game-1', forkAuthorization: 'nonCommercial' },
});
renderPage(createAuthValue());
expect(await screen.findByText(/共创:禁止共创/)).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '共创授权' }));
const nonCommercialOption = screen.getByRole('button', { name: '非商用' });
expect((nonCommercialOption as HTMLButtonElement).disabled).toBe(false);
fireEvent.click(nonCommercialOption);
// 首次点击只进入确认态,不发送请求。
expect(updateGameForkAuthorization).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '确认提升' }));
await waitFor(() =>
expect(updateGameForkAuthorization).toHaveBeenCalledTimes(1),
);
const [gameId, target, expected, idempotencyKey] =
vi.mocked(updateGameForkAuthorization).mock.calls[0] ?? [];
expect(gameId).toBe('game-1');
expect(target).toBe('nonCommercial');
expect(expected).toBe('forbidden');
expect(String(idempotencyKey)).toContain('game-1');
await waitFor(() => expect(listMyGames).toHaveBeenCalledTimes(2));
expect(screen.getByText(/共创授权已更新为「允许非商用共创」/)).toBeTruthy();
});
test('共创授权当前档位与更低档位不可点', async () => {
vi.mocked(listMyGames).mockResolvedValue([
createGame({ forkAuthorization: 'nonCommercial' }),
]);
renderPage(createAuthValue());
expect(await screen.findByText(/共创:允许非商用共创/)).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '共创授权' }));
expect(
(screen.getByRole('button', { name: '禁止' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '非商用' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '全开放' }) as HTMLButtonElement)
.disabled,
).toBe(false);
});
test('共创授权已达全开放时入口变为只读展示', async () => {
vi.mocked(listMyGames).mockResolvedValue([
createGame({ forkAuthorization: 'full' }),
]);
renderPage(createAuthValue());
expect(await screen.findByText('共创授权已达上限')).toBeTruthy();
expect(screen.queryByRole('button', { name: '共创授权' })).toBeNull();
});
@@ -4,6 +4,14 @@ import { ArrowLeft, RefreshCcw } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { GameDistributionRecoveryAction } from '../../../packages/shared/src/contracts/gameDistribution';
import {
canUpgradeGameForkAuthorization,
GAME_DISTRIBUTION_FORK_AUTHORIZATIONS,
GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS,
GAME_DISTRIBUTION_FORK_AUTHORIZATION_SHORT_LABELS,
type GameDistributionForkAuthorization,
resolveGameForkAuthorization,
} from '../../../packages/shared/src/contracts/gameDistribution';
import {
cancelGameVersion,
deleteMyGame,
@@ -14,6 +22,7 @@ import {
loadVersionRecoveryActions,
submitGameVersion,
unpublishGame,
updateGameForkAuthorization,
} from '../../services/gameDistributionClient';
import { useAuthUi } from '../auth/AuthUiContext';
import { PlatformActionButton } from '../common/PlatformActionButton';
@@ -83,6 +92,12 @@ function createDeleteKey(gameId: string) {
return `game-delete-${gameId}-${Date.now()}-${deleteKeySeed}`;
}
let forkAuthorizationKeySeed = 0;
function createForkAuthorizationKey(gameId: string) {
forkAuthorizationKeySeed += 1;
return `game-fork-authorization-${gameId}-${Date.now()}-${forkAuthorizationKeySeed}`;
}
export type MyGameStatusFilter =
| 'all'
| 'published'
@@ -126,6 +141,12 @@ const VERSION_FILTER_STATUSES: Record<
closed: { cancelled: true, revoked: true },
};
/** 行内授权设置的目标档位;两段式确认后才会发出请求。 */
type ForkAuthorizationDraft = {
gameId: string;
target: GameDistributionForkAuthorization;
};
function resolveVersionStatus(game: GameDistributionMyGame) {
const latest = game.latestVersion;
if (!latest) return null;
@@ -169,6 +190,12 @@ export function MyGamesPage({
const [statusFilter, setStatusFilter] = useState<MyGameStatusFilter>('all');
const [versionFilter, setVersionFilter] =
useState<MyGameVersionFilter>('all');
const [forkAuthorizationEditorGameId, setForkAuthorizationEditorGameId] =
useState('');
const [pendingForkAuthorization, setPendingForkAuthorization] =
useState<ForkAuthorizationDraft | null>(null);
const [savingForkAuthorizationGameId, setSavingForkAuthorizationGameId] =
useState('');
const [notice, setNotice] = useState('');
const [error, setError] = useState('');
const canLoad = Boolean(authUi?.user && authUi.canAccessProtectedData);
@@ -312,6 +339,38 @@ export function MyGamesPage({
}
}
async function handleUpdateForkAuthorization(
game: GameDistributionMyGame,
target: GameDistributionForkAuthorization,
expected: GameDistributionForkAuthorization,
) {
setSavingForkAuthorizationGameId(game.id);
setForkAuthorizationEditorGameId('');
setPendingForkAuthorization(null);
setError('');
setNotice('');
try {
await updateGameForkAuthorization(
game.id,
target,
expected,
createForkAuthorizationKey(game.id),
);
setNotice(
`《${game.title}》的共创授权已更新为「${GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS[target]}」。`,
);
await loadGames();
} catch (updateError: unknown) {
setError(
updateError instanceof Error
? updateError.message
: '设置共创授权失败',
);
} finally {
setSavingForkAuthorizationGameId('');
}
}
async function handleUnpublish(game: GameDistributionMyGame) {
setBusyGameId(game.id);
setError('');
@@ -492,6 +551,36 @@ export function MyGamesPage({
const resumeLabel = resolveDraftResumeLabel(game);
const versions = game.versions;
const isHistoryOpen = expandedGameId === game.id;
const forkAuthorization = resolveGameForkAuthorization(
game.forkAuthorization,
);
const forkAuthorizationLabel =
GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS[
forkAuthorization
];
const isForkAuthorizationEditing =
forkAuthorizationEditorGameId === game.id;
const forkAuthorizationTarget =
pendingForkAuthorization?.gameId === game.id
? pendingForkAuthorization.target
: null;
const isSavingForkAuthorization =
savingForkAuthorizationGameId === game.id;
const forkAuthorizationTargetLabel = forkAuthorizationTarget
? GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS[
forkAuthorizationTarget
]
: '';
const forkAuthorizationOptions =
GAME_DISTRIBUTION_FORK_AUTHORIZATIONS.map((option) => ({
option,
label: GAME_DISTRIBUTION_FORK_AUTHORIZATION_SHORT_LABELS[option],
fullLabel: GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS[option],
canUpgrade: canUpgradeGameForkAuthorization(
forkAuthorization,
option,
),
}));
return (
<article key={game.id} className="my-game-card">
<GameCover game={game} compact />
@@ -523,6 +612,8 @@ export function MyGamesPage({
</span>
</>
) : null}
<span>·</span>
<span>共创:{forkAuthorizationLabel}</span>
</div>
{version?.reviewReason ? (
<p className="my-game-card__reason">
@@ -668,6 +759,100 @@ export function MyGamesPage({
</PlatformActionButton>
)
) : null}
{forkAuthorization === 'full' ? (
<span className="my-game-status my-game-status--fork-full">
共创授权已达上限
</span>
) : isForkAuthorizationEditing ? (
forkAuthorizationTarget ? (
<>
<span className="my-game-card__fork-confirm">
确认提升为「{forkAuthorizationTargetLabel}」?
提升后不可撤销。
</span>
<PlatformActionButton
tone="danger"
size="sm"
shape="pill"
disabled={isSavingForkAuthorization}
onClick={() =>
void handleUpdateForkAuthorization(
game,
forkAuthorizationTarget,
forkAuthorization,
)
}
>
确认提升
</PlatformActionButton>
<PlatformActionButton
tone="secondary"
size="sm"
shape="pill"
disabled={isSavingForkAuthorization}
onClick={() => setPendingForkAuthorization(null)}
>
取消
</PlatformActionButton>
</>
) : (
<>
{forkAuthorizationOptions.map(
({ option, label, canUpgrade, fullLabel }) => (
<PlatformActionButton
key={option}
tone={
option === forkAuthorization
? 'primary'
: 'secondary'
}
size="sm"
shape="pill"
disabled={
isSavingForkAuthorization || !canUpgrade
}
title={
canUpgrade
? `提升为「${fullLabel}」`
: '共创授权只能提升,不能降级'
}
onClick={() =>
setPendingForkAuthorization({
gameId: game.id,
target: option,
})
}
>
{label}
</PlatformActionButton>
),
)}
<PlatformActionButton
tone="secondary"
size="sm"
shape="pill"
onClick={() =>
setForkAuthorizationEditorGameId('')
}
>
取消
</PlatformActionButton>
</>
)
) : (
<PlatformActionButton
tone="secondary"
size="sm"
shape="pill"
disabled={isSavingForkAuthorization}
onClick={() => {
setPendingForkAuthorization(null);
setForkAuthorizationEditorGameId(game.id);
}}
>
共创授权
</PlatformActionButton>
)}
<PlatformActionButton
tone="danger"
size="sm"
@@ -382,6 +382,43 @@
font-weight: 800;
letter-spacing: 0.16em;
}
/*
* 共创资料卡:详情页通过共享展示组件的 `infoCards` 槽位渲染,卡片外壳
* (`.game-detail-info-card`)与内联 flex 的 value 包裹层都由共享组件提供。
* 这里把卡内布局改成纵向栅格,免得授权徽章、代际与改编次数挤在一行里被侧栏裁掉;
* 选择器前缀沿用共享组件的类名,保证优先级高于共享样式的 `.game-detail-info-card span`。
*/
.game-detail-info-card .game-fork-card {
display: grid;
justify-items: start;
gap: 0.3rem;
white-space: normal;
}
.game-detail-info-card .game-fork-badge {
border: 1px solid var(--platform-surface-border);
border-radius: 999px;
padding: 0.2rem 0.6rem;
background: var(--platform-surface-fill);
color: var(--platform-text-soft);
font-size: 0.7rem;
font-weight: 800;
}
.game-detail-info-card .game-fork-badge--nonCommercial {
background: var(--platform-success-bg);
color: var(--platform-success-text);
}
.game-detail-info-card .game-fork-badge--full {
background: rgba(199, 101, 61, 0.14);
color: var(--platform-accent-strong);
}
.game-detail-info-card .game-fork-lineage {
color: var(--platform-text-base);
font-weight: 700;
}
.game-detail-info-card .game-fork-lineage__author,
.game-detail-info-card .game-fork-derived {
color: var(--platform-text-soft);
}
.game-loading-panel {
display: grid;
min-height: 18rem;
@@ -727,6 +764,7 @@
.my-game-card__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-top: auto;
padding-top: 0.35rem;
@@ -748,6 +786,16 @@
background: var(--platform-button-danger-fill);
color: var(--platform-button-danger-text);
}
.my-game-status--fork-full {
background: rgba(199, 101, 61, 0.14);
color: var(--platform-accent-strong);
}
.my-game-card__fork-confirm {
flex: 1 1 100%;
color: var(--platform-text-soft);
font-size: 0.72rem;
line-height: 1.5;
}
@media (max-width: 560px) {
.my-game-card {
flex-direction: column;