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
- 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 一致性脚本的共创类型
526 lines
17 KiB
TypeScript
526 lines
17 KiB
TypeScript
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,
|
||
},
|
||
'上传游戏发行包失败',
|
||
);
|
||
}
|