import type { GameDistributionAuthor, GameDistributionCancelVersionResponse, GameDistributionCategory, GameDistributionCreateGameRequest, GameDistributionCreateVersionRequest, GameDistributionDeviceSupport, GameDistributionGame, GameDistributionMyReviewResponse, GameDistributionPlaySessionResponse, GameDistributionPurchaseRequest, GameDistributionPurchaseResponse, GameDistributionRecoveryAction, GameDistributionReviewsResponse, GameDistributionSaveReviewRequest, GameDistributionSaveReviewResponse, GameDistributionUpdateGameMetadataRequest, 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, GameDistributionGame, GameDistributionInputMode, 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 = { authorId?: string; keyword?: string; category?: GameCategory | '全部'; }; export type GameVersionCreateResult = { gameId: string; versionId: string; versionNumber: number; status: 'awaiting_upload'; }; /** * 发布/编辑媒体的原始二进制 part。 * * `metadata` 里的 `coverObjectKey` / `screenshots` 槽位只描述“沿用线上图还是新图”;新图字节 * 由这里附带的 `File` 以 `cover` / `screenshot` part 随同一次 multipart 请求提交。 */ export type GameDistributionPublishMediaParts = { /** 新选择的本地封面;缺省时沿用 `metadata.coverObjectKey`。 */ cover?: File | null; /** 与 `metadata.screenshots` 顺序一一对应的本地截图;`null` 表示该槽沿用 objectKey。 */ screenshots?: (File | null)[]; }; /** 游戏分发公开媒体换签响应;兼容 `read` 包装与平铺两种形状。 */ export type GameDistributionMediaReadUrlResponse = { read?: { objectKey?: string; signedUrl?: string; expiresAt?: string; }; signedUrl?: string; objectKey?: string; expiresAt?: string; }; const API_BASE = '/api/game-distribution'; /** 组装发布/编辑的 multipart 请求体:`metadata` 文本 part + 可选媒体二进制 part。 */ function buildGameDistributionFormData( metadata: unknown, media?: GameDistributionPublishMediaParts, ) { const formData = new FormData(); formData.append('metadata', JSON.stringify(metadata)); if (media?.cover) { formData.append('cover', media.cover); } for (const screenshot of media?.screenshots ?? []) { if (screenshot) { formData.append('screenshot', screenshot); } } return formData; } 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,服务端据此算真实购买态与是否下发付费入口;未登录时按匿名请求, * 不主动补票、也不因 401 影响全局登录态——游客浏览详情不会被要求登录。 */ const OPTIONAL_AUTH_REQUEST_OPTIONS: ApiRequestOptions = { 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.authorId !== undefined) params.set('authorId', query.authorId); if (query.keyword?.trim()) params.set('keyword', query.keyword.trim()); if (query.category && query.category !== '全部') params.set('category', query.category); const search = params.toString(); const payload = await requestJson( `/games${search ? `?${search}` : ''}`, { 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; }; /** * 保存作品展示资料;包内容不在这里改动,仍随版本冻结。 * * 走 `multipart/form-data`:`metadata` 文本 part 携带资料 DTO(含 `coverObjectKey` / * `screenshots` 槽位),新选择的封面/截图作为 `cover` / `screenshot` 二进制 part 随同提交。 * 服务端复用创建游戏的资料校验(含“必须有封面、新图必须是图片”)。 */ export async function updateMyGame( gameId: string, payload: GameDistributionUpdateGameMetadataRequest, idempotencyKey: string, media?: GameDistributionPublishMediaParts, ) { 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: buildGameDistributionFormData(payload, media), }, '保存作品资料失败', ); } /** * 软删除作品:成功后该作品从作者列表与公开投影里消失,历史版本包留在回收窗口内。 * * 与其它写动作一致要带 `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 }), }, '下架游戏失败', ); } /** * 读取游戏公开详情。 * * 走「可选鉴权」:公开资料对所有人可见,但已登录时必须带上身份,否则服务端算不出 `purchased`, * 已购买用户也会拿到空的付费入口。 */ export async function getGame(gameId: string) { const normalizedId = gameId.trim(); if (!normalizedId) return null; return requestJson( `/games/${encodeURIComponent(normalizedId)}`, { method: 'GET', cache: 'no-store' }, '读取游戏详情失败', OPTIONAL_AUTH_REQUEST_OPTIONS, ); } function buildGameDistributionMediaReadParams( objectKey: string, expireSeconds?: number, ) { const normalizedKey = objectKey.trim().replace(/^\/+/u, ''); if (!normalizedKey) throw new Error('缺少游戏媒体标识'); const params = new URLSearchParams({ objectKey: normalizedKey }); if ( typeof expireSeconds === 'number' && Number.isFinite(expireSeconds) && expireSeconds > 0 ) { params.set('expireSeconds', String(Math.floor(expireSeconds))); } return params; } function readGameDistributionMediaSignedUrl( payload: GameDistributionMediaReadUrlResponse | null, ) { // requestJson 对空响应体会返回 null(见其实现),这里必须容忍 payload 为空, // 否则会抛出原始 TypeError 而不是下面这句可控的友好错误。 const read = payload?.read ?? payload; const signedUrl = typeof read?.signedUrl === 'string' ? read.signedUrl.trim() : ''; if (!signedUrl) throw new Error('游戏媒体访问地址缺失'); return signedUrl; } /** * 为公开的游戏封面/截图 objectKey 换取签名读地址。 * * 游戏媒体落在项目快照桶、不经素材库 ACL,匿名可读;与 `/api/assets/read-url` 分离。 * 换签失败不在这里吞掉,由调用方决定占位或回退。 */ export async function getGameDistributionMediaReadUrl( objectKey: string, expireSeconds?: number, signal?: AbortSignal, ) { const params = buildGameDistributionMediaReadParams(objectKey, expireSeconds); const payload = await requestJson( `/media/read-url?${params.toString()}`, { method: 'GET', cache: 'no-store', signal }, '读取游戏媒体地址失败', PUBLIC_GAME_REQUEST_OPTIONS, ); return readGameDistributionMediaSignedUrl(payload); } /** * 为作者自己作品的封面/截图 objectKey 换取签名读地址。 * * 走 owner 作用域 `GET /my-games/{gameId}/media/read-url`:未发布 / 待审 / 被驳回的作品也能 * 预览换签,判定只认「objectKey 命中本人作品当前行的媒体」;非本人或作品不存在返回 404。 */ export async function getOwnerGameDistributionMediaReadUrl( gameId: string, objectKey: string, expireSeconds?: number, signal?: AbortSignal, ) { const normalizedGameId = gameId.trim(); if (!normalizedGameId) throw new Error('缺少作品标识'); const params = buildGameDistributionMediaReadParams(objectKey, expireSeconds); const payload = await requestJson( `/my-games/${encodeURIComponent(normalizedGameId)}/media/read-url?${params.toString()}`, { method: 'GET', cache: 'no-store', signal }, '读取作品媒体地址失败', ); return readGameDistributionMediaSignedUrl(payload); } /** * 上报一次「开始游戏」。调用方按 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, ); } /** * 购买买断制作品。扣费与购买记录由服务端在同一事务内完成。 * * - `Idempotency-Key` 必传:双击、并发或响应丢失重试都只会扣一次; * - `expectedPriceMudPoints` 必须回传客户端看到的价格,服务端价格已变时返回 409; * - 余额不足返回 400 与 `INSUFFICIENT_MUD_POINTS`,调用方据此给出充值入口。 */ export async function purchaseGame( gameId: string, payload: GameDistributionPurchaseRequest, idempotencyKey: string, ) { const normalizedGameId = gameId.trim(); const normalizedKey = idempotencyKey.trim(); if (!normalizedGameId) throw new Error('购买游戏缺少作品编号'); if (!normalizedKey) throw new Error('购买游戏缺少幂等键'); return requestJson( `/games/${encodeURIComponent(normalizedGameId)}/purchase`, { method: 'POST', cache: 'no-store', headers: { 'Idempotency-Key': normalizedKey }, body: JSON.stringify(payload), }, '购买游戏失败', ); } /** * 为买断制作品签发播放会话。 * * 免费游戏不调用这个接口;付费作品由服务端判定作者/管理员免购买与已购买, * 未通过鉴权时拒绝并给出可读错误,客户端不得自行放行。 */ export async function createGamePlaySession(gameId: string) { const normalizedGameId = gameId.trim(); if (!normalizedGameId) throw new Error('创建播放会话缺少作品编号'); return requestJson( `/games/${encodeURIComponent(normalizedGameId)}/play-session`, { method: 'POST', cache: 'no-store' }, '创建播放会话失败', ); } 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) }, '保存评价失败', ); } /** * 创建游戏。 * * 走 `multipart/form-data`:`metadata` 文本 part 携带资料 DTO,新封面/截图作为 `cover` / * `screenshot` 二进制 part 按槽位顺序追加。 */ export async function createGame( payload: GameDistributionCreateGameRequest, idempotencyKey: string, media?: GameDistributionPublishMediaParts, ) { const normalizedKey = idempotencyKey.trim(); if (!normalizedKey) throw new Error('创建游戏缺少幂等键'); return requestJson( '/games', { method: 'POST', headers: { 'Idempotency-Key': normalizedKey }, body: buildGameDistributionFormData(payload, media), }, '创建游戏失败', ); } /** * 在既有 gameId 下创建不可变新版本。 * * `gameMetadata` 走 `metadata` 文本 part;新封面/截图作为 `cover` / `screenshot` part 提交, * 沿用线上图时只在 `gameMetadata.coverObjectKey` / `gameMetadata.screenshots` 里带对象键。 */ export async function createGameVersion( gameId: string, payload: GameDistributionCreateVersionRequest, idempotencyKey: string, media?: GameDistributionPublishMediaParts, ) { 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: buildGameDistributionFormData(payload, media), }, '创建游戏版本失败', ); } /** 回读单个版本状态、驳回理由与服务端给出的恢复动作。 */ 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, }, '上传游戏发行包失败', ); }