fix(游戏共创): 非法游标回归稳定码 THEME_INVALID_CURSOR(契约码此前在 HTTP 面不可达)
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (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
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

端到端实测(54 项里唯一红的那条):`GET /api/game-distribution/themes?cursor=abc` 回 400,但
`error.code = "BAD_REQUEST"`——技术方案 §3.4 与主题 e2e 脚本承诺的稳定码 `THEME_INVALID_CURSOR`
在 HTTP 面上拿不到,客户端只能去匹配中文文案。

根因:`map_spacetime_error` 的 `THEME_` 分支前置条件是「消息含 `THEME_`」,而模块侧游标解析只回
中文串「主题列表游标格式无效」⇒ 落兜底分支成 400 + 通用 `BAD_REQUEST`;契约常量与那条映射分支
因此是死代码。且 api-server 原有单测**明确要求**文案不含 `THEME_`(口径留了两套)。

口径二选一,选**让码可达**(模块产出前缀),删掉「要求不含 `THEME_`」的断言:

- `module-game-distribution/src/errors.rs`:新增 6 个主题码常量
  `GAME_DISTRIBUTION_THEME_{NOT_FOUND,BAD_REQUEST,INVALID_CURSOR,IDEMPOTENCY_CONFLICT,MEMBER_NOT_ROOT,MEMBER_GAME_NOT_FOUND}_CODE`,
  并把 `Display` 从字面量改为**复用同一批常量**(消灭「常量改了、`Display` 没改」这类只有真栈才发现的漂移)。
- `theme.rs`:`parse_game_distribution_theme_cursor` 产出 `THEME_INVALID_CURSOR: 主题列表游标格式无效`;
  文档注释更新为「只保留 `FORK_` 这个排在 `THEME_` 分支之前的子串禁忌」(其余子串已抢不到)。
- `lib.rs`:导出新增的 6 个码常量。
- 测试:
  · `domain.rs::theme_errors_are_prefixed_with_their_machine_code` 改为**枚举全部 6 个主题码**(含唯一
    不走领域变体的游标路径——正是当初漏掉它的原因),并加「任何新增码都必须进这张表」的覆盖完整性断言;
  · `theme.rs::invalid_cursor_message_stays_in_the_bad_request_bucket` 增加「必须以稳定码开头」;
  · api-server 新增 `theme_error_codes_are_reachable_from_module_messages`:用**模块真实产出的消息**
    逐码断言 `(状态码, 稳定码)` 可达,并比对模块常量与 `shared-contracts` 常量是同一组字符串;
  · api-server `theme_list_invalid_cursor_maps_to_bad_request` 改为断言映射出 `THEME_INVALID_CURSOR`,
    只把 `FORK_` 留在禁忌列表里(并写明理由)。

门禁:`cargo test -p module-game-distribution` **100 passed**;`cargo test -p api-server game_distribution`
**86 passed**;`cargo check --all-targets` 0;DTO parity 58 组 / 10 构建器;`check:encoding` 0;
`cargo fmt --all -- --check` 0;`git diff --check` 0。
This commit is contained in:
2026-10-06 03:50:05 +08:00
parent 909f3d5fa2
commit e401963d3a
12 changed files with 1717 additions and 53 deletions
+149
View File
@@ -98,12 +98,29 @@ import type {
ProfileTaskConfigAdminResponse,
ProfileWalletConfigAdminResponse,
} from './adminApiTypes';
import type {
AdminCreateThemeRequest,
AdminGameDistributionThemeListResponse,
AdminGameDistributionThemeMemberResponse,
AdminGameDistributionThemeMutationResponse,
AdminPublicThemeDetail,
AdminThemeStatusFilter,
AdminUpdateThemeRequest,
AdminUpsertThemeMemberRequest,
} from './adminGameThemeTypes';
const API_RESPONSE_ENVELOPE_HEADER = 'x-genarrative-response-envelope';
const ADMIN_API_BASE_URL = normalizeBaseUrl(
import.meta.env.VITE_ADMIN_API_BASE_URL ?? '',
);
/**
* 后台主题列表的 `limit` 上界:与服务端 `MAX_ADMIN_THEME_LIST_LIMIT` 同值。
*
* 服务端按上限截断(不报错),客户端也按同一口径夹一次,避免发出一条注定被截断的请求。
*/
const ADMIN_THEME_LIST_LIMIT_MAX = 200;
interface AdminRequestOptions {
method?: string;
token?: string;
@@ -1488,3 +1505,135 @@ export function importAdminAgcTemplates(token: string, formData: FormData) {
},
);
}
/**
* 后台共创主题列表:`GET /admin/api/game-distribution/themes?limit=&status=`。
*
* 含 draft / archived;`status=all`(或省略)不过滤。**没有游标**:`limit` 上限 200,服务端按
* 上限截断,这里也按同一口径夹一次,避免把一条必然被截断的请求发出去。
*/
export function listAdminGameDistributionThemes(
token: string,
options: {
limit?: number;
status?: AdminThemeStatusFilter;
} = {},
signal?: AbortSignal,
) {
const requestedLimit = options.limit ?? ADMIN_THEME_LIST_LIMIT_MAX;
const normalizedLimit = Number.isFinite(requestedLimit)
? Math.min(Math.max(Math.trunc(requestedLimit), 1), ADMIN_THEME_LIST_LIMIT_MAX)
: ADMIN_THEME_LIST_LIMIT_MAX;
const params = new URLSearchParams({ limit: String(normalizedLimit) });
const status = options.status?.trim();
if (status && status !== 'all') params.set('status', status);
return request<AdminGameDistributionThemeListResponse>(
`/admin/api/game-distribution/themes?${params.toString()}`,
{ token, signal },
);
}
/** 后台创建主题:`theme_id` 由服务端生成,客户端只给字段;写操作必须带幂等键。 */
export function createAdminGameDistributionTheme(
token: string,
idempotencyKey: string,
payload: AdminCreateThemeRequest,
) {
return request<AdminGameDistributionThemeMutationResponse>(
'/admin/api/game-distribution/themes',
{
token,
method: 'POST',
headers: { 'Idempotency-Key': normalizeThemeIdempotencyKey(idempotencyKey) },
body: payload,
},
);
}
/** 后台整体覆盖主题字段:`name/summary/badge/sortOrder/status` 都是必填(不是部分更新)。 */
export function updateAdminGameDistributionTheme(
token: string,
themeId: string,
idempotencyKey: string,
payload: AdminUpdateThemeRequest,
) {
return request<AdminGameDistributionThemeMutationResponse>(
`/admin/api/game-distribution/themes/${encodeURIComponent(normalizeThemeId(themeId))}`,
{
token,
method: 'PUT',
headers: { 'Idempotency-Key': normalizeThemeIdempotencyKey(idempotencyKey) },
body: payload,
},
);
}
/**
* 增 / 改主题成员:**不要求幂等键**(成员身份完全由路径给出,重复调用只改 `sortOrder`)。
*
* 只允许根作品:非根作品服务端回 409 `THEME_MEMBER_NOT_ROOT`。
*/
export function upsertAdminGameDistributionThemeMember(
token: string,
themeId: string,
rootGameId: string,
payload: AdminUpsertThemeMemberRequest,
) {
return request<AdminGameDistributionThemeMemberResponse>(
`/admin/api/game-distribution/themes/${encodeURIComponent(normalizeThemeId(themeId))}/members/${encodeURIComponent(normalizeRootGameId(rootGameId))}`,
{ token, method: 'PUT', body: payload },
);
}
/** 移除主题成员:不要求幂等键;成员不存在也算成功(200)。 */
export function removeAdminGameDistributionThemeMember(
token: string,
themeId: string,
rootGameId: string,
) {
return request<AdminGameDistributionThemeMemberResponse>(
`/admin/api/game-distribution/themes/${encodeURIComponent(normalizeThemeId(themeId))}/members/${encodeURIComponent(normalizeRootGameId(rootGameId))}`,
{ token, method: 'DELETE' },
);
}
/**
* 公开主题详情(匿名单读)。
*
* 后台用它列出**已发布**主题的当前可见成员:服务端目前没有「后台读成员」的接口(只有按根作品 ID
* 的 PUT/DELETE),可见成员能且只能从这条公开路径取得。未发布 / 已归档主题这里会 404,调用方按
* 「拿不到成员列表」处理,不要把它渲染成空列表。
*/
export function getPublicGameDistributionTheme(
themeId: string,
signal?: AbortSignal,
) {
return request<AdminPublicThemeDetail>(
`/api/game-distribution/themes/${encodeURIComponent(normalizeThemeId(themeId))}`,
{ signal },
);
}
function normalizeThemeId(themeId: string) {
const normalized = themeId.trim();
if (!normalized) {
throw new Error('缺少主题 ID');
}
return normalized;
}
function normalizeRootGameId(rootGameId: string) {
const normalized = rootGameId.trim();
if (!normalized) {
throw new Error('缺少根作品 ID');
}
return normalized;
}
function normalizeThemeIdempotencyKey(idempotencyKey: string) {
const normalized = idempotencyKey.trim();
if (!normalized || normalized.length > 128) {
throw new Error('主题写操作幂等键必须是 1 到 128 个字符');
}
return normalized;
}
@@ -0,0 +1,78 @@
/**
* 后台「共创主题」接口的请求 / 响应载荷类型。
*
* 契约来源是 `packages/shared` 的 `contracts/gameDistribution`,这里用**相对路径直接引用**而不是
* `@genarrative/shared`:仓库根的 `node_modules/@genarrative/shared` 软链指向主工作树,主题契约
* 只在本分支上,走包名会拿到缺主题类型的旧副本(AGC 侧同样用这种相对引用)。
*
* 为什么单独一个文件而不是并进 `adminApiTypes.ts`:那边被
* `scripts/check-game-distribution-dto-parity.mjs` 全量扫描(任何未登记的新类型都会判红),而主题
* 后台响应是**服务端手拼 JSON**、没有对应 Rust DTO,登记只能改 `scripts/`(本轮范围外,且另一个
* session 正在改它)。放在这里既不触发那条门禁,也不改共享契约。
*/
import type {
GameDistributionCreateThemeRequest,
GameDistributionThemeDetail,
GameDistributionThemeStatus,
GameDistributionUpdateThemeRequest,
GameDistributionUpsertThemeMemberRequest,
} from '../../../../packages/shared/src/contracts/gameDistribution';
/** 主题状态(与共享契约同源,不另立枚举)。 */
export type AdminThemeStatus = GameDistributionThemeStatus;
/** 后台主题列表的 `status` 查询值:`all` 表示不过滤(服务端白名单同值)。 */
export type AdminThemeStatusFilter = AdminThemeStatus | 'all';
/** 后台创建主题的请求体(与公开契约同一份定义)。 */
export type AdminCreateThemeRequest = GameDistributionCreateThemeRequest;
/** 后台更新主题的请求体:整体覆盖,字段全必填。 */
export type AdminUpdateThemeRequest = GameDistributionUpdateThemeRequest;
/** 后台成员写入请求体:只带运营权重。 */
export type AdminUpsertThemeMemberRequest = GameDistributionUpsertThemeMemberRequest;
/** 公开主题详情(后台用它读已发布主题的可见成员)。 */
export type AdminPublicThemeDetail = GameDistributionThemeDetail;
/**
* 后台主题条目(`GET /admin/api/game-distribution/themes` 的 `themes[]` 与写接口的 `theme`)。
*
* `memberCount` 是**成员行总数**(含当前不可见的成员):后台没有「不该被探测」的顾虑,运营需要
* 知道这个主题挂了几行;公开投影里的同名键是当前可见成员数,两者口径不同。
*/
export interface AdminGameDistributionTheme {
themeId: string;
name: string;
summary: string;
badge: string;
sortOrder: number;
status: AdminThemeStatus;
memberCount: number;
createdAt: string;
updatedAt: string;
}
/** 后台主题列表响应:只有 `themes` 一个键(这条路径不分页、无游标)。 */
export interface AdminGameDistributionThemeListResponse {
themes: AdminGameDistributionTheme[];
}
/** 后台创建 / 更新主题的响应:`replayed` 区分「这次真的写了」与「同键重放」。 */
export interface AdminGameDistributionThemeMutationResponse {
theme: AdminGameDistributionTheme;
replayed: boolean;
}
/**
* 后台成员写入响应。
*
* `sortOrder` 是运营权重(成员列表按它升序);重复 PUT 只改它、不刷新 `createdAt`。
*/
export interface AdminGameDistributionThemeMemberResponse {
themeId: string;
rootGameId: string;
sortOrder: number;
createdAt: string;
}
+7
View File
@@ -32,6 +32,7 @@ import { AdminErrorReportsPage } from '../pages/AdminErrorReportsPage';
import { AdminGameDistributionReviewPage } from '../pages/AdminGameDistributionReviewPage';
import { AdminGameManagementPage } from '../pages/AdminGameManagementPage';
import { AdminGameReviewsPage } from '../pages/AdminGameReviewsPage';
import { AdminGameThemesPage } from '../pages/AdminGameThemesPage';
import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage';
import { AdminInviteCodePage } from '../pages/AdminInviteCodePage';
import { AdminLoginPage } from '../pages/AdminLoginPage';
@@ -346,6 +347,12 @@ export function AdminApp() {
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'game-themes' ? (
<AdminGameThemesPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'editor-assets' ? (
<AdminEditorAssetQueryPage
token={token}
+2
View File
@@ -10,6 +10,7 @@ import {
Gamepad2,
GitBranch,
Images,
Layers,
LayoutDashboard,
ListChecks,
LogOut,
@@ -63,6 +64,7 @@ const routeIcons = {
'game-distribution': Gamepad2,
'game-management': Swords,
'game-reviews': Star,
'game-themes': Layers,
'editor-assets': Images,
'project-snapshots': FolderArchive,
accounts: Users,
+3
View File
@@ -20,6 +20,7 @@ export type AdminRouteId =
| 'game-distribution'
| 'game-management'
| 'game-reviews'
| 'game-themes'
| 'editor-assets'
| 'project-snapshots'
| 'agc-models'
@@ -66,6 +67,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
{ id: 'game-distribution', label: '游戏审核', hash: '#game-distribution' },
{ id: 'game-management', label: '游戏管理', hash: '#game-management' },
{ id: 'game-reviews', label: '游戏评价', hash: '#game-reviews' },
{ id: 'game-themes', label: '共创主题', hash: '#game-themes' },
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
{ id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' },
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
@@ -124,6 +126,7 @@ export const adminNavGroups: AdminNavGroupDefinition[] = [
'game-distribution',
'game-management',
'game-reviews',
'game-themes',
'editor-assets',
'project-snapshots',
],
@@ -0,0 +1,343 @@
/* @vitest-environment jsdom */
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import {
createAdminGameDistributionTheme,
getPublicGameDistributionTheme,
listAdminGameDistributionThemes,
removeAdminGameDistributionThemeMember,
updateAdminGameDistributionTheme,
upsertAdminGameDistributionThemeMember,
} from '../api/adminApiClient';
import type { AdminGameDistributionTheme } from '../api/adminGameThemeTypes';
import { AdminGameThemesPage } from './AdminGameThemesPage';
vi.mock('../api/adminApiClient', () => ({
isAdminApiError: vi.fn(
(error: unknown) =>
typeof error === 'object' &&
error !== null &&
'status' in error &&
typeof error.status === 'number',
),
formatAdminApiError: vi.fn((error: unknown) =>
error instanceof Error ? error.message : '请求失败',
),
listAdminGameDistributionThemes: vi.fn(),
createAdminGameDistributionTheme: vi.fn(),
updateAdminGameDistributionTheme: vi.fn(),
upsertAdminGameDistributionThemeMember: vi.fn(),
removeAdminGameDistributionThemeMember: vi.fn(),
getPublicGameDistributionTheme: vi.fn(),
}));
const publishedTheme: AdminGameDistributionTheme = {
themeId: 'theme_1',
name: '星际防线',
summary: '同一母版下的二创合集',
badge: '热门',
sortOrder: 10,
status: 'published',
memberCount: 3,
createdAt: '2026-10-01T08:00:00Z',
updatedAt: '2026-10-02T08:00:00Z',
};
const draftTheme: AdminGameDistributionTheme = {
...publishedTheme,
themeId: 'theme_2',
name: '草稿主题',
badge: '新',
sortOrder: 20,
status: 'draft',
memberCount: 0,
};
function mockList(themes: AdminGameDistributionTheme[] = [publishedTheme]) {
vi.mocked(listAdminGameDistributionThemes).mockResolvedValue({ themes });
}
function renderPage(
onUnauthorized: (message?: string) => void = () => undefined,
) {
return render(
<AdminGameThemesPage token="admin-token" onUnauthorized={onUnauthorized} />,
);
}
beforeEach(() => {
vi.clearAllMocks();
mockList();
// 成员面板打开时会读公开投影;默认给一个空 roots,个别用例再覆盖。
vi.mocked(getPublicGameDistributionTheme).mockResolvedValue({
roots: [],
} as never);
});
afterEach(() => {
cleanup();
});
test('列表展示名称、角标、状态、排序与成员数,并按状态过滤', async () => {
renderPage();
const row = (await screen.findByText('星际防线')).closest('tr')!;
expect(within(row).getByText('热门')).toBeTruthy();
expect(within(row).getByText('已发布')).toBeTruthy();
expect(within(row).getByText('10')).toBeTruthy();
expect(within(row).getByText('3')).toBeTruthy();
expect(within(row).getByText('theme_1')).toBeTruthy();
expect(listAdminGameDistributionThemes).toHaveBeenCalledWith(
'admin-token',
{ limit: 200, status: 'all' },
expect.anything(),
);
// 切到归档过滤:请求带上 status,且列表按新数据渲染。
mockList([{ ...draftTheme, status: 'archived', name: '归档主题' }]);
fireEvent.change(screen.getByLabelText('状态过滤'), {
target: { value: 'archived' },
});
fireEvent.click(screen.getByRole('button', { name: /查询/ }));
await waitFor(() =>
expect(listAdminGameDistributionThemes).toHaveBeenLastCalledWith(
'admin-token',
{ limit: 200, status: 'archived' },
expect.anything(),
),
);
expect(await screen.findByText('归档主题')).toBeTruthy();
});
test('创建主题:先校验再写,写操作带幂等键,重放要提示', async () => {
vi.mocked(createAdminGameDistributionTheme).mockResolvedValue({
theme: publishedTheme,
replayed: true,
});
renderPage();
await screen.findByText('星际防线');
fireEvent.click(screen.getByRole('button', { name: '新建主题' }));
const dialog = await screen.findByRole('dialog');
// 名称为空:本地就拦下,不发请求。
fireEvent.click(within(dialog).getByRole('button', { name: '创建' }));
expect(
await within(dialog).findByText('主题名不能为空'),
).toBeTruthy();
expect(createAdminGameDistributionTheme).not.toHaveBeenCalled();
fireEvent.change(within(dialog).getByLabelText('新建主题主题名'), {
target: { value: '星际防线二' },
});
fireEvent.change(within(dialog).getByLabelText('新建主题简介'), {
target: { value: '第二个主题' },
});
fireEvent.change(within(dialog).getByLabelText('新建主题角标'), {
target: { value: '新' },
});
fireEvent.change(within(dialog).getByLabelText('新建主题排序号'), {
target: { value: '5' },
});
fireEvent.click(within(dialog).getByRole('button', { name: '创建' }));
// 写操作要先过确认对话框。
const confirm = await screen.findByRole('dialog', { name: '确认操作' });
fireEvent.click(within(confirm).getByRole('button', { name: '确认' }));
await waitFor(() =>
expect(createAdminGameDistributionTheme).toHaveBeenCalledTimes(1),
);
const [token, idempotencyKey, payload] = vi.mocked(
createAdminGameDistributionTheme,
).mock.calls[0] ?? [];
expect(token).toBe('admin-token');
expect(payload).toEqual({
name: '星际防线二',
summary: '第二个主题',
badge: '新',
sortOrder: 5,
status: 'draft',
});
expect(String(idempotencyKey).length).toBeGreaterThan(8);
expect(String(idempotencyKey).length).toBeLessThanOrEqual(128);
expect(
await screen.findByText(/已存在(幂等重放,未重复写入)/),
).toBeTruthy();
});
test('创建时同键换请求的 409 要原样解释给运营', async () => {
vi.mocked(createAdminGameDistributionTheme).mockRejectedValue(
Object.assign(new Error('Idempotency-Key 已用于不同的请求'), {
status: 409,
code: 'ADMIN_IDEMPOTENCY_CONFLICT',
}),
);
renderPage();
await screen.findByText('星际防线');
fireEvent.click(screen.getByRole('button', { name: '新建主题' }));
const dialog = await screen.findByRole('dialog');
fireEvent.change(within(dialog).getByLabelText('新建主题主题名'), {
target: { value: '冲突主题' },
});
fireEvent.click(within(dialog).getByRole('button', { name: '创建' }));
const confirm = await screen.findByRole('dialog', { name: '确认操作' });
fireEvent.click(within(confirm).getByRole('button', { name: '确认' }));
expect(
await within(dialog).findByText(/Idempotency-Key 已用于不同的请求/),
).toBeTruthy();
});
test('编辑主题:整体覆盖字段,并提示归档后公开侧不可读', async () => {
vi.mocked(updateAdminGameDistributionTheme).mockResolvedValue({
theme: { ...draftTheme, status: 'archived' },
replayed: false,
});
renderPage();
const row = (await screen.findByText('星际防线')).closest('tr')!;
fireEvent.click(within(row).getByRole('button', { name: '编辑' }));
const dialog = await screen.findByRole('dialog');
expect(
within(dialog).getByText(/归档(archived)后公开侧不可读/),
).toBeTruthy();
fireEvent.change(within(dialog).getByLabelText('编辑主题主题名'), {
target: { value: '星际防线(归档)' },
});
fireEvent.change(within(dialog).getByLabelText('编辑主题状态'), {
target: { value: 'archived' },
});
fireEvent.click(within(dialog).getByRole('button', { name: '保存' }));
const confirm = await screen.findByRole('dialog', { name: '确认操作' });
fireEvent.click(within(confirm).getByRole('button', { name: '确认' }));
await waitFor(() =>
expect(updateAdminGameDistributionTheme).toHaveBeenCalledTimes(1),
);
const [token, themeId, , payload] = vi.mocked(
updateAdminGameDistributionTheme,
).mock.calls[0] ?? [];
expect(token).toBe('admin-token');
expect(themeId).toBe('theme_1');
expect(payload).toMatchObject({
name: '星际防线(归档)',
status: 'archived',
});
});
test('加非根作品:把 409 翻成「只能收录根作品」的可读原因', async () => {
vi.mocked(upsertAdminGameDistributionThemeMember).mockRejectedValue(
Object.assign(new Error('not root'), {
status: 409,
code: 'THEME_MEMBER_NOT_ROOT',
}),
);
renderPage();
const row = (await screen.findByText('星际防线')).closest('tr')!;
fireEvent.click(within(row).getByRole('button', { name: '成员' }));
const dialog = await screen.findByRole('dialog');
fireEvent.change(within(dialog).getByLabelText('根作品 ID'), {
target: { value: 'game_child_1' },
});
fireEvent.click(within(dialog).getByRole('button', { name: '添加成员' }));
expect(
await within(dialog).findByText(
'该作品不是根作品(母版);主题只能收录根作品',
),
).toBeTruthy();
expect(upsertAdminGameDistributionThemeMember).toHaveBeenCalledWith(
'admin-token',
'theme_1',
'game_child_1',
{ sortOrder: 0 },
);
});
test('成员面板:已发布主题列出可见成员并支持逐条移除', async () => {
vi.mocked(getPublicGameDistributionTheme).mockResolvedValue({
theme: {
themeId: 'theme_1',
name: '星际防线',
summary: '',
badge: '热门',
sortOrder: 10,
memberCount: 3,
updatedAt: '2026-10-02T08:00:00Z',
},
roots: [
{ id: 'game_root_1', title: '母版作品' },
{ id: 'game_root_2', title: '另一母版' },
],
} as never);
vi.mocked(removeAdminGameDistributionThemeMember).mockResolvedValue({
themeId: 'theme_1',
rootGameId: 'game_root_1',
sortOrder: 0,
createdAt: '2026-10-01T08:00:00Z',
});
renderPage();
const row = (await screen.findByText('星际防线')).closest('tr')!;
fireEvent.click(within(row).getByRole('button', { name: '成员' }));
const dialog = await screen.findByRole('dialog');
const members = await within(dialog).findByRole('list', {
name: '公开可见成员',
});
expect(within(members).getByText('母版作品')).toBeTruthy();
expect(within(members).getByText('game_root_1')).toBeTruthy();
// 只移除点中的那一条。
fireEvent.click(within(members).getAllByRole('button', { name: '移除' })[0]!);
await waitFor(() =>
expect(removeAdminGameDistributionThemeMember).toHaveBeenCalledWith(
'admin-token',
'theme_1',
'game_root_1',
),
);
await waitFor(() =>
expect(within(dialog).queryByText('game_root_1')).toBeNull(),
);
expect(within(dialog).getByText('game_root_2')).toBeTruthy();
});
test('成员面板:未发布主题明说读不到成员列表,而不是渲染成空', async () => {
mockList([draftTheme]);
renderPage();
const row = (await screen.findByText('草稿主题')).closest('tr')!;
fireEvent.click(within(row).getByRole('button', { name: '成员' }));
const dialog = await screen.findByRole('dialog');
expect(
await within(dialog).findByText(/公开侧读不到成员列表/),
).toBeTruthy();
expect(getPublicGameDistributionTheme).not.toHaveBeenCalled();
});
test('401 时交给上层处理登录失效,不把错误塞进页面', async () => {
const onUnauthorized = vi.fn();
vi.mocked(listAdminGameDistributionThemes).mockRejectedValue(
Object.assign(new Error('unauthorized'), { status: 401 }),
);
renderPage(onUnauthorized);
await waitFor(() =>
expect(onUnauthorized).toHaveBeenCalledWith('登录状态已失效'),
);
});
File diff suppressed because it is too large Load Diff
@@ -8678,33 +8678,109 @@ mod tests {
assert_eq!(GAME_DISTRIBUTION_THEME_PAGE_LIMIT_DEFAULT, 20);
}
/// 主题列表非法游标必须落 400:模块侧文案经 `map_spacetime_error` 的兜底分支,
/// 不得被 404 / 409 的子串规则抢先命中(与 `/my-collections` 同一条纪律)。
/// 主题列表非法游标必须落 400 **且带稳定码 `THEME_INVALID_CURSOR`**。
///
/// 这条测试是那次真缺陷的回归网:模块侧曾经只回中文串「主题列表游标格式无效」,不含 `THEME_`,
/// 于是 `THEME_` 映射分支不命中,落兜底 400 + 通用 `BAD_REQUEST`——契约里的稳定码在 HTTP 面上
/// **不可达**,客户端只能匹配中文文案。现在两头都钉住:模块产出前缀(模块侧枚举测试),
/// 这里钉「HTTP 面真的映射出那个码」。
///
/// 文案不得含 `FORK_`:`FORK_` 分支排在 `THEME_` **之前**,只要文案里出现 `FORK_`,这条错误
/// 就会被 FORK 分支抢走(其余「不存在」「状态」等子串都在 `THEME_` 之后,抢不到了)。
#[test]
fn theme_list_invalid_cursor_maps_to_bad_request() {
let message = module_game_distribution::parse_game_distribution_theme_cursor("不是游标")
.expect_err("非法游标必须报错");
assert!(message.contains("格式无效"), "{message}");
for forbidden in [
"不存在",
"已被删除",
"状态",
"不匹配",
"幂等",
"已存在",
"FORK_",
"THEME_",
] {
assert!(
!message.contains("FORK_"),
"游标错误文案不得含 `FORK_`(FORK 分支排在 THEME 之前,会被它抢走):{message}"
);
let error = map_spacetime_error(SpacetimeClientError::Procedure(message.clone()));
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST, "{message}");
assert_eq!(
error.code(),
GAME_DISTRIBUTION_THEME_INVALID_CURSOR,
"非法游标必须映射出契约里的稳定码,否则客户端只能匹配中文文案:{message}"
);
}
/// **可达性**:6 个主题码里每一个都必须能从**模块真实产出的文案**映射出 (状态码, 稳定码)。
///
/// 这是那次真缺陷的根因所在的另一面:`theme_errors_map_to_documented_status_codes` 用的是
/// `format!("{code}: 具体原因")` 这类**合成**文案,只能证明「映射表里有这条」,证明不了「模块
/// 真的会产出这个前缀」——`THEME_INVALID_CURSOR` 就此成了死代码。这条测试改用模块真实产出的
/// 消息(5 个领域变体的 `Display` + 游标解析的裸字符串),顺带比对模块常量与 `shared-contracts`
/// 常量是同一组字符串(两侧改字都会红)。
#[test]
fn theme_error_codes_are_reachable_from_module_messages() {
use module_game_distribution::GameDistributionError as DomainError;
let cases = [
(
DomainError::ThemeNotFound.to_string(),
module_game_distribution::GAME_DISTRIBUTION_THEME_NOT_FOUND_CODE,
GAME_DISTRIBUTION_THEME_NOT_FOUND,
StatusCode::NOT_FOUND,
),
(
DomainError::ThemeBadRequest {
reason: "主题名不能为空".to_string(),
}
.to_string(),
module_game_distribution::GAME_DISTRIBUTION_THEME_BAD_REQUEST_CODE,
GAME_DISTRIBUTION_THEME_BAD_REQUEST,
StatusCode::BAD_REQUEST,
),
(
module_game_distribution::parse_game_distribution_theme_cursor("不是游标")
.expect_err("非法游标必须报错"),
module_game_distribution::GAME_DISTRIBUTION_THEME_INVALID_CURSOR_CODE,
GAME_DISTRIBUTION_THEME_INVALID_CURSOR,
StatusCode::BAD_REQUEST,
),
(
DomainError::ThemeIdempotencyConflict.to_string(),
module_game_distribution::GAME_DISTRIBUTION_THEME_IDEMPOTENCY_CONFLICT_CODE,
GAME_DISTRIBUTION_THEME_IDEMPOTENCY_CONFLICT,
StatusCode::CONFLICT,
),
(
DomainError::ThemeMemberNotRoot {
game_id: "game_child".to_string(),
}
.to_string(),
module_game_distribution::GAME_DISTRIBUTION_THEME_MEMBER_NOT_ROOT_CODE,
GAME_DISTRIBUTION_THEME_MEMBER_NOT_ROOT,
StatusCode::CONFLICT,
),
(
DomainError::ThemeMemberGameNotFound {
game_id: "game_missing".to_string(),
}
.to_string(),
module_game_distribution::GAME_DISTRIBUTION_THEME_MEMBER_GAME_NOT_FOUND_CODE,
GAME_DISTRIBUTION_THEME_MEMBER_GAME_NOT_FOUND,
StatusCode::NOT_FOUND,
),
];
for (message, module_code, contract_code, expected_status) in &cases {
assert_eq!(
module_code, contract_code,
"模块常量与 shared-contracts 常量必须是同一组字符串:{message}"
);
assert!(
!message.contains(forbidden),
"游标错误文案不得含「{forbidden}」:{message}"
message.starts_with(contract_code),
"模块产出的文案必须以契约码开头(否则 HTTP 面不可达):{message}"
);
let error = map_spacetime_error(SpacetimeClientError::Procedure(message.clone()));
assert_eq!(error.status_code(), *expected_status, "{message}");
assert_eq!(
error.code(),
*contract_code,
"稳定码必须原样透传到 HTTP 面:{message}"
);
}
assert_eq!(
map_spacetime_error(SpacetimeClientError::Procedure(message.clone())).status_code(),
StatusCode::BAD_REQUEST,
"{message}"
);
}
/// 主题列表响应形状:`themes` / `nextCursor` 一定发出;游标是真实值,最后一页为 `null`;
@@ -438,39 +438,55 @@ mod fork_authorization_tests {
}
}
/// 共创主题同理:后台写接口的错误码同样靠前缀被 HTTP 层识别,文案格式一变就静默退化成通用 4xx。
/// 共创主题同理:错误码同样靠前缀被 HTTP 层识别,文案格式一变就静默退化成通用 4xx。
///
/// 这里钉的是**领域侧**的半张表(字面量),另一半(`shared-contracts` 的常量 + 状态码)由
/// api-server 的映射测试钉;两半都对着同一组字符串,因此任何一侧改字都会红。
/// 这条测试**枚举全部主题码**——包括唯一不走领域变体的那条(游标解析只回裸字符串,`Display`
/// 的前缀测试天然覆盖不到它)。正因为当初漏了它,才有过「技术方案承诺 `THEME_INVALID_CURSOR`、
/// HTTP 面实际只回通用 `BAD_REQUEST`」这个真缺陷。
///
/// 这里钉的是**领域侧**的半张表(本 crate 的码常量,`Display` 也复用同一批常量);另一半
/// (`shared-contracts` 的同名常量 + 状态码)由 api-server 的
/// `theme_error_codes_are_reachable_from_module_messages` 钉;两半都对着同一组字符串,
/// 因此任何一侧改字都会红。
#[test]
fn theme_errors_are_prefixed_with_their_machine_code() {
let cases = [
(GameDistributionError::ThemeNotFound, "THEME_NOT_FOUND"),
(
GameDistributionError::ThemeNotFound.to_string(),
crate::GAME_DISTRIBUTION_THEME_NOT_FOUND_CODE,
),
(
GameDistributionError::ThemeBadRequest {
reason: "主题名不能为空".to_string(),
},
"THEME_BAD_REQUEST",
}
.to_string(),
crate::GAME_DISTRIBUTION_THEME_BAD_REQUEST_CODE,
),
(
GameDistributionError::ThemeIdempotencyConflict,
"THEME_IDEMPOTENCY_CONFLICT",
GameDistributionError::ThemeIdempotencyConflict.to_string(),
crate::GAME_DISTRIBUTION_THEME_IDEMPOTENCY_CONFLICT_CODE,
),
(
GameDistributionError::ThemeMemberNotRoot {
game_id: "game_child".to_string(),
},
"THEME_MEMBER_NOT_ROOT",
}
.to_string(),
crate::GAME_DISTRIBUTION_THEME_MEMBER_NOT_ROOT_CODE,
),
(
GameDistributionError::ThemeMemberGameNotFound {
game_id: "game_missing".to_string(),
},
"THEME_MEMBER_GAME_NOT_FOUND",
}
.to_string(),
crate::GAME_DISTRIBUTION_THEME_MEMBER_GAME_NOT_FOUND_CODE,
),
(
crate::parse_game_distribution_theme_cursor("不是游标")
.expect_err("非法游标必须报错"),
crate::GAME_DISTRIBUTION_THEME_INVALID_CURSOR_CODE,
),
];
for (error, code) in cases {
let message = error.to_string();
for (message, code) in &cases {
assert!(
message.starts_with(code),
"expected {code} prefix, got {message}"
@@ -482,6 +498,21 @@ mod fork_authorization_tests {
"expected {code} to be followed by ':', got {message}"
);
}
// 覆盖完整性:本 crate 一共只有这 6 个主题码,将来新增的码必须一并进这张表,否则
// 新码的「HTTP 面是否可达」又会变成只有真栈才能发现的事。
for code in [
crate::GAME_DISTRIBUTION_THEME_NOT_FOUND_CODE,
crate::GAME_DISTRIBUTION_THEME_BAD_REQUEST_CODE,
crate::GAME_DISTRIBUTION_THEME_INVALID_CURSOR_CODE,
crate::GAME_DISTRIBUTION_THEME_IDEMPOTENCY_CONFLICT_CODE,
crate::GAME_DISTRIBUTION_THEME_MEMBER_NOT_ROOT_CODE,
crate::GAME_DISTRIBUTION_THEME_MEMBER_GAME_NOT_FOUND_CODE,
] {
assert!(
cases.iter().any(|(_, covered)| *covered == code),
"主题码 {code} 未被这条枚举测试覆盖"
);
}
// 空名原因要原样透出:400 只有一个状态码,区分「空名」与「超长」只有靠这句。
let empty_name = GameDistributionError::ThemeBadRequest {
reason: "主题名不能为空".to_string(),
@@ -2,6 +2,27 @@ use std::{error::Error, fmt};
use crate::{GameVersionStatus, GameVisibility};
/// 共创主题错误码的字面量。
///
/// 与 `shared-contracts` 里的同名常量是**同一组字符串的两个副本**:本 crate 不依赖
/// `shared-contracts`(那边也不依赖本 crate),所以无法直接共用。两侧由 api-server 的一条测试
/// 逐码比对(`theme_error_codes_are_reachable_from_module_messages`),任何一侧改字都会红。
///
/// 这些码必须出现在**每条**主题错误的文案开头(`"{CODE}: {中文说明}"`):HTTP 层靠「文案含
/// `THEME_` + 码前缀」映射稳定码,少一个前缀就静默退化成通用 `BAD_REQUEST`——游标解析就是
/// 这样漏过一次(游标错误不是领域变体,只是裸字符串,所以不在下面的 `Display` 里)。
pub const GAME_DISTRIBUTION_THEME_NOT_FOUND_CODE: &str = "THEME_NOT_FOUND";
/// 后台请求参数非法(空名 / 超长 / 状态不在白名单)。
pub const GAME_DISTRIBUTION_THEME_BAD_REQUEST_CODE: &str = "THEME_BAD_REQUEST";
/// 主题列表游标格式非法。
pub const GAME_DISTRIBUTION_THEME_INVALID_CURSOR_CODE: &str = "THEME_INVALID_CURSOR";
/// 同 `Idempotency-Key` 不同请求摘要。
pub const GAME_DISTRIBUTION_THEME_IDEMPOTENCY_CONFLICT_CODE: &str = "THEME_IDEMPOTENCY_CONFLICT";
/// 目标作品有血缘行(非根),不能作为主题成员。
pub const GAME_DISTRIBUTION_THEME_MEMBER_NOT_ROOT_CODE: &str = "THEME_MEMBER_NOT_ROOT";
/// 作为成员的目标作品不存在。
pub const GAME_DISTRIBUTION_THEME_MEMBER_GAME_NOT_FOUND_CODE: &str = "THEME_MEMBER_GAME_NOT_FOUND";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GameDistributionFieldError {
MissingGameId,
@@ -154,21 +175,35 @@ impl fmt::Display for GameDistributionError {
.write_str("FORK_SOURCE_VERSION_MISMATCH: 改编来源版本不是该作品的当前公开版本"),
Self::ForkDeclarationOnExistingGame => formatter
.write_str("FORK_DECLARATION_ON_EXISTING_GAME: 已存在的作品不能再声明改编来源"),
// 共创主题同理:文案以错误码开头,HTTP 层按码映射(不做子串猜测)。
Self::ThemeNotFound => formatter.write_str("THEME_NOT_FOUND: 主题不存在"),
// 共创主题同理:文案以错误码开头,HTTP 层按码映射(不做子串猜测);码串统一取上面的常量,
// 避免「常量改了、Display 没改」这类只有真栈才会发现的漂移。
Self::ThemeNotFound => write!(
formatter,
"{}: 主题不存在",
GAME_DISTRIBUTION_THEME_NOT_FOUND_CODE
),
Self::ThemeBadRequest { reason } => {
write!(formatter, "THEME_BAD_REQUEST: {reason}")
write!(
formatter,
"{}: {reason}",
GAME_DISTRIBUTION_THEME_BAD_REQUEST_CODE
)
}
Self::ThemeIdempotencyConflict => formatter
.write_str("THEME_IDEMPOTENCY_CONFLICT: 同一个 Idempotency-Key 对应了不同的请求"),
Self::ThemeIdempotencyConflict => write!(
formatter,
"{}: 同一个 Idempotency-Key 对应了不同的请求",
GAME_DISTRIBUTION_THEME_IDEMPOTENCY_CONFLICT_CODE
),
Self::ThemeMemberNotRoot { game_id } => write!(
formatter,
"THEME_MEMBER_NOT_ROOT: 作品 {game_id} 有血缘行(不是根作品),不能作为主题成员"
"{}: 作品 {game_id} 有血缘行(不是根作品),不能作为主题成员",
GAME_DISTRIBUTION_THEME_MEMBER_NOT_ROOT_CODE
),
Self::ThemeMemberGameNotFound { game_id } => {
write!(
formatter,
"THEME_MEMBER_GAME_NOT_FOUND: 作品 {game_id} 不存在"
"{}: 作品 {game_id} 不存在",
GAME_DISTRIBUTION_THEME_MEMBER_GAME_NOT_FOUND_CODE
)
}
}
@@ -37,7 +37,13 @@ pub use domain::{
counts_as_public_derivative, fork_lineage_visible_identity, generate_game_id,
generate_game_version_id, next_generation, resolve_version_number,
};
pub use errors::{GameDistributionError, GameDistributionFieldError};
pub use errors::{
GAME_DISTRIBUTION_THEME_BAD_REQUEST_CODE, GAME_DISTRIBUTION_THEME_IDEMPOTENCY_CONFLICT_CODE,
GAME_DISTRIBUTION_THEME_INVALID_CURSOR_CODE,
GAME_DISTRIBUTION_THEME_MEMBER_GAME_NOT_FOUND_CODE,
GAME_DISTRIBUTION_THEME_MEMBER_NOT_ROOT_CODE, GAME_DISTRIBUTION_THEME_NOT_FOUND_CODE,
GameDistributionError, GameDistributionFieldError,
};
pub use events::GameDistributionEvent;
pub use lineage::{
GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT, LineageGameFacts, LineageLink, LineageNodeList,
@@ -197,20 +197,26 @@ pub fn encode_game_distribution_theme_cursor(created_at_micros: i64, theme_id: &
format!("{created_at_micros}:{theme_id}")
}
/// 主题列表游标解析;格式非法时返回 `Err`(api-server 会把它透传成 400)。
/// 主题列表游标解析;格式非法时返回带**稳定码**的 `Err`:`THEME_INVALID_CURSOR: …`。
///
/// 严格的错误文案是刻意的:它不得含「不存在」「已被删除」「状态」「不匹配」「幂等」「已存在」
/// 「FORK_」等子串,否则会被 api-server 的既有 `map_spacetime_error` 分支抢先映射成 404 / 409;
/// 「格式无效」只会落到兜底的 400,这正是「客户端传了坏游标」的语义。
/// 文案必须以码开头(`"{CODE}: {中文说明}"`):api-server 的 `THEME_` 映射分支要求消息含 `THEME_`
/// 且按冒号前的码查状态码表,少了前缀就静默退化成兜底的 400 + 通用 `BAD_REQUEST`,客户端只能去
/// 匹配中文——这正是本项目出过一次的真缺陷(游标错误不是领域变体,只是裸字符串,`Display` 的
/// 前缀测试覆盖不到它,所以另有一条测试专门枚举全部主题码)。
///
/// 仍然刻意避开的中文子串:只保留了**排在 `THEME_` 分支之前**的 `FORK_` 不能出现(否则会被
/// `FORK_` 分支抢先映射);「不存在」「状态」等子串不再有害——`THEME_` 分支先命中,码由前缀决定。
pub fn parse_game_distribution_theme_cursor(value: &str) -> Result<(i64, String), String> {
let (micros, theme_id) = value
.split_once(':')
.ok_or_else(|| "主题列表游标格式无效".to_string())?;
let micros = micros
.parse::<i64>()
.map_err(|_| "主题列表游标格式无效".to_string())?;
let invalid = || {
format!(
"{}: 主题列表游标格式无效",
crate::GAME_DISTRIBUTION_THEME_INVALID_CURSOR_CODE
)
};
let (micros, theme_id) = value.split_once(':').ok_or_else(invalid)?;
let micros = micros.parse::<i64>().map_err(|_| invalid())?;
if theme_id.is_empty() {
return Err("主题列表游标格式无效".to_string());
return Err(invalid());
}
Ok((micros, theme_id.to_string()))
}
@@ -543,11 +549,19 @@ mod tests {
}
}
/// 非法游标文案必须落 400:不得含会被 api-server 抢先映射成 404 / 409 的子串。
/// 非法游标文案必须以**稳定码**开头,且不含会被 api-server 抢先映射的子串。
///
/// 只有排在 `THEME_` 分支**之前**的分支才会抢:`map_spacetime_error` 里 `FORK_` 在前、
/// `THEME_` 紧随其后,`owner 不匹配` / `已被删除` / 409 的「状态」子串都在 `THEME_` **之后**,
/// 因此不再需要回避它们(这里保留断言,是为了让「顺序一变就红」这件事仍然可见)。
#[test]
fn invalid_cursor_message_stays_in_the_bad_request_bucket() {
let message =
parse_game_distribution_theme_cursor("不是游标").expect_err("非法游标必须报错");
assert!(
message.starts_with(crate::GAME_DISTRIBUTION_THEME_INVALID_CURSOR_CODE),
"游标错误必须以稳定码开头,否则 HTTP 面只能回通用 BAD_REQUEST:{message}"
);
assert!(message.contains("格式无效"), "{message}");
for forbidden in [
"不存在",