Files
Genarrative/src/services/gameDistributionClient.ts
T
suzmii 018f9d58e9
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
实现游戏共创 M1:作品级共创授权与改编血缘
- game_distribution_game 表尾追加 fork_authorization(默认 forbidden,只升不降,旧行自动取默认)
- 新增 game_distribution_lineage 表,记录父作品、来源版本、根作品与代际,并按 parent/root/owner 建索引
- migration.rs 登记新表并注明血缘随迁移导出
- 新增 set_game_distribution_fork_authorization_and_return procedure 与幂等收据、期望值 CAS
- 创建作品时校验改编声明:来源存在、已公开、授权开放、来源版本等于当前公开版本;复用既有身份时拒绝携带血缘
- 公开与后台响应增量:forkAuthorization、forkCount、lineage、generation、forkedFromGameId、derivedCount
- 公开快照的作者名与头像改为读时联 user_account,不再落到「创作者」兜底文案
- api-server 新增 PUT /api/game-distribution/games/{game_id}/fork-authorization(Bearer、幂等键、期望值校验)
- 错误映射新增 FORK_ 前缀分支并置于通用子串分支之前,避免被误判成 409 或 404
- module-game-distribution 增加授权阶梯、代际计算与错误码,并补单测覆盖阶梯与错误码前缀
- 网页端详情页新增共创信息卡,我的作品页新增授权提升入口,补服务层调用与定向测试
- 重新生成 SpacetimeDB 绑定,登记 DTO 一致性脚本的共创类型
2026-10-05 00:06:05 +08:00

526 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<GameDistributionGame>;
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<T>(
path: string,
init: RequestInit,
fallbackMessage: string,
options?: ApiRequestOptions,
) {
return requestApiJson<T>(
`${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<unknown>(
`/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<GameDistributionMyGame>;
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<GameDistributionMyGame | null> {
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<GameDistributionUpdateMyGameResponse>(
`/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<Record<string, GameDistributionRecoveryAction>> {
const uniqueVersionIds = [
...new Set(versionIds.map((versionId) => versionId.trim()).filter(Boolean)),
];
const details = await Promise.allSettled(
uniqueVersionIds.map((versionId) => getGameVersion(versionId)),
);
const actions: Record<string, GameDistributionRecoveryAction> = {};
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<GameDistributionGame>(
`/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<GameDistributionReviewsResponse>(
`/games/${encodeURIComponent(gameId)}/reviews?page=${page}&pageSize=20`,
{ method: 'GET', cache: 'no-store' },
'读取游戏评价失败',
PUBLIC_GAME_REQUEST_OPTIONS,
);
}
export function getMyGameReview(gameId: string) {
return requestJson<GameDistributionMyReviewResponse>(
`/games/${encodeURIComponent(gameId)}/my-review`,
{ method: 'GET', cache: 'no-store' },
'读取我的评价失败',
);
}
export function saveMyGameReview(
gameId: string,
payload: GameDistributionSaveReviewRequest,
) {
return requestJson<GameDistributionSaveReviewResponse>(
`/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<GameDistributionGame>(
'/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<GameVersionCreateResult>(
`/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<GameDistributionVersionDetail>(
`/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<GameDistributionCancelVersionResponse>(
`/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,
},
'上传游戏发行包失败',
);
}