后台新增游戏管理页与全量游戏列表接口
Project CI / AI game creator shell Rust crates (push) Successful in 1m23s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m0s
Project CI / Backend tests (push) Successful in 5m45s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m51s
Project CI / Native shell tests (push) Successful in 7m25s
Project CI / Frontend tests (push) Successful in 2m33s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 10m6s
Project CI / Repository checks (push) Successful in 2m1s
Project CI / AI game creator shell web tests (push) Successful in 1m32s

- SpacetimeDB 新增 list_admin_game_distribution_games_and_return:一个事务内返回全量游戏、版本数与最近 20 个版本,作者名/头像读时联 user_account
- SpacetimeDB 新增 restore_game_distribution_game_and_return:恢复管理员暂停的游戏时重新激活最近一次由管理员暂停撤回的公开版本,作者自行下架的版本不会被恢复
- 后台新增 GET /admin/api/game-distribution/games 与 POST /admin/api/game-distribution/games/{gameId}/restore,并新增 game-management Tab 权限,待审队列仍只属于 editor-showcase
- admin-web 新增 #game-management「游戏管理」页:标题/作者/gameId/状态/版本数/游玩数表格,行内下架、恢复与版本历史弹层,复用现有后台表格与二次确认
- 同步生成 spacetime 绑定并更新后端架构数据契约文档
This commit is contained in:
2026-09-23 21:46:40 +08:00
parent cf19241544
commit cc958f3678
26 changed files with 1883 additions and 14 deletions
@@ -8,10 +8,12 @@ import {
getAdminUserDetail,
importAdminAgcTemplates,
listAdminAgcTrackingEvents,
listAdminGameDistributionGames,
listAdminGameDistributionReviews,
listAdminRechargeOrders,
reconcileAdminUserConsumption,
resolveAdminRechargeRefundManualReview,
restoreAdminGameDistributionGame,
reviewAdminGameDistributionVersion,
suspendAdminGameDistributionGame,
updateAdminAccount,
@@ -525,6 +527,85 @@ test('游戏审核列表与审核动作使用约定的 URL、方法和幂等键'
);
});
test('游戏管理列表与恢复动作使用约定的 URL、方法和幂等键', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify({ ok: true, data: { games: [] } }), {
status: 200,
}),
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({
ok: true,
data: {
game: {
id: 'game/1',
title: '测试游戏',
status: 'published',
publicationRevision: 10,
},
replayed: false,
},
}),
{ status: 200 },
),
);
vi.stubGlobal('fetch', fetchMock);
const controller = new AbortController();
await listAdminGameDistributionGames(
'admin-token',
{ limit: 80 },
controller.signal,
);
await restoreAdminGameDistributionGame(
'admin-token',
' game/1 ',
' game-restore-key-1 ',
{ expectedPublicationRevision: 9 },
);
expect(fetchMock.mock.calls[0]?.[0]).toBe(
'/admin/api/game-distribution/games?limit=50',
);
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
method: 'GET',
signal: controller.signal,
headers: expect.objectContaining({
Authorization: 'Bearer admin-token',
}),
}),
);
expect(fetchMock.mock.calls[1]?.[0]).toBe(
'/admin/api/game-distribution/games/game%2F1/restore',
);
expect(fetchMock.mock.calls[1]?.[1]).toEqual(
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: 'Bearer admin-token',
'Idempotency-Key': 'game-restore-key-1',
}),
body: JSON.stringify({ expectedPublicationRevision: 9 }),
}),
);
expect(() =>
restoreAdminGameDistributionGame('admin-token', ' ', 'key', {
expectedPublicationRevision: 9,
}),
).toThrow('缺少游戏 ID');
expect(() =>
restoreAdminGameDistributionGame('admin-token', 'game-1', ' ', {
expectedPublicationRevision: 9,
}),
).toThrow('恢复幂等键必须是 1 到 128 个字符');
expect(fetchMock).toHaveBeenCalledTimes(2);
});
test('安全下架请求携带公开修订号、原因与幂等键', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
+43
View File
@@ -32,6 +32,9 @@ import type {
AdminExternalApiKeyListQuery,
AdminExternalApiKeyListResponse,
AdminFeatureGateConfigResponse,
AdminGameDistributionGameListResponse,
AdminGameDistributionRestoreRequest,
AdminGameDistributionRestoreResponse,
AdminGameDistributionReviewListResponse,
AdminGameDistributionReviewRequest,
AdminGameDistributionReviewResponse,
@@ -1240,6 +1243,46 @@ export function listAdminGameDistributionReviews(token: string, limit = 48) {
);
}
export function listAdminGameDistributionGames(
token: string,
options: { limit?: number } = {},
signal?: AbortSignal,
) {
const requestedLimit = options.limit ?? 50;
const normalizedLimit = Number.isFinite(requestedLimit)
? Math.min(Math.max(Math.trunc(requestedLimit), 1), 50)
: 50;
return request<AdminGameDistributionGameListResponse>(
`/admin/api/game-distribution/games?limit=${normalizedLimit}`,
{ token, signal },
);
}
export function restoreAdminGameDistributionGame(
token: string,
gameId: string,
idempotencyKey: string,
payload: AdminGameDistributionRestoreRequest,
) {
const normalizedGameId = gameId.trim();
const normalizedKey = idempotencyKey.trim();
if (!normalizedGameId) {
throw new Error('缺少游戏 ID');
}
if (!normalizedKey || normalizedKey.length > 128) {
throw new Error('恢复幂等键必须是 1 到 128 个字符');
}
return request<AdminGameDistributionRestoreResponse>(
`/admin/api/game-distribution/games/${encodeURIComponent(normalizedGameId)}/restore`,
{
method: 'POST',
token,
headers: { 'Idempotency-Key': normalizedKey },
body: payload,
},
);
}
/**
* 审核游戏发行版本。幂等键由调用方生成并在同一次提交内复用,避免重复点击产生
* 两条审核结论。
+51
View File
@@ -1132,6 +1132,57 @@ export interface AdminGameDistributionSuspendResponse {
replayed: boolean;
}
export interface AdminGameDistributionGameVersionEntry {
versionId: string;
gameId: string;
versionNumber: number;
status: string;
reviewReason: string | null;
packageBytes: number;
packageSha256: string;
createdAt: string;
updatedAt: string;
reviewedAt: string | null;
publishedAt: string | null;
entryUrl: string | null;
}
export interface AdminGameDistributionGameEntry {
gameId: string;
title: string;
author: {
id: string;
name: string;
avatarUrl: string | null;
};
status: string;
versionCount: number;
playCount: number;
activeVersionId: string | null;
publicationRevision: number;
createdAt: string;
updatedAt: string;
versions: AdminGameDistributionGameVersionEntry[];
}
export interface AdminGameDistributionGameListResponse {
games: AdminGameDistributionGameEntry[];
}
export interface AdminGameDistributionRestoreRequest {
expectedPublicationRevision: number;
}
export interface AdminGameDistributionRestoreResponse {
game: {
id: string;
title: string;
status: string;
publicationRevision: number;
};
replayed: boolean;
}
export interface AdminAgcTemplatePayload {
id: string;
title: string;
+7
View File
@@ -29,6 +29,7 @@ import { AdminEditorGenerationPricingPage } from '../pages/AdminEditorGeneration
import { AdminEditorShowcaseReviewPage } from '../pages/AdminEditorShowcaseReviewPage';
import { AdminErrorReportsPage } from '../pages/AdminErrorReportsPage';
import { AdminGameDistributionReviewPage } from '../pages/AdminGameDistributionReviewPage';
import { AdminGameManagementPage } from '../pages/AdminGameManagementPage';
import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage';
import { AdminInviteCodePage } from '../pages/AdminInviteCodePage';
import { AdminLoginPage } from '../pages/AdminLoginPage';
@@ -321,6 +322,12 @@ export function AdminApp() {
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'game-management' ? (
<AdminGameManagementPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'editor-assets' ? (
<AdminEditorAssetQueryPage
token={token}
+2
View File
@@ -14,6 +14,7 @@ import {
ReceiptText,
ShieldCheck,
Star,
Swords,
Table2,
TicketCheck,
TicketPercent,
@@ -52,6 +53,7 @@ const routeIcons = {
'editor-generation-pricing': Coins,
'editor-showcase': Star,
'game-distribution': Gamepad2,
'game-management': Swords,
'editor-assets': Images,
'project-snapshots': FolderArchive,
accounts: Users,
@@ -168,6 +168,16 @@ test('后台游戏审核路由可通过导航和 hash 访问', () => {
expect(routeHash('game-distribution')).toBe('#game-distribution');
});
test('后台游戏管理路由可通过导航和 hash 访问', () => {
expect(adminRoutes).toContainEqual({
id: 'game-management',
label: '游戏管理',
hash: '#game-management',
});
expect(resolveAdminRoute('#game-management')).toBe('game-management');
expect(routeHash('game-management')).toBe('#game-management');
});
test('member 可单独获得游戏审核 Tab 权限', () => {
const routes = getAccessibleAdminRoutes({
accountRole: 'member',
+2
View File
@@ -17,6 +17,7 @@ export type AdminRouteId =
| 'editor-generation-pricing'
| 'editor-showcase'
| 'game-distribution'
| 'game-management'
| 'editor-assets'
| 'project-snapshots'
| 'agc-models'
@@ -60,6 +61,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
{ id: 'agc-templates', label: '模板管理', hash: '#agc-templates' },
{ id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' },
{ id: 'game-distribution', label: '游戏审核', hash: '#game-distribution' },
{ id: 'game-management', label: '游戏管理', hash: '#game-management' },
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
{ id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' },
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
@@ -0,0 +1,265 @@
/* @vitest-environment jsdom */
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import {
listAdminGameDistributionGames,
restoreAdminGameDistributionGame,
suspendAdminGameDistributionGame,
} from '../api/adminApiClient';
import type {
AdminGameDistributionGameEntry,
AdminGameDistributionGameVersionEntry,
} from '../api/adminApiTypes';
import { AdminGameManagementPage } from './AdminGameManagementPage';
vi.mock('../api/adminApiClient', () => ({
isAdminApiError: vi.fn(
(error: unknown) =>
typeof error === 'object' &&
error !== null &&
'status' in error &&
typeof error.status === 'number',
),
formatAdminApiError: vi.fn((error: unknown) =>
error instanceof Error ? error.message : '请求失败',
),
listAdminGameDistributionGames: vi.fn(),
restoreAdminGameDistributionGame: vi.fn(),
suspendAdminGameDistributionGame: vi.fn(),
}));
const version: AdminGameDistributionGameVersionEntry = {
versionId: 'version-3',
gameId: 'game_1',
versionNumber: 3,
status: 'published',
reviewReason: '测试原因',
packageBytes: 2048,
packageSha256:
'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789',
createdAt: '2026-09-20T08:00:00Z',
updatedAt: '2026-09-20T08:00:00Z',
reviewedAt: '2026-09-20T09:00:00Z',
publishedAt: '2026-09-20T10:00:00Z',
entryUrl: 'https://game.example.com/game_1',
};
const publishedGame: AdminGameDistributionGameEntry = {
gameId: 'game_1',
title: '测试游戏',
author: {
id: 'user_1',
name: '作者甲',
avatarUrl: 'https://example.com/avatar.png',
},
status: 'published',
versionCount: 21,
playCount: 345,
activeVersionId: 'version-3',
publicationRevision: 4,
createdAt: '2026-09-18T08:00:00Z',
updatedAt: '2026-09-20T10:00:00Z',
versions: [version],
};
const suspendedGame: AdminGameDistributionGameEntry = {
gameId: 'game_2',
title: '下架游戏',
author: {
id: 'user_2',
name: '作者乙',
avatarUrl: null,
},
status: 'suspended',
versionCount: 2,
playCount: 8,
activeVersionId: null,
publicationRevision: 7,
createdAt: '2026-09-19T08:00:00Z',
updatedAt: '2026-09-21T08:00:00Z',
versions: [],
};
beforeEach(() => {
vi.mocked(listAdminGameDistributionGames)
.mockReset()
.mockResolvedValue({ games: [publishedGame] });
vi.mocked(restoreAdminGameDistributionGame)
.mockReset()
.mockResolvedValue({
game: {
id: suspendedGame.gameId,
title: suspendedGame.title,
status: 'published',
publicationRevision: 8,
},
replayed: false,
});
vi.mocked(suspendAdminGameDistributionGame)
.mockReset()
.mockResolvedValue({
game: {
id: publishedGame.gameId,
title: publishedGame.title,
status: 'suspended',
publicationRevision: 5,
},
replayed: false,
});
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
test('列表展示作者头像、状态、版本数和游玩数', async () => {
vi.mocked(listAdminGameDistributionGames).mockResolvedValue({
games: [publishedGame, suspendedGame],
});
render(
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
);
const publishedRow = (await screen.findByText('测试游戏')).closest('tr')!;
expect(within(publishedRow).getByText('作者甲')).toBeTruthy();
const avatar = within(publishedRow).getByRole('img', {
name: '作者甲 头像',
});
expect(avatar.getAttribute('src')).toBe('https://example.com/avatar.png');
expect(within(publishedRow).getByText('已公开')).toBeTruthy();
expect(within(publishedRow).getByText('21')).toBeTruthy();
expect(within(publishedRow).getByText('345')).toBeTruthy();
const suspendedRow = (await screen.findByText('下架游戏')).closest('tr')!;
expect(within(suspendedRow).getByText('作者乙')).toBeTruthy();
expect(suspendedRow.querySelector('.admin-user-avatar')?.textContent).toBe(
'作',
);
expect(within(suspendedRow).getByText('已下架')).toBeTruthy();
});
test('恢复按钮只在下架态出现,成功后携带幂等键并刷新列表', async () => {
vi.mocked(listAdminGameDistributionGames)
.mockResolvedValueOnce({ games: [suspendedGame] })
.mockResolvedValueOnce({
games: [{ ...suspendedGame, status: 'published' }],
});
render(
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('下架游戏');
expect(screen.queryByRole('button', { name: '下架' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '恢复' }));
expect(restoreAdminGameDistributionGame).not.toHaveBeenCalled();
await screen.findByRole('dialog');
fireEvent.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() =>
expect(restoreAdminGameDistributionGame).toHaveBeenCalledTimes(1),
);
const [token, gameId, idempotencyKey, payload] =
vi.mocked(restoreAdminGameDistributionGame).mock.calls[0] ?? [];
expect(token).toBe('admin-token');
expect(gameId).toBe('game_2');
expect(String(idempotencyKey)).toContain('game_2');
expect(payload).toEqual({ expectedPublicationRevision: 7 });
await waitFor(() =>
expect(listAdminGameDistributionGames).toHaveBeenCalledTimes(2),
);
expect(await screen.findByText('游戏《下架游戏》已恢复')).toBeTruthy();
});
test('下架需要原因和二次确认,提交原因与当前公开修订号', async () => {
render(
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('测试游戏');
fireEvent.change(screen.getByLabelText('下架原因'), {
target: { value: '违规内容' },
});
fireEvent.click(screen.getByRole('button', { name: '下架' }));
expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
await screen.findByRole('dialog');
fireEvent.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() =>
expect(suspendAdminGameDistributionGame).toHaveBeenCalledTimes(1),
);
const [token, gameId, idempotencyKey, payload] =
vi.mocked(suspendAdminGameDistributionGame).mock.calls[0] ?? [];
expect(token).toBe('admin-token');
expect(gameId).toBe('game_1');
expect(String(idempotencyKey)).toContain('game_1');
expect(payload).toEqual({
expectedPublicationRevision: 4,
reason: '违规内容',
});
expect(await screen.findByText('游戏《测试游戏》已下架')).toBeTruthy();
});
test('版本历史弹层展示版本条目与统计信息', async () => {
render(
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('测试游戏');
fireEvent.click(screen.getByRole('button', { name: '版本历史' }));
const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByText('共 21 个版本,展示最近 20 个')).toBeTruthy();
expect(within(dialog).getByText('v3')).toBeTruthy();
expect(within(dialog).getByText('published')).toBeTruthy();
expect(within(dialog).getByText('2.0 KiB')).toBeTruthy();
expect(within(dialog).getByText('abcdef012345')).toBeTruthy();
expect(within(dialog).getByText('测试原因')).toBeTruthy();
expect(
within(dialog).getByText('https://game.example.com/game_1'),
).toBeTruthy();
});
test('接口失败显示错误文案', async () => {
vi.mocked(listAdminGameDistributionGames).mockRejectedValue(
new Error('游戏列表读取失败'),
);
render(
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
);
expect(await screen.findByText('游戏列表读取失败')).toBeTruthy();
});
test('401 交给 onUnauthorized 处理', async () => {
const onUnauthorized = vi.fn();
vi.mocked(listAdminGameDistributionGames).mockRejectedValue(
Object.assign(new Error('未授权'), { status: 401 }),
);
render(
<AdminGameManagementPage
token="admin-token"
onUnauthorized={onUnauthorized}
/>,
);
await waitFor(() =>
expect(onUnauthorized).toHaveBeenCalledWith('登录状态已失效'),
);
expect(screen.queryByRole('alert')).toBeNull();
});
@@ -0,0 +1,456 @@
import { RefreshCcw, X } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import {
listAdminGameDistributionGames,
restoreAdminGameDistributionGame,
suspendAdminGameDistributionGame,
} from '../api/adminApiClient';
import type {
AdminGameDistributionGameEntry,
AdminGameDistributionGameVersionEntry,
} from '../api/adminApiTypes';
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
import { handlePageError } from './pageUtils';
interface AdminGameManagementPageProps {
token: string;
onUnauthorized: (message?: string) => void;
}
const GAME_STATUS_META: Record<string, { label: string; className: string }> = {
published: { label: '已公开', className: 'admin-status-ok' },
suspended: { label: '已下架', className: 'admin-status-error' },
unpublished: { label: '未公开', className: 'admin-status-pending' },
};
function gameStatusMeta(status: string) {
return (
GAME_STATUS_META[status] ?? {
label: status || '—',
className: 'admin-status-pending',
}
);
}
function formatBytes(value: number) {
if (value >= 1024 * 1024) {
return `${(value / (1024 * 1024)).toFixed(1)} MiB`;
}
if (value >= 1024) {
return `${(value / 1024).toFixed(1)} KiB`;
}
return `${value} B`;
}
function formatTime(value: string) {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return parsed.toLocaleString('zh-CN', { hour12: false });
}
function formatOptionalTime(value: string | null) {
return value ? formatTime(value) : '—';
}
function authorName(entry: AdminGameDistributionGameEntry) {
return entry.author?.name?.trim() || '—';
}
function authorInitial(entry: AdminGameDistributionGameEntry) {
const name = authorName(entry);
return name === '—' ? '—' : (Array.from(name)[0] ?? '—');
}
function createGameActionIdempotencyKey(
action: 'suspend' | 'restore',
gameId: string,
) {
const random =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
const prefix = action === 'suspend' ? 'game-suspend' : 'game-restore';
return `${prefix}-${gameId}-${random}`.slice(0, 128);
}
export function AdminGameManagementPage({
token,
onUnauthorized,
}: AdminGameManagementPageProps) {
const [games, setGames] = useState<AdminGameDistributionGameEntry[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [busyGameId, setBusyGameId] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const [statusMessage, setStatusMessage] = useState('');
const [suspendReasonByGame, setSuspendReasonByGame] = useState<
Record<string, string>
>({});
const [versionGame, setVersionGame] =
useState<AdminGameDistributionGameEntry | null>(null);
const writeConfirm = useAdminWriteConfirm();
const loadGames = useCallback(async () => {
setIsLoading(true);
setErrorMessage('');
try {
const response = await listAdminGameDistributionGames(token, {
limit: 50,
});
setGames(response.games);
} catch (error) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsLoading(false);
}
}, [token, onUnauthorized]);
useEffect(() => {
void loadGames();
}, [loadGames]);
useEffect(() => {
if (!versionGame) return undefined;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setVersionGame(null);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [versionGame]);
async function suspendGame(entry: AdminGameDistributionGameEntry) {
const reason = (suspendReasonByGame[entry.gameId] ?? '').trim();
if (!reason) {
setErrorMessage('下架原因不能为空');
setStatusMessage('');
return;
}
const confirmed = await writeConfirm.confirmWrite({
action: '下架游戏',
target: `${entry.title || entry.gameId}${entry.gameId}`,
});
if (!confirmed) return;
setBusyGameId(entry.gameId);
setErrorMessage('');
setStatusMessage('');
try {
await suspendAdminGameDistributionGame(
token,
entry.gameId,
createGameActionIdempotencyKey('suspend', entry.gameId),
{
expectedPublicationRevision: entry.publicationRevision,
reason,
},
);
setStatusMessage(`游戏《${entry.title || entry.gameId}》已下架`);
setSuspendReasonByGame((current) => ({
...current,
[entry.gameId]: '',
}));
await loadGames();
} catch (error) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setBusyGameId('');
}
}
async function restoreGame(entry: AdminGameDistributionGameEntry) {
const confirmed = await writeConfirm.confirmWrite({
action: '恢复游戏',
target: `${entry.title || entry.gameId}${entry.gameId}`,
});
if (!confirmed) return;
setBusyGameId(entry.gameId);
setErrorMessage('');
setStatusMessage('');
try {
await restoreAdminGameDistributionGame(
token,
entry.gameId,
createGameActionIdempotencyKey('restore', entry.gameId),
{ expectedPublicationRevision: entry.publicationRevision },
);
setStatusMessage(`游戏《${entry.title || entry.gameId}》已恢复`);
await loadGames();
} catch (error) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setBusyGameId('');
}
}
return (
<section className="admin-page admin-page-wide">
<div className="admin-page-heading">
<h1></h1>
<button
type="button"
className="admin-secondary-button"
onClick={() => void loadGames()}
disabled={isLoading}
>
<RefreshCcw aria-hidden="true" />
</button>
</div>
{errorMessage ? (
<div className="admin-alert admin-alert-warning" role="alert">
{errorMessage}
</div>
) : null}
{statusMessage ? (
<div className="admin-alert admin-alert-success" role="status">
{statusMessage}
</div>
) : null}
<div className="admin-panel">
<div className="admin-panel-heading">
<h2></h2>
<span className="admin-muted-text"> {games.length} </span>
</div>
{isLoading ? (
<p className="admin-muted-text"></p>
) : null}
{!isLoading && games.length === 0 ? (
<p className="admin-muted-text"></p>
) : null}
{!isLoading && games.length > 0 ? (
<div className="admin-table-wrap">
<table className="admin-table admin-table-wide">
<thead>
<tr>
<th></th>
<th></th>
<th>gameId</th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{games.map((entry) => {
const status = gameStatusMeta(entry.status);
const isSuspended = entry.status === 'suspended';
const busy = busyGameId === entry.gameId;
return (
<tr key={entry.gameId}>
<td>
<strong>{entry.title?.trim() || '—'}</strong>
</td>
<td>
<div
className="admin-database-user-cell"
style={{ justifyContent: 'flex-start' }}
>
<div
className="admin-user-avatar"
style={{
width: 32,
height: 32,
flex: '0 0 32px',
fontSize: 13,
}}
>
{entry.author?.avatarUrl ? (
<img
alt={`${authorName(entry)} 头像`}
src={entry.author.avatarUrl}
/>
) : (
authorInitial(entry)
)}
</div>
<div>
<span>{authorName(entry)}</span>
<small>{entry.author?.id?.trim() || '—'}</small>
</div>
</div>
</td>
<td>
<code>{entry.gameId}</code>
</td>
<td>
<span className={`admin-status ${status.className}`}>
{status.label}
</span>
</td>
<td>{entry.versionCount}</td>
<td>{entry.playCount}</td>
<td>
<div className="admin-action-row">
{!isSuspended ? (
<>
<div className="admin-field">
<label
htmlFor={`game-suspend-reason-${entry.gameId}`}
>
</label>
<input
id={`game-suspend-reason-${entry.gameId}`}
value={
suspendReasonByGame[entry.gameId] ?? ''
}
onChange={(event) =>
setSuspendReasonByGame((current) => ({
...current,
[entry.gameId]: event.target.value,
}))
}
disabled={busy}
/>
</div>
<button
type="button"
className="admin-danger-button"
disabled={busy}
onClick={() => void suspendGame(entry)}
>
{busy ? '处理中…' : '下架'}
</button>
</>
) : (
<button
type="button"
className="admin-primary-button"
disabled={busy}
onClick={() => void restoreGame(entry)}
>
{busy ? '处理中…' : '恢复'}
</button>
)}
<button
type="button"
className="admin-secondary-button"
disabled={busy}
onClick={() => setVersionGame(entry)}
>
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : null}
</div>
{versionGame ? (
<div
className="admin-confirm-backdrop"
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
setVersionGame(null);
}
}}
>
<section
aria-labelledby="admin-game-version-history-title"
aria-modal="true"
className="admin-detail-panel"
role="dialog"
>
<div className="admin-panel-heading">
<div>
<h2 id="admin-game-version-history-title"></h2>
<span>
{versionGame.title?.trim() || '—'}{versionGame.gameId}
</span>
</div>
<button
aria-label="关闭版本历史"
className="admin-ghost-button"
type="button"
onClick={() => setVersionGame(null)}
>
<X size={17} aria-hidden="true" />
</button>
</div>
<p className="admin-muted-text">
{versionGame.versionCount} 20
</p>
{versionGame.versions.length === 0 ? (
<p className="admin-muted-text"></p>
) : (
<div className="admin-table-wrap">
<table className="admin-table admin-table-wide">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th>SHA</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{versionGame.versions.map((version) => (
<GameVersionRow
key={version.versionId}
version={version}
/>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
) : null}
{writeConfirm.confirmDialog}
</section>
);
}
function GameVersionRow({
version,
}: {
version: AdminGameDistributionGameVersionEntry;
}) {
return (
<tr>
<td>v{version.versionNumber}</td>
<td>{version.status || '—'}</td>
<td>{formatBytes(version.packageBytes)}</td>
<td>
<code>{version.packageSha256?.slice(0, 12) || '—'}</code>
</td>
<td>{formatOptionalTime(version.createdAt)}</td>
<td>{formatOptionalTime(version.reviewedAt)}</td>
<td>{formatOptionalTime(version.publishedAt)}</td>
<td>{version.reviewReason?.trim() || '—'}</td>
<td>
{version.entryUrl ? (
<a href={version.entryUrl} rel="noreferrer" target="_blank">
{version.entryUrl}
</a>
) : (
'—'
)}
</td>
</tr>
);
}
@@ -471,6 +471,15 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
- 作者回读投影:版本回读(作者本人)与审核回读(管理员)在版本 payload 上追加 `frozenMetadata`(冻结快照原样 JSON,历史版本为 `null`)。只有公开投影会剥掉素材 ID,作者与管理员拿到 `coverAssetId` / `screenshots[].assetId`,因此作者续发时可以直接复用同一批封面与截图素材,不需要为了沿用封面重新上传一次;素材 ID 缺失(旧版本)时前端必须要求作者重新选择封面,不能用对象键反推素材身份。
- 撤回与回读:`cancel_game_distribution_version_and_return` 只允许把未参与当前公开投影的版本推进到 `cancelled`,并要求 `expected_publication_revision` 与游戏公开修订号一致;`get_game_distribution_version_and_return` 供管理员按版本 ID 直读。客户端看到的 `recoveryAction``api-server``status` 派生,不落表。
### 后台游戏管理读模型与恢复动作(2026-09-23)
- 页面目标:后台新增「游戏管理」页,展示全量游戏(标题 / 作者名 + 头像 / gameId / 状态 / 版本数 / 游玩数),行内提供安全下架、恢复与版本历史;它是运营面的全量视图,不替代 `#game-distribution` 待审队列。
- 数据来源:新增只读 procedure `list_admin_game_distribution_games_and_return`(输入 `GameDistributionAdminGameListInput { limit }`)。它在同一事务里读 `game_distribution_game`,按 `by_game_distribution_version_game_id` 统计每个游戏的版本数并取最近 20 个版本;作者名与头像按 `user_account.user_id` 读时联 `display_name` / `avatar_url`(行内快照为空时以联表结果为准)。不新增表、不改 schema、不改公开投影。
- 恢复动作:新增 procedure `restore_game_distribution_game_and_return`(输入 `GameDistributionRestoreInput`)。它只允许管理员解除 `suspended`:重新激活该游戏最近一个由管理员暂停撤回(`status = revoked``published_at` 非空且 `reviewed_by_user_id` 非空)的版本,恢复 `visibility = published` 并递增 `publication_revision`;作者自行下架的版本不写审核者,因此不会被恢复动作重新公开。没有可恢复版本、`expected_publication_revision` CAS 不符或游戏不在暂停态时失败关闭。幂等收据复用 `game_distribution_idempotency_receipt`action = `restore`),恢复动作不受发布灰度开关限制,与安全下架同口径。
- 后台 HTTP`GET /admin/api/game-distribution/games?limit=` 返回 `{ games: [{ gameId, title, author{ id, name, avatarUrl }, status, versionCount, playCount, activeVersionId, publicationRevision, createdAt, updatedAt, versions: [...] }] }``POST /admin/api/game-distribution/games/{gameId}/restore` 要求 `Idempotency-Key``expectedPublicationRevision`,返回 `{ game, replayed }`。两者都走 `require_admin_auth`Tab 权限为 `game-management``editor-showcase`,不新增公开契约。
- 前端:`apps/admin-web` 新增 `#game-management` 路由与 `AdminGameManagementPage`,复用现有 `admin-table` 表格与 `useAdminWriteConfirm` 二次确认;版本历史在弹层内展示,长列表保持横向滚动。
- 验收:`cargo test -p api-server game_distribution``cargo test -p spacetime-module game_distribution``npm run spacetime:generate``npm run check:spacetime-schema`、admin-web 定向 Vitest + typecheck、`npm run check:encoding``git diff --check`
### `game_distribution_idempotency_receipt`
- Rust 结构体:`GameDistributionIdempotencyReceipt`
+50 -1
View File
@@ -2215,7 +2215,16 @@ fn admin_permission_requirement(_method: &Method, path: &str) -> AdminPermission
"/admin/api/editor-assets" => AnyTab(&["editor-assets"]),
"/admin/api/assets/read-url" => AnyTab(&["editor-assets", "editor-showcase"]),
path if path.starts_with("/admin/api/editor-showcase/") => AnyTab(&["editor-showcase"]),
path if path.starts_with("/admin/api/game-distribution/") => AnyTab(&["editor-showcase"]),
path if path.starts_with("/admin/api/game-distribution/reviews") => {
AnyTab(&["editor-showcase"])
}
path if path.starts_with("/admin/api/game-distribution/versions/") => {
AnyTab(&["editor-showcase"])
}
// 游戏管理页与审核页共享 games/* 面(列表、恢复、安全下架)。
path if path.starts_with("/admin/api/game-distribution/games") => {
AnyTab(&["editor-showcase", "game-management"])
}
"/admin/api/profile/redeem-codes" | "/admin/api/profile/redeem-codes/disable" => {
AnyTab(&["redeem"])
}
@@ -7435,6 +7444,11 @@ mod tests {
Method::GET,
"/admin/api/editor-showcase/assets",
),
(
"game-management",
Method::GET,
"/admin/api/game-distribution/games",
),
("editor-assets", Method::GET, "/admin/api/editor-assets"),
];
@@ -7453,6 +7467,41 @@ mod tests {
}
}
#[test]
fn game_management_tab_is_separate_from_game_review_queue() {
// 游戏管理页可以读全量游戏与恢复;待审队列仍只属于游戏审核页。
assert!(
enforce_admin_request_permission(
"member",
&["game-management".to_string()],
&[],
&Method::GET,
"/admin/api/game-distribution/games",
)
.is_ok()
);
assert!(
enforce_admin_request_permission(
"member",
&["game-management".to_string()],
&[],
&Method::POST,
"/admin/api/game-distribution/games/game_1/restore",
)
.is_ok()
);
assert!(
enforce_admin_request_permission(
"member",
&["game-management".to_string()],
&[],
&Method::GET,
"/admin/api/game-distribution/reviews",
)
.is_err()
);
}
#[test]
fn wallet_consumption_reconcile_requires_its_standalone_action_permission() {
assert!(
@@ -31,10 +31,12 @@ use shared_contracts::game_distribution::{
GameDistributionPublishMetadataSuggestion, GameDistributionPublishMetadataSuggestionRequest,
};
use spacetime_client::{
GameDistributionApproveRecordInput, GameDistributionCancelVersionRecordInput,
GameDistributionGameRecord, GameDistributionGetGameRecordInput,
GameDistributionPublicGameListRecordInput, GameDistributionPublicGameRecord,
GameDistributionRejectRecordInput, GameDistributionSubmitReviewRecordInput,
GameDistributionAdminGameListRecordInput, GameDistributionAdminGameRecord,
GameDistributionAdminVersionRecord, GameDistributionApproveRecordInput,
GameDistributionCancelVersionRecordInput, GameDistributionGameRecord,
GameDistributionGetGameRecordInput, GameDistributionPublicGameListRecordInput,
GameDistributionPublicGameRecord, GameDistributionRejectRecordInput,
GameDistributionRestoreRecordInput, GameDistributionSubmitReviewRecordInput,
GameDistributionSuspendRecordInput, GameDistributionUnpublishRecordInput,
GameDistributionVersionRecord, SpacetimeClientError,
};
@@ -61,6 +63,8 @@ pub(crate) const MAX_PACKAGE_CHUNK_REQUEST_BODY_BYTES: usize = PACKAGE_UPLOAD_CH
/// 分片偏移由客户端显式声明,服务端以对象当前长度为唯一权威。
const PACKAGE_UPLOAD_OFFSET_HEADER: &str = "x-genarrative-upload-offset";
const MAX_LIST_LIMIT: u32 = 48;
/// 后台游戏管理页全量列表上限,与 spacetime-module 的 admin game list limit 保持同口径。
const MAX_ADMIN_GAME_LIST_LIMIT: u32 = 200;
const MAX_IDEMPOTENCY_KEY_CHARS: usize = 128;
const MAX_PACKAGE_MANIFEST_JSON_BYTES: usize = 2 * 1024 * 1024;
/// 首版截图上限,与主规范冻结口径一致。
@@ -175,6 +179,17 @@ struct AdminSuspendRequest {
reason: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AdminGameListQuery {
limit: Option<u32>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AdminRestoreGameRequest {
expected_publication_revision: u64,
}
pub fn router(state: AppState) -> Router<AppState> {
let protected = Router::new()
.route(
@@ -242,10 +257,15 @@ pub fn router(state: AppState) -> Router<AppState> {
"/admin/api/game-distribution/versions/{version_id}",
get(admin_get_version),
)
.route("/admin/api/game-distribution/games", get(admin_list_games))
.route(
"/admin/api/game-distribution/games/{game_id}/suspend",
post(admin_suspend_game),
)
.route(
"/admin/api/game-distribution/games/{game_id}/restore",
post(admin_restore_game),
)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
@@ -1394,6 +1414,37 @@ async fn admin_list_reviews(
))
}
async fn admin_list_games(
State(state): State<AppState>,
Extension(ctx): Extension<RequestContext>,
Extension(_admin): Extension<AuthenticatedAdmin>,
Query(query): Query<AdminGameListQuery>,
) -> Result<Json<Value>, AppError> {
let limit = query
.limit
.unwrap_or(MAX_ADMIN_GAME_LIST_LIMIT)
.min(MAX_ADMIN_GAME_LIST_LIMIT);
let games = state
.spacetime_client()
.list_admin_game_distribution_games(GameDistributionAdminGameListRecordInput { limit })
.await
.map_err(map_spacetime_error)?;
info!(
request_id = ctx.request_id(),
operation = "admin_games_listed",
games = games.len(),
limit,
elapsed_ms = ctx.elapsed(),
"后台读取全量发行游戏"
);
Ok(json_success_body(
Some(&ctx),
json!({
"games": games.iter().map(admin_game_payload).collect::<Vec<_>>(),
}),
))
}
async fn admin_review_version(
State(state): State<AppState>,
Extension(ctx): Extension<RequestContext>,
@@ -1639,6 +1690,51 @@ async fn admin_suspend_game(
))
}
async fn admin_restore_game(
State(state): State<AppState>,
Extension(ctx): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
headers: HeaderMap,
Path(game_id): Path<String>,
Json(payload): Json<AdminRestoreGameRequest>,
) -> Result<Json<Value>, AppError> {
let idempotency_key = idempotency_key(&headers)?;
let admin_user_id = admin.session().subject.clone();
let request_digest = compute_request_digest(
&serde_json::to_vec(&(game_id.as_str(), payload.expected_publication_revision))
.map_err(|error| internal(error.to_string()))?,
);
let log_game_id = game_id.clone();
let log_admin_user_id = admin_user_id.clone();
let game = state
.spacetime_client()
.restore_game_distribution_game(GameDistributionRestoreRecordInput {
game_id,
admin_user_id,
expected_publication_revision: payload.expected_publication_revision,
idempotency_key,
request_digest,
now_micros: now_micros(),
})
.await
.map_err(map_spacetime_error)?;
info!(
request_id = ctx.request_id(),
operation = "game_restored",
game_id = %log_game_id,
admin_user_id = %log_admin_user_id,
publication_revision = game.0.publication_revision,
visibility = %game.0.visibility,
replayed = game.1,
elapsed_ms = ctx.elapsed(),
"管理员恢复已下架游戏"
);
Ok(json_success_body(
Some(&ctx),
json!({ "game": game_payload(&game.0), "replayed": game.1 }),
))
}
async fn record_upload_failure(
state: &AppState,
owner_user_id: &str,
@@ -1808,6 +1904,51 @@ fn public_game_payload(game: GameDistributionPublicGameRecord) -> Value {
payload
}
/// 后台游戏管理页的游戏行:作者名/头像由 spacetime 事务内读时联账号表得到。
fn admin_game_payload(game: &GameDistributionAdminGameRecord) -> Value {
json!({
"gameId": game.game_id,
"title": game.title,
"author": {
"id": game.owner_user_id,
"name": game.author_name.as_deref().unwrap_or("未知作者"),
"avatarUrl": game.author_avatar_url,
},
"status": game.visibility,
"versionCount": game.version_count,
"playCount": game.play_count,
"activeVersionId": game.active_version_id,
"publicationRevision": game.publication_revision,
"createdAt": game.created_at,
"updatedAt": game.updated_at,
"versions": game
.versions
.iter()
.map(|version| admin_game_version_payload(&game.game_id, version))
.collect::<Vec<_>>(),
})
}
fn admin_game_version_payload(
game_id: &str,
version: &GameDistributionAdminVersionRecord,
) -> Value {
json!({
"versionId": version.version_id,
"gameId": game_id,
"versionNumber": version.version_number,
"status": version.status,
"reviewReason": version.review_reason,
"packageBytes": version.package_bytes,
"packageSha256": version.package_sha256,
"entryUrl": version.entry_url,
"createdAt": version.created_at,
"updatedAt": version.updated_at,
"reviewedAt": version.reviewed_at,
"publishedAt": version.published_at,
})
}
fn game_payload(game: &GameDistributionGameRecord) -> Value {
let tags = serde_json::from_str::<Vec<String>>(&game.tags_json).unwrap_or_default();
let screenshots = game
@@ -2755,6 +2896,51 @@ mod tests {
assert_eq!(recovery_action_for_status("unknown_status"), "none");
}
#[tokio::test]
async fn admin_game_management_routes_are_mounted() {
use axum::{body::Body, http::Request};
use tower::ServiceExt;
let app = crate::app::build_router(
crate::state::AppState::new(crate::config::AppConfig::default())
.expect("测试状态应可构建"),
);
// 全量列表与恢复都必须先过管理员鉴权,未带 token 时在进入业务前被拒。
let unauthenticated_list = app
.clone()
.oneshot(
Request::builder()
.uri("/admin/api/game-distribution/games")
.body(Body::empty())
.expect("请求"),
)
.await
.expect("路由响应");
// 测试态没有启用后台运行时,鉴权中间件会在 503 处失败关闭;关键是不能 404。
assert!(matches!(
unauthenticated_list.status(),
StatusCode::UNAUTHORIZED | StatusCode::SERVICE_UNAVAILABLE
));
let unauthenticated_restore = app
.oneshot(
Request::builder()
.method("POST")
.uri("/admin/api/game-distribution/games/game_1/restore")
.header("content-type", "application/json")
.header("Idempotency-Key", "restore-1")
.body(Body::from(r#"{"expectedPublicationRevision":1}"#))
.expect("请求"),
)
.await
.expect("路由响应");
assert!(matches!(
unauthenticated_restore.status(),
StatusCode::UNAUTHORIZED | StatusCode::SERVICE_UNAVAILABLE
));
}
#[tokio::test]
async fn version_readback_and_cancel_routes_are_mounted() {
use axum::{body::Body, http::Request};
@@ -10,7 +10,7 @@ use crate::creation_entry_config::{
};
/// 后台 member 可被授予的一级 Tab 权限;账号管理仅 owner 可见,不进入该集合。
pub const ADMIN_TAB_PERMISSIONS: [&str; 19] = [
pub const ADMIN_TAB_PERMISSIONS: [&str; 20] = [
"dashboard",
"overview",
"tables",
@@ -26,6 +26,7 @@ pub const ADMIN_TAB_PERMISSIONS: [&str; 19] = [
"recharge-orders",
"editor-generation-pricing",
"editor-showcase",
"game-management",
"editor-assets",
"agc-templates",
"error-reports",
@@ -22,11 +22,12 @@ mod error_reports;
pub mod external_api_key;
pub mod game_distribution;
pub use game_distribution::{
GameDistributionApproveRecordInput, GameDistributionCancelVersionRecordInput,
GameDistributionConfirmPackageRecordInput, GameDistributionCreateGameRecordInput,
GameDistributionCreateVersionRecordInput, GameDistributionFailUploadRecordInput,
GameDistributionGetGameRecordInput, GameDistributionOwnerGameListRecordInput,
GameDistributionPublicGameListRecordInput, GameDistributionRejectRecordInput,
GameDistributionAdminGameListRecordInput, GameDistributionApproveRecordInput,
GameDistributionCancelVersionRecordInput, GameDistributionConfirmPackageRecordInput,
GameDistributionCreateGameRecordInput, GameDistributionCreateVersionRecordInput,
GameDistributionFailUploadRecordInput, GameDistributionGetGameRecordInput,
GameDistributionOwnerGameListRecordInput, GameDistributionPublicGameListRecordInput,
GameDistributionRejectRecordInput, GameDistributionRestoreRecordInput,
GameDistributionSubmitReviewRecordInput, GameDistributionSuspendRecordInput,
GameDistributionUnpublishRecordInput,
};
@@ -92,6 +92,7 @@ pub use self::external_generation::{
ExternalGenerationQueueStatsRecord,
};
pub use self::game_distribution::{
GameDistributionAdminGameRecord, GameDistributionAdminVersionRecord,
GameDistributionGameRecord, GameDistributionOwnerGameRecord, GameDistributionPublicGameRecord,
GameDistributionVersionRecord,
};
@@ -148,9 +149,10 @@ pub(crate) use self::external_generation::{
map_external_generation_queue_stats_result,
};
pub(crate) use self::game_distribution::{
map_game_distribution_game_result, map_game_distribution_owner_game_list_result,
map_game_distribution_public_game_list_result, map_game_distribution_public_game_result,
map_game_distribution_review_list_result, map_game_distribution_version_result,
map_game_distribution_admin_game_list_result, map_game_distribution_game_result,
map_game_distribution_owner_game_list_result, map_game_distribution_public_game_list_result,
map_game_distribution_public_game_result, map_game_distribution_review_list_result,
map_game_distribution_version_result,
};
pub(crate) use self::runtime::{
map_feature_gate_config_procedure_result, map_runtime_setting_procedure_result,
@@ -47,6 +47,38 @@ pub struct GameDistributionVersionRecord {
pub metadata_json: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GameDistributionAdminVersionRecord {
pub version_id: String,
pub version_number: u64,
pub status: String,
pub review_reason: Option<String>,
pub package_sha256: String,
pub package_bytes: u64,
pub entry_url: Option<String>,
pub created_at: String,
pub reviewed_at: Option<String>,
pub published_at: Option<String>,
pub updated_at: String,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GameDistributionAdminGameRecord {
pub game_id: String,
pub owner_user_id: String,
pub title: String,
pub author_name: Option<String>,
pub author_avatar_url: Option<String>,
pub visibility: String,
pub version_count: u64,
pub play_count: u64,
pub active_version_id: Option<String>,
pub publication_revision: u64,
pub created_at: String,
pub updated_at: String,
pub versions: Vec<GameDistributionAdminVersionRecord>,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GameDistributionOwnerGameRecord {
pub game: GameDistributionGameRecord,
@@ -111,6 +143,57 @@ fn map_version(
}
}
fn map_admin_version(
value: crate::module_bindings::GameDistributionAdminVersionSnapshot,
) -> GameDistributionAdminVersionRecord {
GameDistributionAdminVersionRecord {
version_id: value.version_id,
version_number: value.version_number,
status: value.status,
review_reason: value.review_reason,
package_sha256: value.package_sha_256,
package_bytes: value.package_bytes,
entry_url: value.entry_url,
created_at: shared_kernel::format_timestamp_micros(value.created_at_micros),
reviewed_at: value
.reviewed_at_micros
.map(shared_kernel::format_timestamp_micros),
published_at: value
.published_at_micros
.map(shared_kernel::format_timestamp_micros),
updated_at: shared_kernel::format_timestamp_micros(value.updated_at_micros),
}
}
fn map_admin_game(
value: crate::module_bindings::GameDistributionAdminGameSnapshot,
) -> GameDistributionAdminGameRecord {
GameDistributionAdminGameRecord {
game_id: value.game_id,
owner_user_id: value.owner_user_id,
title: value.title,
author_name: value.author_name,
author_avatar_url: value.author_avatar_url,
visibility: value.visibility,
version_count: value.version_count,
play_count: value.play_count,
active_version_id: value.active_version_id,
publication_revision: value.publication_revision,
created_at: shared_kernel::format_timestamp_micros(value.created_at_micros),
updated_at: shared_kernel::format_timestamp_micros(value.updated_at_micros),
versions: value.versions.into_iter().map(map_admin_version).collect(),
}
}
pub(crate) fn map_game_distribution_admin_game_list_result(
result: crate::module_bindings::GameDistributionAdminGameListResult,
) -> Result<Vec<GameDistributionAdminGameRecord>, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
Ok(result.games.into_iter().map(map_admin_game).collect())
}
pub(crate) fn map_game_distribution_game_result(
result: crate::module_bindings::GameDistributionProcedureResult,
) -> Result<
@@ -7,6 +7,11 @@ pub struct GameDistributionPublicGameListRecordInput {
pub limit: u32,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GameDistributionAdminGameListRecordInput {
pub limit: u32,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GameDistributionOwnerGameListRecordInput {
pub owner_user_id: String,
@@ -128,6 +133,16 @@ pub struct GameDistributionRejectRecordInput {
pub now_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GameDistributionRestoreRecordInput {
pub game_id: String,
pub admin_user_id: String,
pub expected_publication_revision: u64,
pub idempotency_key: String,
pub request_digest: String,
pub now_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GameDistributionUnpublishRecordInput {
pub game_id: String,
@@ -665,6 +680,69 @@ impl SpacetimeClient {
.await
}
/// 后台游戏管理页的全量游戏列表:含版本数与最近版本历史,作者名/头像由事务内读时联。
pub async fn list_admin_game_distribution_games(
&self,
input: GameDistributionAdminGameListRecordInput,
) -> Result<Vec<GameDistributionAdminGameRecord>, SpacetimeClientError> {
let procedure_input =
crate::module_bindings::GameDistributionAdminGameListInput { limit: input.limit };
self.call_after_connect(
"list_admin_game_distribution_games",
move |connection, sender| {
connection
.procedures()
.list_admin_game_distribution_games_and_return_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_game_distribution_admin_game_list_result);
send_once(&sender, mapped);
},
);
},
)
.await
}
/// 管理员解除安全下架:重新激活最近一次曾公开的版本。
pub async fn restore_game_distribution_game(
&self,
input: GameDistributionRestoreRecordInput,
) -> Result<(GameDistributionGameRecord, bool), SpacetimeClientError> {
let procedure_input = crate::module_bindings::GameDistributionRestoreInput {
game_id: input.game_id,
admin_user_id: input.admin_user_id,
expected_publication_revision: input.expected_publication_revision,
idempotency_key: input.idempotency_key,
request_digest: input.request_digest,
now_micros: input.now_micros,
};
self.call_after_connect(
"restore_game_distribution_game",
move |connection, sender| {
connection
.procedures()
.restore_game_distribution_game_and_return_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_game_distribution_game_result)
.and_then(|(game, _, replayed)| {
game.map(|game| (game, replayed)).ok_or_else(|| {
SpacetimeClientError::missing_snapshot("游戏恢复结果")
})
});
send_once(&sender, mapped);
},
);
},
)
.await
}
pub async fn list_game_distribution_reviews(
&self,
limit: u32,
@@ -398,6 +398,10 @@ pub mod feature_gate_config_snapshot_type;
pub mod feature_gate_config_table;
pub mod feature_gate_config_type;
pub mod find_editor_asset_group_source_and_return_procedure;
pub mod game_distribution_admin_game_list_input_type;
pub mod game_distribution_admin_game_list_result_type;
pub mod game_distribution_admin_game_snapshot_type;
pub mod game_distribution_admin_version_snapshot_type;
pub mod game_distribution_approve_input_type;
pub mod game_distribution_cancel_version_input_type;
pub mod game_distribution_confirm_package_input_type;
@@ -421,6 +425,7 @@ pub mod game_distribution_public_game_input_type;
pub mod game_distribution_public_game_list_input_type;
pub mod game_distribution_public_game_snapshot_type;
pub mod game_distribution_reject_input_type;
pub mod game_distribution_restore_input_type;
pub mod game_distribution_review_list_input_type;
pub mod game_distribution_submit_review_input_type;
pub mod game_distribution_suspend_input_type;
@@ -468,6 +473,7 @@ pub mod import_database_migration_incremental_from_chunks_procedure;
pub mod import_database_migration_incremental_from_file_procedure;
pub mod initialize_editor_generation_pricing_config_if_missing_and_return_procedure;
pub mod list_admin_accounts_and_return_procedure;
pub mod list_admin_game_distribution_games_and_return_procedure;
pub mod list_agc_tracking_events_procedure;
pub mod list_asset_history_and_return_procedure;
pub mod list_editor_agent_conversations_and_return_procedure;
@@ -591,6 +597,7 @@ pub mod repair_editor_canvas_resources_and_return_procedure;
pub mod repair_editor_project_resource_media_and_return_procedure;
pub mod resolve_editor_reference_and_return_procedure;
pub mod resolve_profile_recharge_refund_manual_review_and_return_procedure;
pub mod restore_game_distribution_game_and_return_procedure;
pub mod revoke_database_migration_operator_procedure;
pub mod revoke_external_api_key_and_return_procedure;
pub mod rollback_editor_canvas_layout_and_return_procedure;
@@ -1168,6 +1175,10 @@ pub use feature_gate_config_snapshot_type::FeatureGateConfigSnapshot;
pub use feature_gate_config_table::*;
pub use feature_gate_config_type::FeatureGateConfig;
pub use find_editor_asset_group_source_and_return_procedure::find_editor_asset_group_source_and_return;
pub use game_distribution_admin_game_list_input_type::GameDistributionAdminGameListInput;
pub use game_distribution_admin_game_list_result_type::GameDistributionAdminGameListResult;
pub use game_distribution_admin_game_snapshot_type::GameDistributionAdminGameSnapshot;
pub use game_distribution_admin_version_snapshot_type::GameDistributionAdminVersionSnapshot;
pub use game_distribution_approve_input_type::GameDistributionApproveInput;
pub use game_distribution_cancel_version_input_type::GameDistributionCancelVersionInput;
pub use game_distribution_confirm_package_input_type::GameDistributionConfirmPackageInput;
@@ -1191,6 +1202,7 @@ pub use game_distribution_public_game_input_type::GameDistributionPublicGameInpu
pub use game_distribution_public_game_list_input_type::GameDistributionPublicGameListInput;
pub use game_distribution_public_game_snapshot_type::GameDistributionPublicGameSnapshot;
pub use game_distribution_reject_input_type::GameDistributionRejectInput;
pub use game_distribution_restore_input_type::GameDistributionRestoreInput;
pub use game_distribution_review_list_input_type::GameDistributionReviewListInput;
pub use game_distribution_submit_review_input_type::GameDistributionSubmitReviewInput;
pub use game_distribution_suspend_input_type::GameDistributionSuspendInput;
@@ -1238,6 +1250,7 @@ pub use import_database_migration_incremental_from_chunks_procedure::import_data
pub use import_database_migration_incremental_from_file_procedure::import_database_migration_incremental_from_file;
pub use initialize_editor_generation_pricing_config_if_missing_and_return_procedure::initialize_editor_generation_pricing_config_if_missing_and_return;
pub use list_admin_accounts_and_return_procedure::list_admin_accounts_and_return;
pub use list_admin_game_distribution_games_and_return_procedure::list_admin_game_distribution_games_and_return;
pub use list_agc_tracking_events_procedure::list_agc_tracking_events;
pub use list_asset_history_and_return_procedure::list_asset_history_and_return;
pub use list_editor_agent_conversations_and_return_procedure::list_editor_agent_conversations_and_return;
@@ -1361,6 +1374,7 @@ pub use repair_editor_canvas_resources_and_return_procedure::repair_editor_canva
pub use repair_editor_project_resource_media_and_return_procedure::repair_editor_project_resource_media_and_return;
pub use resolve_editor_reference_and_return_procedure::resolve_editor_reference_and_return;
pub use resolve_profile_recharge_refund_manual_review_and_return_procedure::resolve_profile_recharge_refund_manual_review_and_return;
pub use restore_game_distribution_game_and_return_procedure::restore_game_distribution_game_and_return;
pub use revoke_database_migration_operator_procedure::revoke_database_migration_operator;
pub use revoke_external_api_key_and_return_procedure::revoke_external_api_key_and_return;
pub use rollback_editor_canvas_layout_and_return_procedure::rollback_editor_canvas_layout_and_return;
@@ -0,0 +1,15 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct GameDistributionAdminGameListInput {
pub limit: u32,
}
impl __sdk::InModule for GameDistributionAdminGameListInput {
type Module = super::RemoteModule;
}
@@ -0,0 +1,19 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::game_distribution_admin_game_snapshot_type::GameDistributionAdminGameSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct GameDistributionAdminGameListResult {
pub ok: bool,
pub games: Vec<GameDistributionAdminGameSnapshot>,
pub error_message: Option<String>,
}
impl __sdk::InModule for GameDistributionAdminGameListResult {
type Module = super::RemoteModule;
}
@@ -0,0 +1,29 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::game_distribution_admin_version_snapshot_type::GameDistributionAdminVersionSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct GameDistributionAdminGameSnapshot {
pub game_id: String,
pub owner_user_id: String,
pub title: String,
pub author_name: Option<String>,
pub author_avatar_url: Option<String>,
pub visibility: String,
pub version_count: u64,
pub play_count: u64,
pub active_version_id: Option<String>,
pub publication_revision: u64,
pub created_at_micros: i64,
pub updated_at_micros: i64,
pub versions: Vec<GameDistributionAdminVersionSnapshot>,
}
impl __sdk::InModule for GameDistributionAdminGameSnapshot {
type Module = super::RemoteModule;
}
@@ -0,0 +1,25 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct GameDistributionAdminVersionSnapshot {
pub version_id: String,
pub version_number: u64,
pub status: String,
pub review_reason: Option<String>,
pub package_sha_256: String,
pub package_bytes: u64,
pub entry_url: Option<String>,
pub created_at_micros: i64,
pub reviewed_at_micros: Option<i64>,
pub published_at_micros: Option<i64>,
pub updated_at_micros: i64,
}
impl __sdk::InModule for GameDistributionAdminVersionSnapshot {
type Module = super::RemoteModule;
}
@@ -0,0 +1,20 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct GameDistributionRestoreInput {
pub game_id: String,
pub admin_user_id: String,
pub expected_publication_revision: u64,
pub idempotency_key: String,
pub request_digest: String,
pub now_micros: i64,
}
impl __sdk::InModule for GameDistributionRestoreInput {
type Module = super::RemoteModule;
}
@@ -0,0 +1,62 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::game_distribution_admin_game_list_input_type::GameDistributionAdminGameListInput;
use super::game_distribution_admin_game_list_result_type::GameDistributionAdminGameListResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct ListAdminGameDistributionGamesAndReturnArgs {
pub input: GameDistributionAdminGameListInput,
}
impl __sdk::InModule for ListAdminGameDistributionGamesAndReturnArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `list_admin_game_distribution_games_and_return`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait list_admin_game_distribution_games_and_return {
fn list_admin_game_distribution_games_and_return(
&self,
input: GameDistributionAdminGameListInput,
) {
self.list_admin_game_distribution_games_and_return_then(input, |_, _| {});
}
fn list_admin_game_distribution_games_and_return_then(
&self,
input: GameDistributionAdminGameListInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<GameDistributionAdminGameListResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl list_admin_game_distribution_games_and_return for super::RemoteProcedures {
fn list_admin_game_distribution_games_and_return_then(
&self,
input: GameDistributionAdminGameListInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<GameDistributionAdminGameListResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, GameDistributionAdminGameListResult>(
"list_admin_game_distribution_games_and_return",
ListAdminGameDistributionGamesAndReturnArgs { input },
__callback,
);
}
}
@@ -0,0 +1,59 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::game_distribution_procedure_result_type::GameDistributionProcedureResult;
use super::game_distribution_restore_input_type::GameDistributionRestoreInput;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct RestoreGameDistributionGameAndReturnArgs {
pub input: GameDistributionRestoreInput,
}
impl __sdk::InModule for RestoreGameDistributionGameAndReturnArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `restore_game_distribution_game_and_return`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait restore_game_distribution_game_and_return {
fn restore_game_distribution_game_and_return(&self, input: GameDistributionRestoreInput) {
self.restore_game_distribution_game_and_return_then(input, |_, _| {});
}
fn restore_game_distribution_game_and_return_then(
&self,
input: GameDistributionRestoreInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<GameDistributionProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl restore_game_distribution_game_and_return for super::RemoteProcedures {
fn restore_game_distribution_game_and_return_then(
&self,
input: GameDistributionRestoreInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<GameDistributionProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, GameDistributionProcedureResult>(
"restore_game_distribution_game_and_return",
RestoreGameDistributionGameAndReturnArgs { input },
__callback,
);
}
}

Some files were not shown because too many files have changed in this diff Show More