diff --git a/apps/admin-web/src/api/adminApiClient.test.ts b/apps/admin-web/src/api/adminApiClient.test.ts index 63fd94567..7c5464f06 100644 --- a/apps/admin-web/src/api/adminApiClient.test.ts +++ b/apps/admin-web/src/api/adminApiClient.test.ts @@ -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( diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index da313c68b..8aae410af 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -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( + `/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( + `/admin/api/game-distribution/games/${encodeURIComponent(normalizedGameId)}/restore`, + { + method: 'POST', + token, + headers: { 'Idempotency-Key': normalizedKey }, + body: payload, + }, + ); +} + /** * 审核游戏发行版本。幂等键由调用方生成并在同一次提交内复用,避免重复点击产生 * 两条审核结论。 diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index a85b4129f..01e99ed10 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -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; diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx index 73b48d326..f9294a7a5 100644 --- a/apps/admin-web/src/app/AdminApp.tsx +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -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' ? ( + + ) : null} {activeRouteId === 'editor-assets' ? ( { 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', diff --git a/apps/admin-web/src/app/adminRoutes.ts b/apps/admin-web/src/app/adminRoutes.ts index 46aa13750..d6c948348 100644 --- a/apps/admin-web/src/app/adminRoutes.ts +++ b/apps/admin-web/src/app/adminRoutes.ts @@ -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 }, diff --git a/apps/admin-web/src/pages/AdminGameManagementPage.test.tsx b/apps/admin-web/src/pages/AdminGameManagementPage.test.tsx new file mode 100644 index 000000000..18407c5bb --- /dev/null +++ b/apps/admin-web/src/pages/AdminGameManagementPage.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + expect(await screen.findByText('游戏列表读取失败')).toBeTruthy(); +}); + +test('401 交给 onUnauthorized 处理', async () => { + const onUnauthorized = vi.fn(); + vi.mocked(listAdminGameDistributionGames).mockRejectedValue( + Object.assign(new Error('未授权'), { status: 401 }), + ); + + render( + , + ); + + await waitFor(() => + expect(onUnauthorized).toHaveBeenCalledWith('登录状态已失效'), + ); + expect(screen.queryByRole('alert')).toBeNull(); +}); diff --git a/apps/admin-web/src/pages/AdminGameManagementPage.tsx b/apps/admin-web/src/pages/AdminGameManagementPage.tsx new file mode 100644 index 000000000..2ab32bff4 --- /dev/null +++ b/apps/admin-web/src/pages/AdminGameManagementPage.tsx @@ -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 = { + 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([]); + const [isLoading, setIsLoading] = useState(false); + const [busyGameId, setBusyGameId] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); + const [statusMessage, setStatusMessage] = useState(''); + const [suspendReasonByGame, setSuspendReasonByGame] = useState< + Record + >({}); + const [versionGame, setVersionGame] = + useState(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 ( +
+
+

游戏管理

+ +
+ + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + {statusMessage ? ( +
+ {statusMessage} +
+ ) : null} + +
+
+

游戏列表

+ 共 {games.length} 条 +
+ + {isLoading ? ( +

正在加载游戏列表…

+ ) : null} + + {!isLoading && games.length === 0 ? ( +

暂无游戏。

+ ) : null} + + {!isLoading && games.length > 0 ? ( +
+ + + + + + + + + + + + + + {games.map((entry) => { + const status = gameStatusMeta(entry.status); + const isSuspended = entry.status === 'suspended'; + const busy = busyGameId === entry.gameId; + return ( + + + + + + + + + + ); + })} + +
标题作者gameId状态版本数游玩数操作
+ {entry.title?.trim() || '—'} + +
+
+ {entry.author?.avatarUrl ? ( + {`${authorName(entry)} + ) : ( + authorInitial(entry) + )} +
+
+ {authorName(entry)} + {entry.author?.id?.trim() || '—'} +
+
+
+ {entry.gameId} + + + {status.label} + + {entry.versionCount}{entry.playCount} +
+ {!isSuspended ? ( + <> +
+ + + setSuspendReasonByGame((current) => ({ + ...current, + [entry.gameId]: event.target.value, + })) + } + disabled={busy} + /> +
+ + + ) : ( + + )} + +
+
+
+ ) : null} +
+ + {versionGame ? ( +
{ + if (event.target === event.currentTarget) { + setVersionGame(null); + } + }} + > +
+
+
+

版本历史

+ + {versionGame.title?.trim() || '—'}({versionGame.gameId}) + +
+ +
+

+ 共 {versionGame.versionCount} 个版本,展示最近 20 个 +

+ {versionGame.versions.length === 0 ? ( +

暂无版本记录。

+ ) : ( +
+ + + + + + + + + + + + + + + + {versionGame.versions.map((version) => ( + + ))} + +
版本状态包大小SHA创建时间审核时间公开时间审核原因发行入口
+
+ )} +
+
+ ) : null} + + {writeConfirm.confirmDialog} +
+ ); +} + +function GameVersionRow({ + version, +}: { + version: AdminGameDistributionGameVersionEntry; +}) { + return ( + + v{version.versionNumber} + {version.status || '—'} + {formatBytes(version.packageBytes)} + + {version.packageSha256?.slice(0, 12) || '—'} + + {formatOptionalTime(version.createdAt)} + {formatOptionalTime(version.reviewedAt)} + {formatOptionalTime(version.publishedAt)} + {version.reviewReason?.trim() || '—'} + + {version.entryUrl ? ( + + {version.entryUrl} + + ) : ( + '—' + )} + + + ); +} diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 925580bd1..64030aad6 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -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` diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index 432e82c0f..cb5971076 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -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!( diff --git a/server-rs/crates/api-server/src/modules/game_distribution.rs b/server-rs/crates/api-server/src/modules/game_distribution.rs index f4807bbaf..6bc5c39a4 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -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, } +#[derive(Debug, Deserialize)] +struct AdminGameListQuery { + limit: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AdminRestoreGameRequest { + expected_publication_revision: u64, +} + pub fn router(state: AppState) -> Router { let protected = Router::new() .route( @@ -242,10 +257,15 @@ pub fn router(state: AppState) -> Router { "/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, + Extension(ctx): Extension, + Extension(_admin): Extension, + Query(query): Query, +) -> Result, 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::>(), + }), + )) +} + async fn admin_review_version( State(state): State, Extension(ctx): Extension, @@ -1639,6 +1690,51 @@ async fn admin_suspend_game( )) } +async fn admin_restore_game( + State(state): State, + Extension(ctx): Extension, + Extension(admin): Extension, + headers: HeaderMap, + Path(game_id): Path, + Json(payload): Json, +) -> Result, 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::>(), + }) +} + +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::>(&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}; diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs index 19ed5f06b..e8e0c4637 100644 --- a/server-rs/crates/shared-contracts/src/admin.rs +++ b/server-rs/crates/shared-contracts/src/admin.rs @@ -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", diff --git a/server-rs/crates/spacetime-client/src/active.rs b/server-rs/crates/spacetime-client/src/active.rs index f53356bed..9bcd727c7 100644 --- a/server-rs/crates/spacetime-client/src/active.rs +++ b/server-rs/crates/spacetime-client/src/active.rs @@ -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, }; diff --git a/server-rs/crates/spacetime-client/src/active/mapper.rs b/server-rs/crates/spacetime-client/src/active/mapper.rs index 117980ebd..301ed3202 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper.rs @@ -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, diff --git a/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs b/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs index 72d04d537..3814b976b 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs @@ -47,6 +47,38 @@ pub struct GameDistributionVersionRecord { pub metadata_json: Option, } +#[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, + pub package_sha256: String, + pub package_bytes: u64, + pub entry_url: Option, + pub created_at: String, + pub reviewed_at: Option, + pub published_at: Option, + 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, + pub author_avatar_url: Option, + pub visibility: String, + pub version_count: u64, + pub play_count: u64, + pub active_version_id: Option, + pub publication_revision: u64, + pub created_at: String, + pub updated_at: String, + pub versions: Vec, +} + #[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, 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< diff --git a/server-rs/crates/spacetime-client/src/game_distribution.rs b/server-rs/crates/spacetime-client/src/game_distribution.rs index a9feb2ce9..0a20dea7b 100644 --- a/server-rs/crates/spacetime-client/src/game_distribution.rs +++ b/server-rs/crates/spacetime-client/src/game_distribution.rs @@ -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, 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, diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 94d10bb47..8c316104e 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -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; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_game_list_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_game_list_input_type.rs new file mode 100644 index 000000000..bcd5d17d6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_game_list_input_type.rs @@ -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; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_game_list_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_game_list_result_type.rs new file mode 100644 index 000000000..0db38de34 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_game_list_result_type.rs @@ -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, + pub error_message: Option, +} + +impl __sdk::InModule for GameDistributionAdminGameListResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_game_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_game_snapshot_type.rs new file mode 100644 index 000000000..8d79a32a3 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_game_snapshot_type.rs @@ -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, + pub author_avatar_url: Option, + pub visibility: String, + pub version_count: u64, + pub play_count: u64, + pub active_version_id: Option, + pub publication_revision: u64, + pub created_at_micros: i64, + pub updated_at_micros: i64, + pub versions: Vec, +} + +impl __sdk::InModule for GameDistributionAdminGameSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_version_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_version_snapshot_type.rs new file mode 100644 index 000000000..c32847c6d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_version_snapshot_type.rs @@ -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, + pub package_sha_256: String, + pub package_bytes: u64, + pub entry_url: Option, + pub created_at_micros: i64, + pub reviewed_at_micros: Option, + pub published_at_micros: Option, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for GameDistributionAdminVersionSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_restore_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_restore_input_type.rs new file mode 100644 index 000000000..3b23564c7 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_restore_input_type.rs @@ -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; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_admin_game_distribution_games_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_admin_game_distribution_games_and_return_procedure.rs new file mode 100644 index 000000000..bb17220f1 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_admin_game_distribution_games_and_return_procedure.rs @@ -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, + ) + 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, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, GameDistributionAdminGameListResult>( + "list_admin_game_distribution_games_and_return", + ListAdminGameDistributionGamesAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/restore_game_distribution_game_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/restore_game_distribution_game_and_return_procedure.rs new file mode 100644 index 000000000..1bf5be722 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/restore_game_distribution_game_and_return_procedure.rs @@ -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, + ) + 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, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, GameDistributionProcedureResult>( + "restore_game_distribution_game_and_return", + RestoreGameDistributionGameAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-module/src/game_distribution.rs b/server-rs/crates/spacetime-module/src/game_distribution.rs index 53ef0d7d8..7c552a707 100644 --- a/server-rs/crates/spacetime-module/src/game_distribution.rs +++ b/server-rs/crates/spacetime-module/src/game_distribution.rs @@ -170,6 +170,11 @@ const GAME_DISTRIBUTION_ACTION_APPROVE: &str = "approve"; const GAME_DISTRIBUTION_ACTION_REJECT: &str = "reject"; const GAME_DISTRIBUTION_ACTION_UNPUBLISH: &str = "unpublish"; const GAME_DISTRIBUTION_ACTION_SUSPEND: &str = "suspend"; +const GAME_DISTRIBUTION_ACTION_RESTORE: &str = "restore"; +/// 后台游戏管理页每个游戏最多回传多少个版本历史;版本数仍按全量统计。 +const GAME_DISTRIBUTION_ADMIN_VERSION_HISTORY_LIMIT: usize = 20; +/// 后台游戏管理页一次最多回传多少个游戏;全量视图按上限截断,不做游标分页。 +const GAME_DISTRIBUTION_ADMIN_GAME_LIST_LIMIT: u32 = 200; const GAME_DISTRIBUTION_RECEIPT_RETENTION_MICROS: i64 = 30 * 24 * 60 * 60 * 1_000_000; const GAME_DISTRIBUTION_MAX_LIST_LIMIT: u32 = 48; const GAME_DISTRIBUTION_MAX_OWNER_VERSIONS_PER_GAME: usize = 10; @@ -309,6 +314,21 @@ pub struct GameDistributionReviewListInput { pub limit: u32, } +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionAdminGameListInput { + pub limit: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +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, +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct GameDistributionOwnerGameListInput { pub owner_user_id: String, @@ -385,6 +405,47 @@ pub struct GameDistributionVersionSnapshot { pub metadata_json: Option, } +/// 后台游戏管理页的版本历史条目:补上审核与公开时间,供运营判断下架与恢复影响。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionAdminVersionSnapshot { + pub version_id: String, + pub version_number: u64, + pub status: String, + pub review_reason: Option, + pub package_sha256: String, + pub package_bytes: u64, + pub entry_url: Option, + pub created_at_micros: i64, + pub reviewed_at_micros: Option, + pub published_at_micros: Option, + pub updated_at_micros: i64, +} + +/// 后台游戏管理页的聚合快照:游戏行 + 全量版本数 + 最近版本历史 + 读时联的作者资料。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionAdminGameSnapshot { + pub game_id: String, + pub owner_user_id: String, + pub title: String, + pub author_name: Option, + pub author_avatar_url: Option, + pub visibility: String, + pub version_count: u64, + pub play_count: u64, + pub active_version_id: Option, + pub publication_revision: u64, + pub created_at_micros: i64, + pub updated_at_micros: i64, + pub versions: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionAdminGameListResult { + pub ok: bool, + pub games: Vec, + pub error_message: Option, +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct GameDistributionPublicGameSnapshot { pub game: GameDistributionGameSnapshot, @@ -676,6 +737,46 @@ pub fn list_game_distribution_reviews_and_return( } } +/// 返回后台游戏管理页的全量游戏;版本数与最近版本在同一事务内统计,作者名/头像读时联账号表。 +#[spacetimedb::procedure] +pub fn list_admin_game_distribution_games_and_return( + ctx: &mut ProcedureContext, + input: GameDistributionAdminGameListInput, +) -> GameDistributionAdminGameListResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + require_editor_generation_runtime_service_identity(tx, caller)?; + list_admin_game_distribution_games_tx(tx, input.clone()) + }) { + Ok(games) => GameDistributionAdminGameListResult { + ok: true, + games, + error_message: None, + }, + Err(error) => GameDistributionAdminGameListResult { + ok: false, + games: Vec::new(), + error_message: Some(error), + }, + } +} + +/// 管理员解除安全下架:重新激活该游戏最近一次曾公开的版本。 +#[spacetimedb::procedure] +pub fn restore_game_distribution_game_and_return( + ctx: &mut ProcedureContext, + input: GameDistributionRestoreInput, +) -> GameDistributionProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + require_editor_generation_runtime_service_identity(tx, caller)?; + restore_game_distribution_game_tx(tx, input.clone()) + }) { + Ok((game, replayed)) => game_distribution_game_result(game, replayed), + Err(error) => game_distribution_result_error(error), + } +} + /// 返回作者名下的游戏与最近版本;owner 来自 api-server 的认证主体,调用方不能指定他人。 #[spacetimedb::procedure] pub fn list_owner_game_distribution_games_and_return( @@ -1850,6 +1951,205 @@ fn suspend_game_distribution_game_tx( Ok((game_distribution_game_snapshot(&game), false)) } +/// 管理员解除暂停:把最近一次曾公开(有 published_at)的已撤回版本重新设为公开版本。 +/// +/// 暂停下架会把当时的公开版本置为 `revoked` 并清空 `active_version_id`,所以恢复不能只 +/// 翻转可见性;找不到可恢复版本时失败关闭,由运营要求作者重新送审。 +fn restore_game_distribution_game_tx( + ctx: &ReducerContext, + input: GameDistributionRestoreInput, +) -> Result<(GameDistributionGameSnapshot, bool), String> { + let admin_user_id = required_game_distribution_text(input.admin_user_id, "admin_user_id")?; + let game_id = required_game_distribution_text(input.game_id, "game_id")?; + let idempotency_key = + required_game_distribution_text(input.idempotency_key, "idempotency_key")?; + let request_digest = required_game_distribution_text(input.request_digest, "request_digest")?; + let receipt_id = game_distribution_receipt_id( + admin_user_id.as_str(), + GAME_DISTRIBUTION_ACTION_RESTORE, + idempotency_key.as_str(), + ); + if let Some(receipt) = find_game_distribution_receipt(ctx, receipt_id.as_str()) { + ensure_game_distribution_receipt_digest(&receipt, request_digest.as_str())?; + let game = ctx + .db + .game_distribution_game() + .game_id() + .find(&game_id) + .ok_or_else(|| "幂等收据对应的游戏已不存在".to_string())?; + return Ok((game_distribution_game_snapshot(&game), true)); + } + let mut game = ctx + .db + .game_distribution_game() + .game_id() + .find(&game_id) + .ok_or_else(|| "游戏不存在".to_string())?; + ensure_game_distribution_publication_revision(&game, input.expected_publication_revision)?; + if game.visibility != GAME_DISTRIBUTION_VISIBILITY_SUSPENDED { + return Err("只有管理员暂停的游戏才能恢复".to_string()); + } + let mut candidates = ctx + .db + .game_distribution_version() + .by_game_distribution_version_game_id() + .filter(&game.game_id) + .filter(|version| { + // `reviewed_by_user_id` 区分「管理员暂停撤回」与「作者自行下架撤回」: + // 作者下架不写审核者,恢复不能把作者已经下架的游戏重新公开。 + version.status == GAME_DISTRIBUTION_VERSION_REVOKED + && version.published_at.is_some() + && version.reviewed_by_user_id.is_some() + }) + .collect::>(); + candidates.sort_by(|left, right| { + right + .version_number + .cmp(&left.version_number) + .then_with(|| right.version_id.cmp(&left.version_id)) + }); + let mut version = candidates + .into_iter() + .next() + .ok_or_else(|| "没有可恢复的已审核版本,请作者重新送审".to_string())?; + let now = Timestamp::from_micros_since_unix_epoch(input.now_micros); + if version.entry_url.is_none() { + return Err("可恢复版本缺少发行入口,请作者重新送审".to_string()); + } + version.status = GAME_DISTRIBUTION_VERSION_PUBLISHED.to_string(); + version.revoked_at = None; + version.updated_at = now; + ctx.db + .game_distribution_version() + .version_id() + .update(version.clone()); + game.visibility = GAME_DISTRIBUTION_VISIBILITY_PUBLISHED.to_string(); + game.active_version_id = Some(version.version_id.clone()); + game.publication_revision = game + .publication_revision + .checked_add(1) + .ok_or_else(|| "publication_revision 溢出".to_string())?; + game.updated_at = now; + ctx.db + .game_distribution_game() + .game_id() + .update(game.clone()); + insert_game_distribution_receipt( + ctx, + GameDistributionReceiptInput { + receipt_id, + owner_user_id: admin_user_id, + action: GAME_DISTRIBUTION_ACTION_RESTORE.to_string(), + idempotency_key, + request_digest, + game_id: Some(game_id), + version_id: Some(version.version_id), + outcome_kind: "game".to_string(), + outcome_game_id: Some(game.game_id.clone()), + outcome_version_id: None, + outcome_json: None, + now_micros: input.now_micros, + }, + )?; + Ok((game_distribution_game_snapshot(&game), false)) +} + +fn list_admin_game_distribution_games_tx( + ctx: &ReducerContext, + input: GameDistributionAdminGameListInput, +) -> Result, String> { + let limit = if input.limit == 0 { + GAME_DISTRIBUTION_ADMIN_GAME_LIST_LIMIT + } else { + input.limit.min(GAME_DISTRIBUTION_ADMIN_GAME_LIST_LIMIT) + } as usize; + let mut games = ctx.db.game_distribution_game().iter().collect::>(); + games.sort_by(|left, right| { + right + .created_at + .to_micros_since_unix_epoch() + .cmp(&left.created_at.to_micros_since_unix_epoch()) + .then_with(|| left.game_id.cmp(&right.game_id)) + }); + games.truncate(limit); + Ok(games + .into_iter() + .map(|game| admin_game_distribution_game_snapshot(ctx, &game)) + .collect()) +} + +fn admin_game_distribution_game_snapshot( + ctx: &ReducerContext, + game: &GameDistributionGame, +) -> GameDistributionAdminGameSnapshot { + let mut versions = ctx + .db + .game_distribution_version() + .by_game_distribution_version_game_id() + .filter(&game.game_id) + .collect::>(); + // 版本数按全量统计,历史只回传最近若干版本,避免后台一次拉取全部历史。 + let version_count = versions.len() as u64; + versions.sort_by(|left, right| { + right + .version_number + .cmp(&left.version_number) + .then_with(|| right.version_id.cmp(&left.version_id)) + }); + versions.truncate(GAME_DISTRIBUTION_ADMIN_VERSION_HISTORY_LIMIT); + // 作者名/头像读时联账号表;创建时写入的是快照,账号改名或换头像后后台立即跟随。 + let account = ctx.db.user_account().user_id().find(&game.owner_user_id); + let author_name = account + .as_ref() + .map(|user| user.display_name.trim()) + .filter(|name| !name.is_empty()) + .map(str::to_string) + .or_else(|| game.author_name.clone()); + let author_avatar_url = account + .and_then(|user| user.avatar_url) + .or_else(|| game.author_avatar_url.clone()); + GameDistributionAdminGameSnapshot { + game_id: game.game_id.clone(), + owner_user_id: game.owner_user_id.clone(), + title: game.title.clone(), + author_name, + author_avatar_url, + visibility: game.visibility.clone(), + version_count, + play_count: game.play_count, + active_version_id: game.active_version_id.clone(), + publication_revision: game.publication_revision, + created_at_micros: game.created_at.to_micros_since_unix_epoch(), + updated_at_micros: game.updated_at.to_micros_since_unix_epoch(), + versions: versions + .iter() + .map(admin_game_distribution_version_snapshot) + .collect(), + } +} + +fn admin_game_distribution_version_snapshot( + version: &GameDistributionVersion, +) -> GameDistributionAdminVersionSnapshot { + GameDistributionAdminVersionSnapshot { + version_id: version.version_id.clone(), + version_number: version.version_number, + status: version.status.clone(), + review_reason: version.review_reason.clone(), + package_sha256: version.package_sha256.clone(), + package_bytes: version.package_bytes, + entry_url: version.entry_url.clone(), + created_at_micros: version.created_at.to_micros_since_unix_epoch(), + reviewed_at_micros: version + .reviewed_at + .map(|value| value.to_micros_since_unix_epoch()), + published_at_micros: version + .published_at + .map(|value| value.to_micros_since_unix_epoch()), + updated_at_micros: version.updated_at.to_micros_since_unix_epoch(), + } +} + fn list_game_distribution_reviews_tx( ctx: &ReducerContext, input: GameDistributionReviewListInput,