Files
Genarrative/src/services/gameDistributionClient.ts
T
suzmii 39669763fe 实现游戏共创 M3:创作族谱树与衍生列表
- 新增 module-game-distribution/src/lineage.rs:锚点可读性、可见性过滤、稳定排序、上限截断的纯函数,附 15 个单测(复用 M1 的软删除判定,不重写第二套规则)
- 新增公开只读 procedure get_game_distribution_lineage / list_game_distribution_derived_games;锚点必须公开可读(未公开/已软删除/不存在一律按不存在处理),树内只出现未软删除且已公开的节点,不补 null 占位节点
- api-server 新增 GET /games/{id}/lineage 与 /games/{id}/derived(匿名可读、no-store),响应走结构化 DTO,节点键集合由测试钉死,杜绝下发对象键/素材键
- 作者自有游戏聚合追加 forkCount(与公开详情 forkCount 同口径),供「被改编 N」入口使用
- shared-contracts 与 TS 契约新增 LineageNode / LineageResponse / DerivedResponse 三型并登记 DTO 一致性检查
- 前端新增 /games/lineage 族谱页,登记 stage 类型、路由表、页面标题与壳层渲染分支;详情页共创卡新增「查看创作族谱」入口(仅已公开作品)
- 我的作品页新增「被改编 N」行内展开,列出直接子代的标题/作者/代际/状态,含空态与失败重试(仅已公开作品)
- 补前端组件/路由/标题/客户端用例与 api-server 路由、负载映射用例;同步里程碑验收标准与技术方案 §3.4 的锚点可读性口径
2026-10-05 17:30:45 +08:00

561 lines
18 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,
GameDistributionDerivedResponse,
GameDistributionDeviceSupport,
GameDistributionForkAuthorization,
GameDistributionGame,
GameDistributionLineageResponse,
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,
GameDistributionDerivedResponse,
GameDistributionDeviceSupport,
GameDistributionForkAuthorization,
GameDistributionGame,
GameDistributionInputMode,
GameDistributionLineage,
GameDistributionLineageNode,
GameDistributionLineageResponse,
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,
);
}
/**
* 读取以某作品的根为顶的创作族谱树。公开只读接口,匿名可读;
* 作品不存在或已软删除时服务端返回 404,这里按失败抛出,由页面走既有错误态。
*/
export async function getGameLineage(gameId: string) {
const normalizedId = gameId.trim();
if (!normalizedId) return null;
return requestJson<GameDistributionLineageResponse>(
`/games/${encodeURIComponent(normalizedId)}/lineage`,
{ method: 'GET', cache: 'no-store' },
'读取创作族谱失败',
PUBLIC_GAME_REQUEST_OPTIONS,
);
}
/**
* 读取某作品的直接衍生作品(「被改编」列表)。只含对外可见的直接子代,
* 与公开详情里 `forkCount` 的口径一致。
*/
export async function getDerivedGames(gameId: string) {
const normalizedId = gameId.trim();
if (!normalizedId) return null;
return requestJson<GameDistributionDerivedResponse>(
`/games/${encodeURIComponent(normalizedId)}/derived`,
{ method: 'GET', cache: 'no-store' },
'读取衍生作品失败',
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,
},
'上传游戏发行包失败',
);
}