diff --git a/packages/shared/src/contracts/gameDistribution.ts b/packages/shared/src/contracts/gameDistribution.ts index ea24d11c2..afd6cce28 100644 --- a/packages/shared/src/contracts/gameDistribution.ts +++ b/packages/shared/src/contracts/gameDistribution.ts @@ -421,6 +421,70 @@ export type GameDistributionUpsertThemeMemberRequest = { sortOrder?: number; }; +/** + * 后台主题条目:`{ themeId, name, summary, badge, sortOrder, status, memberCount, createdAt, updatedAt }`。 + * + * 与公开的 `GameDistributionThemeSummary` **刻意不同形**:后台多出 `sortOrder` / `status` / 时间戳, + * 且 `memberCount` 的口径是**成员行总数**(含当前对外不可见的成员)——后台没有「不该被探测」的顾虑, + * 运营需要知道这个主题挂了几个根;公开投影里的同名键是**当前可见**成员数。 + * + * 没有 Rust 结构体可对(服务端由 `admin_theme_payload` 逐字段手拼 JSON),因此在 DTO parity 里 + * 走 `TS_ONLY_TYPES` + `RESPONSE_BUILDERS` 登记:形状由机器门禁钉住,后台 UI 与实现不能各自漂移。 + */ +export type GameDistributionAdminTheme = { + themeId: string; + name: string; + summary: string; + badge: string; + sortOrder: number; + status: GameDistributionThemeStatus; + memberCount: number; + createdAt: string; + updatedAt: string; +}; + +/** + * 后台主题列表响应:`{ themes: [...] }`。 + * + * **没有 `nextCursor`**:这条路径不分页(`limit` 缺省与上限都是 200),因此这里也不能声明可选游标, + * 否则前端会以为存在第二页。 + */ +export type GameDistributionAdminThemeListResponse = { + themes: GameDistributionAdminTheme[]; +}; + +/** + * 后台创建 / 更新主题的响应:`{ theme, replayed }`。 + * + * `replayed` 必须原样发出:客户端据此区分「这次真的写了」与「同键重放,库里没动」。 + */ +export type GameDistributionAdminThemeMutationResponse = { + theme: GameDistributionAdminTheme; + replayed: boolean; +}; + +/** + * 后台成员写入的响应:`{ themeId, rootGameId, sortOrder, createdAt }`。 + * + * `createdAt` 是**首次挂载**的时间:重复 `PUT` 只改 `sortOrder`,不会刷新它。 + */ +export type GameDistributionAdminThemeMemberResponse = { + themeId: string; + rootGameId: string; + sortOrder: number; + createdAt: string; +}; + +/** + * 后台移除成员的响应:`{ themeId, rootGameId }`。 + * + * 刻意不含「之前存不存在」:删掉的与本来就没有的回同一个形状,重复调用逐字节相同。 + */ +export type GameDistributionAdminThemeMemberRemovalResponse = { + themeId: string; + rootGameId: string; +}; + export type GameDistributionCreateGameRequest = { /** 发布方本地项目标识;同一作者重复发布会复用既有 gameId。 */ localProjectId?: string | null; diff --git a/scripts/check-game-distribution-dto-parity.mjs b/scripts/check-game-distribution-dto-parity.mjs index 91efa9a5a..89f306da8 100644 --- a/scripts/check-game-distribution-dto-parity.mjs +++ b/scripts/check-game-distribution-dto-parity.mjs @@ -125,6 +125,13 @@ const TS_ONLY_TYPES = [ 'GameDistributionVersionDetail', 'GameDistributionCancelVersionRequest', 'GameDistributionCancelVersionResponse', + // 后台主题的四个响应形状:服务端也是逐字段手拼 JSON(`admin_theme_payload` 一族), + // 因此同样只能走本清单 + `RESPONSE_BUILDERS` 登记,而不是 Rust↔TS 映射表。 + 'GameDistributionAdminTheme', + 'GameDistributionAdminThemeListResponse', + 'GameDistributionAdminThemeMutationResponse', + 'GameDistributionAdminThemeMemberResponse', + 'GameDistributionAdminThemeMemberRemovalResponse', ]; // 服务端逐字段手拼 JSON 的响应构建器:把「这个函数真的会发出的顶层键」与 TS 类型逐键比对。 @@ -199,6 +206,49 @@ const RESPONSE_BUILDERS = [ ts: 'GameDistributionThemeReference', mustEmit: ['themeId', 'name', 'badge'], }, + { + // 后台主题条目:`admin_themes_payload` 逐条调用它,且它比公开的 `GameDistributionThemeSummary` + // 多出 `sortOrder` / `status` / 时间戳、`memberCount` 口径也不同(成员**行**总数), + // 因此单独建类型、单独登记——后台 UI 落地时形状漂移会被这里抓住。 + fn: 'admin_theme_payload', + ts: 'GameDistributionAdminTheme', + mustEmit: [ + 'themeId', + 'name', + 'summary', + 'badge', + 'sortOrder', + 'status', + 'memberCount', + 'createdAt', + 'updatedAt', + ], + }, + { + // 后台主题列表:只有一个顶层键 `themes`(**没有** `nextCursor`:这条路径不分页)。 + fn: 'admin_themes_payload', + ts: 'GameDistributionAdminThemeListResponse', + mustEmit: ['themes'], + }, + { + // 后台创建 / 更新的响应:`{ theme, replayed }`。`theme` 的值是函数调用而不是对象字面量, + // 所以不能用 `nested` 比对;条目形状由上面 `admin_theme_payload` 那条独立覆盖。 + fn: 'admin_theme_mutation_payload', + ts: 'GameDistributionAdminThemeMutationResponse', + mustEmit: ['theme', 'replayed'], + }, + { + // 后台成员写入的响应:`{ themeId, rootGameId, sortOrder, createdAt }`。 + fn: 'admin_theme_member_payload', + ts: 'GameDistributionAdminThemeMemberResponse', + mustEmit: ['themeId', 'rootGameId', 'sortOrder', 'createdAt'], + }, + { + // 后台移除成员的响应:`{ themeId, rootGameId }`(刻意不含「之前存不存在」,重复调用逐字节相同)。 + fn: 'admin_theme_member_removal_payload', + ts: 'GameDistributionAdminThemeMemberRemovalResponse', + mustEmit: ['themeId', 'rootGameId'], + }, ]; function camelCase(value) { diff --git a/server-rs/crates/api-server/src/modules/game_distribution.rs b/server-rs/crates/api-server/src/modules/game_distribution.rs index e05a267a1..36cc013ba 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -1953,7 +1953,7 @@ async fn admin_remove_theme_member( // 幂等:删掉的与「本来就没有」回同一个形状,客户端不可能据此推断成员是否曾经存在。 Ok(json_success_body( Some(&ctx), - json!({ "themeId": theme_id, "rootGameId": root_game_id }), + admin_theme_member_removal_payload(&theme_id, &root_game_id), )) } @@ -2056,6 +2056,16 @@ fn admin_theme_member_payload(member: &GameDistributionAdminThemeMemberRecord) - }) } +/// 后台移除成员的响应:`{ themeId, rootGameId }`。 +/// +/// 抽成命名构建器(而不是留在 handler 里的内联 `json!`)是为了让 DTO parity 能看到它: +/// 内联字面量不在 `RESPONSE_BUILDERS` 的证据范围内,形状漂移不会有门禁变红。 +/// +/// 刻意不含「之前存不存在」:删掉的与本来就没有的回同一个形状,重复调用逐字节相同。 +fn admin_theme_member_removal_payload(theme_id: &str, root_game_id: &str) -> Value { + json!({ "themeId": theme_id, "rootGameId": root_game_id }) +} + /// 读接口的「不可读即 404」:族谱与衍生列表的锚点必须公开可读,否则按「不存在」处理。 /// /// 抽成函数是为了让这条映射可被单测钉住(不可读 → 404),而不是散落在两个 handler 里。 diff --git a/server-rs/crates/module-game-distribution/src/theme.rs b/server-rs/crates/module-game-distribution/src/theme.rs index e1286080a..1e63e40e2 100644 --- a/server-rs/crates/module-game-distribution/src/theme.rs +++ b/server-rs/crates/module-game-distribution/src/theme.rs @@ -70,9 +70,11 @@ pub const GAME_DISTRIBUTION_THEME_BADGE_MAX_CHARS: usize = 16; /// 后台主题文本字段的校验:合法返回 `None`,否则返回可直接拼进 `THEME_BAD_REQUEST:` 的原因。 /// -/// 规则本身(长度上限、空名)只写在这里一处:api-server 与事务都调它,因此「HTTP 挡住的」 -/// 与「落库挡住的」永远是同一套判据。三个字段都先 `trim` 再判定:一串空格与空名同罪,调用方 -/// 忘掉 trim 也不会把「只有空白的主题名」写进库(长度也按 trim 后的字符数算,尾部空格不占额度)。 +/// 规则本身(长度上限、空名)只写在这里一处:**唯一的判据来源是写入事务** +/// `ensure_game_distribution_theme_write_fields`(api-server 不做 HTTP 层预校验,违规一律由事务 +/// 回 `THEME_BAD_REQUEST:` 再映射 400),因此「HTTP 挡住的」与「落库挡住的」永远是同一套判据。 +/// 三个字段都先 `trim` 再判定:一串空格与空名同罪,调用方忘掉 trim 也不会把「只有空白的主题名」 +/// 写进库(长度也按 trim 后的字符数算,尾部空格不占额度)。 pub fn game_distribution_theme_text_violation( name: &str, summary: &str, diff --git a/server-rs/crates/spacetime-module/src/game_distribution.rs b/server-rs/crates/spacetime-module/src/game_distribution.rs index ce7d44ef1..9a8fdfc0f 100644 --- a/server-rs/crates/spacetime-module/src/game_distribution.rs +++ b/server-rs/crates/spacetime-module/src/game_distribution.rs @@ -5674,9 +5674,10 @@ fn ensure_game_distribution_theme_receipt_digest( /// 后台主题写接口的文本与状态校验:空名 / 超长 / 未知状态一律失败关闭。 /// -/// 文本规则来自纯函数 `game_distribution_theme_text_violation`(api-server 侧同一份),状态白名单 -/// 来自 `game_distribution_theme_status_valid`——两处都不在这里重写,否则「HTTP 挡住的」与 -/// 「落库挡住的」会各长一套判据。 +/// 文本规则来自纯函数 `game_distribution_theme_text_violation`,状态白名单来自 +/// `game_distribution_theme_status_valid`——两处都不在这里重写(也**只有这里**调用它们: +/// api-server 不做 HTTP 层预校验,违规一律由本函数回 `THEME_BAD_REQUEST:` 再映射 400), +/// 否则「HTTP 挡住的」与「落库挡住的」会各长一套判据。 fn ensure_game_distribution_theme_write_fields( name: &str, summary: &str,