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;
}