import type { GameDistributionAuthor, GameDistributionCancelVersionResponse, GameDistributionCategory, GameDistributionCreateGameRequest, GameDistributionCreateVersionRequest, GameDistributionDeviceSupport, GameDistributionForkAuthorization, GameDistributionGame, GameDistributionMyReviewResponse, GameDistributionRecoveryAction, GameDistributionReviewsResponse, GameDistributionSaveReviewRequest, GameDistributionSaveReviewResponse, GameDistributionUpdateGameMetadataRequest, GameDistributionSetForkAuthorizationRequest, GameDistributionVersionDetail, GameDistributionVersionStatus as GameDistributionVersionStatusValue, GameDistributionVersionSummary, } from '../../packages/shared/src/contracts/gameDistribution'; import { type ApiRequestOptions, BACKGROUND_AUTH_REQUEST_OPTIONS, requestJson as requestApiJson, } from './apiClient'; import { getGamePlayClientId } from './gamePlayClientId'; export type { GameDistributionAuthor, GameDistributionDeviceSupport, GameDistributionForkAuthorization, GameDistributionGame, GameDistributionInputMode, GameDistributionLineage, GameDistributionRatingSummary, GameDistributionRecoveryAction, GameDistributionReview, GameDistributionReviewsResponse, GameDistributionVersionDetail, GameDistributionVersionSummary, } from '../../packages/shared/src/contracts/gameDistribution'; export type GameCategory = GameDistributionCategory; export type GameDeviceSupport = GameDistributionDeviceSupport; export type GameAuthor = GameDistributionAuthor; export type GameVersionSummary = GameDistributionVersionSummary; export type GameListQuery = { keyword?: string; category?: GameCategory | '全部'; }; export type GameVersionCreateResult = { gameId: string; versionId: string; versionNumber: number; status: 'awaiting_upload'; }; const API_BASE = '/api/game-distribution'; function normalizeGameList(value: unknown): GameDistributionGame[] { if (!Array.isArray(value)) return []; return value.filter((item): item is GameDistributionGame => { if (!item || typeof item !== 'object') return false; const game = item as Partial; return Boolean(game.id && game.title && game.currentVersion); }); } const PUBLIC_GAME_REQUEST_OPTIONS: ApiRequestOptions = { skipAuth: true, skipRefresh: true, notifyAuthStateChange: false, clearAuthOnUnauthorized: false, }; /** * 游玩上报是后台尽力而为的请求:带上已登录 bearer(后端据此按 userId 去重),但即使遇到 401 * 也不刷新会话、不改动全局登录态——直接复用后台请求已有的同一份鉴权降级策略。 */ async function requestJson( path: string, init: RequestInit, fallbackMessage: string, options?: ApiRequestOptions, ) { return requestApiJson( `${API_BASE}${path}`, { ...init, headers: { Accept: 'application/json', ...(init.body instanceof FormData || (typeof Blob !== 'undefined' && init.body instanceof Blob) || init.body instanceof ArrayBuffer ? {} : init.body ? { 'Content-Type': 'application/json' } : {}), ...init.headers, }, }, fallbackMessage, options, ); } export async function listGames(query: GameListQuery = {}) { const params = new URLSearchParams(); if (query.keyword?.trim()) params.set('keyword', query.keyword.trim()); if (query.category && query.category !== '全部') params.set('category', query.category); const payload = await requestJson( `/games${params.size ? `?${params.toString()}` : ''}`, { method: 'GET' }, '读取游戏目录失败', PUBLIC_GAME_REQUEST_OPTIONS, ); return normalizeGameList( Array.isArray(payload) ? payload : (payload as { games?: unknown[] }).games, ); } export type GameDistributionMyGame = GameDistributionGame & { latestVersion: GameDistributionVersionStatusEntry | null; versions: GameDistributionVersionStatusEntry[]; }; export type GameDistributionVersionStatusEntry = { versionId: string; gameId: string; versionNumber: number; packageSha256: string; packageBytes: number; status: GameDistributionVersionStatusValue; publicationRevision: number; reviewReason?: string | null; createdAt: string; updatedAt: string; /** * 版本私有的发行入口;只有已公开版本有值,作者详情用它判定当前是否能在线游玩。 */ entryUrl?: string | null; /** * 服务端派生的下一步动作。作者列表投影目前不带这一项(只有版本回读带), * 缺失时前端只展示状态、不自行推断动作。 */ recoveryAction?: GameDistributionRecoveryAction | null; }; /** * 作者自有游戏条目一定是「公开投影 + 私有版本状态」的形状;服务端缺字段时按空值兜底, * 避免页面对半成品响应直接崩掉。 */ function normalizeOwnerGameEntry( value: unknown, ): GameDistributionMyGame | null { if (!value || typeof value !== 'object') return null; const entry = value as Partial; if (!entry.id) return null; const versions = Array.isArray(entry.versions) ? entry.versions.filter( (item): item is GameDistributionVersionStatusEntry => Boolean(item && typeof item === 'object' && item.versionId), ) : []; return { ...(entry as GameDistributionMyGame), latestVersion: entry.latestVersion ?? null, versions, }; } export async function listMyGames() { const payload = await requestJson<{ games?: unknown[] }>( '/my-games', { method: 'GET' }, '读取我的游戏失败', ); const games = Array.isArray(payload?.games) ? payload.games : []; return games .map(normalizeOwnerGameEntry) .filter((game): game is GameDistributionMyGame => game !== null); } /** * 作者读取自己名下单个作品:审核中 / 被驳回 / 已下架 / 已撤回的作品都能打开, * 公开详情只服务已公开投影(作者点自己的未公开作品会拿到 404)。 */ export async function getMyGame( gameId: string, ): Promise { const normalizedGameId = gameId.trim(); if (!normalizedGameId) return null; const payload = await requestJson<{ game?: unknown }>( `/my-games/${encodeURIComponent(normalizedGameId)}`, { method: 'GET' }, '读取作品详情失败', ); return normalizeOwnerGameEntry(payload?.game); } /** * 保存作品资料的成功响应:`game` 是公开投影形状(不含版本列表), * `replayed` 表示服务端按同一个 Idempotency-Key 重放了这次写入。 */ export type GameDistributionUpdateMyGameResponse = { game: GameDistributionGame; replayed: boolean; }; /** * 保存作品展示资料;包内容不在这里改动,仍随版本冻结。 * * 服务端复用创建游戏的资料校验(含"必须有封面、截图必须是本人素材"), * 因此编辑也必须带上当前封面/截图的素材 ID,否则资料不完整或会把线上截图清空。 */ export async function updateMyGame( gameId: string, payload: GameDistributionUpdateGameMetadataRequest, idempotencyKey: string, ) { const normalizedGameId = gameId.trim(); const normalizedKey = idempotencyKey.trim(); if (!normalizedGameId) throw new Error('缺少作品编号'); if (!normalizedKey) throw new Error('保存作品资料缺少幂等键'); return requestJson( `/my-games/${encodeURIComponent(normalizedGameId)}`, { method: 'PATCH', headers: { 'Idempotency-Key': normalizedKey }, body: JSON.stringify(payload), }, '保存作品资料失败', ); } /** * 软删除作品:成功后该作品从作者列表与公开投影里消失,历史版本包留在回收窗口内。 * * 与其它写动作一致要带 `Idempotency-Key`;CAS 修订号走查询串(删除本身没有请求体), * 并发下架/审核后过期由服务端拒绝。 */ export async function deleteMyGame( gameId: string, expectedPublicationRevision: number, idempotencyKey: string, ) { const normalizedGameId = gameId.trim(); const normalizedKey = idempotencyKey.trim(); if (!normalizedGameId) throw new Error('缺少作品编号'); if (!normalizedKey) throw new Error('删除作品缺少幂等键'); const params = new URLSearchParams({ expectedPublicationRevision: String(expectedPublicationRevision), }); return requestJson<{ game?: GameDistributionGame; replayed?: boolean }>( `/my-games/${encodeURIComponent(normalizedGameId)}?${params.toString()}`, { method: 'DELETE', headers: { 'Idempotency-Key': normalizedKey }, }, '删除作品失败', ); } /** * 补读版本回读里的 `recoveryAction`。 * * 作者列表投影不带这一步动作,只为「还需要作者动作」的最新版本各回读一次;单个版本 * 回读失败只丢失该版本的下一步提示,不影响列表与其它作品。 */ export async function loadVersionRecoveryActions( versionIds: readonly string[], ): Promise> { const uniqueVersionIds = [ ...new Set(versionIds.map((versionId) => versionId.trim()).filter(Boolean)), ]; const details = await Promise.allSettled( uniqueVersionIds.map((versionId) => getGameVersion(versionId)), ); const actions: Record = {}; uniqueVersionIds.forEach((versionId, index) => { const detail = details[index]; if (detail?.status === 'fulfilled') { actions[versionId] = detail.value.version.recoveryAction; } }); return actions; } export async function unpublishGame( gameId: string, expectedPublicationRevision: number, idempotencyKey: string, ) { const normalizedGameId = gameId.trim(); const normalizedKey = idempotencyKey.trim(); if (!normalizedGameId) throw new Error('缺少游戏编号'); if (!normalizedKey) throw new Error('缺少幂等键'); return requestJson<{ game: { id: string; status: string } }>( `/games/${encodeURIComponent(normalizedGameId)}/unpublish`, { method: 'POST', headers: { 'Idempotency-Key': normalizedKey }, body: JSON.stringify({ expectedPublicationRevision }), }, '下架游戏失败', ); } /** * 提升作品共创授权(只升不降)。`expectedForkAuthorization` 为 CAS 期望值, * 服务端发现档位冲突或降级时返回 409,错误信息由 `requestJson` 透传。 */ export async function updateGameForkAuthorization( gameId: string, forkAuthorization: GameDistributionForkAuthorization, expectedForkAuthorization: GameDistributionForkAuthorization, idempotencyKey: string, ) { const normalizedGameId = gameId.trim(); const normalizedKey = idempotencyKey.trim(); if (!normalizedGameId) throw new Error('缺少游戏编号'); if (!normalizedKey) throw new Error('设置共创授权缺少幂等键'); const payload: GameDistributionSetForkAuthorizationRequest = { expectedForkAuthorization, forkAuthorization, }; return requestJson<{ ok?: boolean; replayed?: boolean; game: { id: string; forkAuthorization?: GameDistributionForkAuthorization; }; }>( `/games/${encodeURIComponent(normalizedGameId)}/fork-authorization`, { method: 'PUT', headers: { 'Idempotency-Key': normalizedKey }, body: JSON.stringify(payload), }, '设置共创授权失败', ); } export async function getGame(gameId: string) { const normalizedId = gameId.trim(); if (!normalizedId) return null; return requestJson( `/games/${encodeURIComponent(normalizedId)}`, { method: 'GET' }, '读取游戏详情失败', PUBLIC_GAME_REQUEST_OPTIONS, ); } /** * 上报一次「开始游戏」。调用方按 fire-and-forget 处理:失败只静默忽略,绝不阻断进入游戏。 */ export function recordGamePlay( gameId: string, ): Promise<{ recorded?: boolean }> { const normalizedId = gameId.trim(); if (!normalizedId) return Promise.resolve({ recorded: false }); return requestJson<{ recorded?: boolean }>( `/games/${encodeURIComponent(normalizedId)}/plays`, { method: 'POST', cache: 'no-store', body: JSON.stringify({ clientId: getGamePlayClientId() }), }, '游玩上报失败', BACKGROUND_AUTH_REQUEST_OPTIONS, ); } export function listGameReviews(gameId: string, page = 1) { return requestJson( `/games/${encodeURIComponent(gameId)}/reviews?page=${page}&pageSize=20`, { method: 'GET', cache: 'no-store' }, '读取游戏评价失败', PUBLIC_GAME_REQUEST_OPTIONS, ); } export function getMyGameReview(gameId: string) { return requestJson( `/games/${encodeURIComponent(gameId)}/my-review`, { method: 'GET', cache: 'no-store' }, '读取我的评价失败', ); } export function saveMyGameReview( gameId: string, payload: GameDistributionSaveReviewRequest, ) { return requestJson( `/games/${encodeURIComponent(gameId)}/my-review`, { method: 'PUT', cache: 'no-store', body: JSON.stringify(payload) }, '保存评价失败', ); } export async function createGame( payload: GameDistributionCreateGameRequest, idempotencyKey: string, ) { const normalizedKey = idempotencyKey.trim(); if (!normalizedKey) throw new Error('创建游戏缺少幂等键'); return requestJson( '/games', { method: 'POST', headers: { 'Idempotency-Key': normalizedKey }, body: JSON.stringify(payload), }, '创建游戏失败', ); } export async function createGameVersion( gameId: string, payload: GameDistributionCreateVersionRequest, idempotencyKey: string, ) { const normalizedGameId = gameId.trim(); const normalizedIdempotencyKey = idempotencyKey.trim(); if (!normalizedGameId || !normalizedIdempotencyKey) { throw new Error('游戏版本创建缺少必要标识'); } return requestJson( `/games/${encodeURIComponent(normalizedGameId)}/versions`, { method: 'POST', headers: { 'Idempotency-Key': normalizedIdempotencyKey }, body: JSON.stringify(payload), }, '创建游戏版本失败', ); } /** 回读单个版本状态、驳回理由与服务端给出的恢复动作。 */ export async function getGameVersion(versionId: string) { const normalizedVersionId = versionId.trim(); if (!normalizedVersionId) throw new Error('缺少版本编号'); return requestJson( `/versions/${encodeURIComponent(normalizedVersionId)}`, { method: 'GET' }, '读取版本状态失败', ); } /** 撤回尚未公开的版本;幂等键由调用方生成,重复提交返回同一结果。 */ export async function cancelGameVersion( versionId: string, expectedPublicationRevision: number, idempotencyKey: string, reason?: string, ) { const normalizedVersionId = versionId.trim(); const normalizedKey = idempotencyKey.trim(); if (!normalizedVersionId) throw new Error('撤回版本缺少版本编号'); if (!normalizedKey) throw new Error('撤回版本缺少幂等键'); const normalizedReason = reason?.trim(); return requestJson( `/versions/${encodeURIComponent(normalizedVersionId)}/cancel`, { method: 'POST', headers: { 'Idempotency-Key': normalizedKey }, body: JSON.stringify({ expectedPublicationRevision, ...(normalizedReason ? { reason: normalizedReason } : {}), }), }, '撤回版本失败', ); } export async function submitGameVersion( versionId: string, expectedPublicationRevision: number, idempotencyKey: string, ) { const normalizedVersionId = versionId.trim(); const normalizedKey = idempotencyKey.trim(); if (!normalizedVersionId) throw new Error('提交审核缺少版本 ID'); if (!normalizedKey) throw new Error('提交审核缺少幂等键'); return requestJson<{ version?: { status?: string } }>( `/versions/${encodeURIComponent(normalizedVersionId)}/submit`, { method: 'POST', headers: { 'Idempotency-Key': normalizedKey }, body: JSON.stringify({ expectedPublicationRevision }), }, '提交审核失败', ); } export async function uploadGamePackage( versionId: string, packageBody: Blob | ArrayBuffer, idempotencyKey: string, ) { const normalizedVersionId = versionId.trim(); const normalizedIdempotencyKey = idempotencyKey.trim(); if (!normalizedVersionId || !normalizedIdempotencyKey) { throw new Error('游戏发行包上传缺少必要标识'); } return requestJson<{ versionId: string; status: 'uploaded' }>( `/versions/${encodeURIComponent(normalizedVersionId)}/package`, { method: 'PUT', headers: { 'Content-Type': 'application/zip', 'Idempotency-Key': normalizedIdempotencyKey, }, body: packageBody, }, '上传游戏发行包失败', ); }