diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 59579b3ed..5f485efac 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -517,6 +517,27 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - 读取口径:`list_game_distribution_collections_and_return` 按 `user_id` 索引取行、按 `created_at` 倒序(同刻按 `game_id` 升序)稳定排序,逐条用**既有** `public_game_distribution_snapshot` 投影,只返回当前公开可读的作品(`module_game_distribution::game_distribution_collection_visible`)。下架 / 软删除的行在**投影时被跳过但不删除**,作品重新公开后自动回到列表。公开详情的 `collected` 由 `is_game_distribution_collected_and_return` 单独查询,不改动既有公开快照契约(`api-server` 在登录时把它并入公开详情负载,匿名不发该键)。 - 迁移:随迁移导入导出(用户态事实),作品下架不删除行。`migration_tables!` 已登记该表。 +### `game_distribution_theme` + +- Rust 结构体:`GameDistributionTheme`,源码:`server-rs/crates/spacetime-module/src/game_distribution.rs`;2026-10-06 新增。私有表,仅通过受信 API 服务身份的 procedure 与 BFF 提供投影。 +- 用途:平台 / 运营**命名**的「共创主题」——一棵(或一组)作品树的归组实体。主题不是作品的属性,而是运营叙事:作品在游戏 Tab 里仍作为独立作品展示,主题只是额外的归组维度。字段:`theme_id: String`(主键,形如 `theme-*`,由服务端签发、不接受客户端指定)、`name`(运营命名,不是根作品标题的派生)、`summary`、`badge`(角标短文本,本轮与 `name` 共同承担「封面」呈现职责,无封面图列)、`sort_order: i64`(成员排序权重)、`status: String`、`created_by_user_id: String`(创建者审计,不参与鉴权判定)、`created_at: Timestamp`、`updated_at: Timestamp`。 +- 状态口径:`status` 三态白名单 `draft` / `published` / `archived`(`module_game_distribution::GAME_DISTRIBUTION_THEME_STATUSES`);只有 `published` 进入公开投影(`game_distribution_theme_public_visible`),`draft` 与 `archived` 在公开列表 / 公开详情 / 作品详情 `themes` 里都不可见,后台仍可见全量。归档**不是删除**:行与成员行都保留,语义可逆。 +- 索引:`by_game_distribution_theme_created_at`。公开列表按创建时间倒序 + `theme_id` 升序兜底翻页(游标 `"{created_at_micros}:{theme_id}"`,默认 20 / 上限 50 / 超界截断 / 非法游标 400);`sort_order` 只用于主题内成员排序与后台列表展示,不参与公开列表排序。 +- 迁移:随迁移导出/导入(运营业务事实);归档只改变公开投影,不删除行。`migration_tables!` 已登记该表。 +- 写入路径:后台写接口(`create_game_distribution_theme_and_return` / `update_game_distribution_theme_and_return`)属后续里程碑;本轮只落表、生成绑定、迁移登记与契约。 + +### `game_distribution_theme_member` + +- Rust 结构体:`GameDistributionThemeMember`,源码:`server-rs/crates/spacetime-module/src/game_distribution.rs`;2026-10-06 新增。私有表。 +- 用途:「主题 ↔ 根作品」的运营归组事实。字段:`member_id: String`(主键)、`theme_id`、`root_game_id`、`sort_order: i64`、`created_at: Timestamp`。 +- 只允许**根作品**:根 = 该作品没有血缘行(第 0 代)。判定事实由调用方用血缘点查给出(`game_distribution_lineage().game_id().find(&game_id)` 命中即有行 ⇒ 非根),规则由纯函数 `game_distribution_theme_root_acceptable` 表达;非根写入被拒(`THEME_MEMBER_NOT_ROOT` 409)。理由:主题页呈现的作品树由 `/games/{id}/lineage` **按根**聚合,若允许子作品也入主题,同一棵树会在主题页里出现多次。 +- 防重:主键是确定性构造的 `{theme_id}:{root_game_id}`(`module_game_distribution::game_distribution_theme_member_id`),同一 (主题, 根) 在结构上不可能出现第二行——去重由主键约束本身承担,而不是「事务里先查后写」。因此**不需要**额外的 `(theme_id, root_game_id)` 唯一索引。跨主题的同一根会有**多行**(每主题一行),这是预期行为而不是重复数据。 +- 索引:`by_game_distribution_theme_member_theme_id`(主题页按主题取成员)、`by_game_distribution_theme_member_root_game_id`(作品详情按根反查所属公开主题,因此第 N 代作品也能看到自己的主题)。 +- 可见性:成员只出现在「作品公开未删且存在当前公开版本」时——判定**委托**收藏的同一条口径 `module_game_distribution::game_distribution_theme_member_visible`(内部即 `game_distribution_collection_visible`),不新写第二个同义判定。作品下架 / 软删除时该成员在**投影里跳过但不删除行**,作品重新公开后自动回到主题页;主题详情的 `roots` 与列表的 `memberCount` 用同一判定,因此两个数永远一致。 +- 排序:主题内成员按 `sort_order` 升序 + `member_id` 升序兜底(`sort_order` 允许重复,兜底键保证全序、翻页/渲染不重不漏);可见性过滤必须发生在排序切页之前。 +- 迁移:随迁移导出/导入(运营业务事实);归档主题与下架作品都不删除成员行。`migration_tables!` 已登记该表。 +- 写入路径:后台成员写接口(`upsert_game_distribution_theme_member_and_return` / `remove_game_distribution_theme_member_and_return`)属后续里程碑;本轮只落表、生成绑定、迁移登记与契约。 + ### `game_distribution_review` - Rust 结构体:`GameDistributionReview`,源码:`server-rs/crates/spacetime-module/src/game_distribution.rs`;私有表,仅通过受信 API 服务身份的 procedure 与 BFF 提供评价投影。 diff --git a/packages/shared/src/contracts/gameDistribution.ts b/packages/shared/src/contracts/gameDistribution.ts index d958205c4..ea24d11c2 100644 --- a/packages/shared/src/contracts/gameDistribution.ts +++ b/packages/shared/src/contracts/gameDistribution.ts @@ -296,6 +296,14 @@ export type GameDistributionGame = { * 收藏按钮态。公开目录不返回。值来自服务端真实投影,不是前端本地状态。 */ collected?: boolean; + /** + * 该作品所属的**公开**共创主题(先取作品的根、再按根反查,跨主题多归属因此是数组)。 + * + * **只有公开详情返回**(匿名与登录都返回;没有所属主题时是空数组,展示层据此不渲染空容器), + * 公开目录与作者侧不带这个键。第 N 代作品同样能看到主题——反查用的是根,不是作品自身。 + * 排序与公开主题列表共用同一份比较器(创建时间倒序 + themeId 升序兜底)。 + */ + themes?: GameDistributionThemeReference[]; playCount: number; createdAt: string; }; @@ -318,6 +326,101 @@ export type GameDistributionMyCollectionsResponse = { nextCursor?: string | null; }; +/** + * 共创主题的三态处置:`draft`(未发布)/ `published`(公开可见)/ `archived`(归档下架)。 + * + * 公开侧只出现 `published`;`archived` 不是删除(主题行与成员行都保留,只是不再公开)。 + * 后台写接口的 `status` 用这个联合类型,非法取值由服务端拒绝(400)。 + */ +export type GameDistributionThemeStatus = 'draft' | 'published' | 'archived'; + +/** + * 作品详情「所属主题」增量的单个条目:只够渲染一个跳转入口(主题页里才有完整信息)。 + */ +export type GameDistributionThemeReference = { + themeId: string; + name: string; + badge: string; +}; + +/** + * 公开主题列表的单条:主题名、简介、角标与**当前公开可见成员数**。 + * + * `memberCount` 与详情响应的 `roots` 长度是同一个数(同一份成员可见性判定),不含草稿 / + * 已下架成员;服务端不回报「全部成员行数」,客户端也不该用两个数互相推算。 + */ +export type GameDistributionThemeSummary = { + themeId: string; + name: string; + summary: string; + badge: string; + memberCount: number; +}; + +/** + * 公开主题列表接口(`GET /api/game-distribution/themes`)的响应体。 + * + * 只含 `published` 主题。分页沿用既有游标惯例:默认 20 条、上限 50 条、超界截断; + * `nextCursor` 是真实游标(`"{createdAtMicros}:{themeId}"`),最后一页为 `null`;游标格式非法时 + * 服务端返回 400(不是 200 的空页)。排序为创建时间倒序 + `themeId` 升序兜底(全序,不重不漏)。 + */ +export type GameDistributionThemeListResponse = { + themes: GameDistributionThemeSummary[]; + nextCursor?: string | null; +}; + +/** + * 公开主题详情接口(`GET /api/game-distribution/themes/{themeId}`)的响应体。 + * + * 顶层扁平形状与 `GET /games/{gameId}` 同形,不引入第二套包装。`roots` 逐条是公开目录同一份 + * 作品投影,排序为 `sortOrder` 升序 + `memberId` 升序兜底。主题不存在或未发布时接口返回 404 + * (不返回空壳);已发布但可见成员为空时返回 200 + 空 `roots`(展示层按空态而不是错误渲染)。 + */ +export type GameDistributionThemeDetail = { + themeId: string; + name: string; + summary: string; + badge: string; + memberCount: number; + roots: GameDistributionGame[]; +}; + +/** + * 后台创建主题(`POST /admin/api/game-distribution/themes`)的请求体。 + * + * `name` 必填且非空;其余字段省略时按「空文本 / 排序 0 / `draft`」落库(与表列默认一致)。 + */ +export type GameDistributionCreateThemeRequest = { + name: string; + summary?: string; + badge?: string; + sortOrder?: number; + status?: GameDistributionThemeStatus; +}; + +/** + * 后台更新主题(`PUT /admin/api/game-distribution/themes/{themeId}`)的请求体。 + * + * **整体覆盖**这几个字段(不是部分更新):字段都必填,避免「省略 = 保留旧值」与「省略 = 清空」 + * 两种解释在客户端之间漂移。 + */ +export type GameDistributionUpdateThemeRequest = { + name: string; + summary: string; + badge: string; + sortOrder: number; + status: GameDistributionThemeStatus; +}; + +/** + * 后台增 / 改成员(`PUT …/themes/{themeId}/members/{rootGameId}`)的请求体。 + * + * 成员身份完全由路径给出,body 只承载运营权重;重复调用由确定性主键保证幂等。 + */ +export type GameDistributionUpsertThemeMemberRequest = { + sortOrder?: number; +}; + 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 8cd188e57..5428d5589 100644 --- a/scripts/check-game-distribution-dto-parity.mjs +++ b/scripts/check-game-distribution-dto-parity.mjs @@ -87,6 +87,21 @@ const PAIRS = [ 'GameDistributionMyCollectionsResponse', 'GameDistributionMyCollectionsResponse', ], + // 共创主题:三态枚举、详情增量条目、公开列表 / 详情与后台写请求逐字段对齐。 + // 公开列表 / 详情的手拼响应构建器(`public_themes_payload` / `public_theme_detail_payload`、 + // 以及作品详情的 `themes` 增量)要等 api-server 路由落地后才登记进 `RESPONSE_BUILDERS`—— + // 那里要求函数真实存在,登记不存在的构建器会让本检查直接失败。 + ['GameDistributionThemeStatus', 'GameDistributionThemeStatus'], + ['GameDistributionThemeReference', 'GameDistributionThemeReference'], + ['GameDistributionThemeSummary', 'GameDistributionThemeSummary'], + ['GameDistributionThemeListResponse', 'GameDistributionThemeListResponse'], + ['GameDistributionThemeDetail', 'GameDistributionThemeDetail'], + ['GameDistributionCreateThemeRequest', 'GameDistributionCreateThemeRequest'], + ['GameDistributionUpdateThemeRequest', 'GameDistributionUpdateThemeRequest'], + [ + 'GameDistributionUpsertThemeMemberRequest', + 'GameDistributionUpsertThemeMemberRequest', + ], ['AdminGameReviewGamesQuery', 'AdminGameReviewGamesQuery'], ['AdminGameReviewGame', 'AdminGameReviewGame'], ['AdminGameReviewGamesResponse', 'AdminGameReviewGamesResponse'], diff --git a/server-rs/crates/module-game-distribution/src/lib.rs b/server-rs/crates/module-game-distribution/src/lib.rs index ac24b98c7..fde5216fb 100644 --- a/server-rs/crates/module-game-distribution/src/lib.rs +++ b/server-rs/crates/module-game-distribution/src/lib.rs @@ -9,6 +9,7 @@ mod package; mod project_bundle; mod release; mod reviews; +mod theme; pub use reviews::{ ReviewModerationValidationError, UserReviewValidationError, normalize_review_comment, @@ -57,3 +58,14 @@ pub use release::{ MAX_RELEASE_ASSET_BYTES, ReleaseAssetError, extract_release_asset, normalize_release_asset_path, release_asset_content_type, }; +pub use theme::{ + GAME_DISTRIBUTION_THEME_PAGE_LIMIT_DEFAULT, GAME_DISTRIBUTION_THEME_PAGE_LIMIT_MAX, + GAME_DISTRIBUTION_THEME_STATUS_ARCHIVED, GAME_DISTRIBUTION_THEME_STATUS_DRAFT, + GAME_DISTRIBUTION_THEME_STATUS_PUBLISHED, GAME_DISTRIBUTION_THEME_STATUSES, + GameDistributionThemeMemberPageItem, GameDistributionThemePageItem, + encode_game_distribution_theme_cursor, game_distribution_theme_member_id, + game_distribution_theme_member_visible, game_distribution_theme_page_limit, + game_distribution_theme_public_visible, game_distribution_theme_root_acceptable, + game_distribution_theme_status_valid, page_public_themes, page_theme_members, + parse_game_distribution_theme_cursor, sort_public_themes, sort_theme_members, +}; diff --git a/server-rs/crates/module-game-distribution/src/theme.rs b/server-rs/crates/module-game-distribution/src/theme.rs new file mode 100644 index 000000000..188f7fbe6 --- /dev/null +++ b/server-rs/crates/module-game-distribution/src/theme.rs @@ -0,0 +1,629 @@ +//! 共创主题的纯函数规则。 +//! +//! 表与事务在 `spacetime-module::game_distribution`;这里只放能被不起 SpacetimeDB 的单测钉住的 +//! 规则:确定性成员主键、「主题 / 成员是否进入公开投影」的可见性判定、「成员只允许根」的约束, +//! 以及公开主题列表与主题内成员的排序、游标编解码与切页。 + +use crate::collection::game_distribution_collection_visible; + +/// 主题状态:`draft`(未发布)。 +pub const GAME_DISTRIBUTION_THEME_STATUS_DRAFT: &str = "draft"; + +/// 主题状态:`published`(公开可见)。 +pub const GAME_DISTRIBUTION_THEME_STATUS_PUBLISHED: &str = "published"; + +/// 主题状态:`archived`(已归档下架,行保留)。 +pub const GAME_DISTRIBUTION_THEME_STATUS_ARCHIVED: &str = "archived"; + +/// 主题状态白名单:`draft` / `published` / `archived`。 +/// +/// 可见性判定、后台列表的 `status` 过滤白名单与 DTO 校验**共用这一份**(另加后台过滤里的 +/// `all`),因此新增一个状态只需要改这里一处;漏改会被「状态白名单」单测与 DTO parity 抓住。 +pub const GAME_DISTRIBUTION_THEME_STATUSES: [&str; 3] = [ + GAME_DISTRIBUTION_THEME_STATUS_DRAFT, + GAME_DISTRIBUTION_THEME_STATUS_PUBLISHED, + GAME_DISTRIBUTION_THEME_STATUS_ARCHIVED, +]; + +/// 状态是否是白名单里的合法值。 +/// +/// 非法值由 api-server 映射 400(`THEME_BAD_REQUEST`),不落到 axum 默认的 422 纯文本。 +pub fn game_distribution_theme_status_valid(status: &str) -> bool { + GAME_DISTRIBUTION_THEME_STATUSES.contains(&status) +} + +/// 主题是否应出现在公开投影(公开列表 / 公开详情 / 作品详情的 `themes`)里。 +/// +/// 等价于 `status == "published"`:`draft` 与 `archived` 一律不可见(后台仍可见全量)。与 +/// [`game_distribution_theme_status_valid`] 分开是刻意的——「值合法」与「对外可见」是两条不同 +/// 的规则,把两者混成一个函数会让「归档」这种合法但不可见的状态无从表达。 +pub fn game_distribution_theme_public_visible(status: &str) -> bool { + status == GAME_DISTRIBUTION_THEME_STATUS_PUBLISHED +} + +/// 主题成员主键:`{theme_id}:{root_game_id}`。 +/// +/// 与 `game_distribution_collection_id`(收藏)同一写法:平台签发的两个标识用冒号拼成确定性 ID。 +/// `theme_id` 由本领域生成(`theme-*`)、`root_game_id` 也是(`game-*`),两者都不含 `:`, +/// 因此组合是单射。 +/// +/// 用确定性主键而不是随机 ID,是为了让**去重由结构保证,而不是由流程保证**:同一 (主题, 根) +/// 只可能有一行,这一点由主键约束本身成立。也正因为主键已经是那条约束,这张表**不需要**再建 +/// `(theme_id, root_game_id)` 唯一索引——两条具名索引只服务两个读取方向(按主题取成员、 +/// 按根反查所属主题)。 +pub fn game_distribution_theme_member_id(theme_id: &str, root_game_id: &str) -> String { + format!("{theme_id}:{root_game_id}") +} + +/// 成员(根)是否应出现在公开投影里。 +/// +/// 只委托 [`game_distribution_collection_visible`](收藏)的同一条口径:未软删除、可见性为 +/// `published`、且存在当前公开版本(`has_public_version` 由调用方按 `public_game_distribution_version` +/// 命中给出)。**不新写第二个同义判定**:公开目录、收藏、主题成员三条路径共用一条规则,就不可能 +/// 出现「收藏列表看得见、主题页看不见」这类漂移。 +/// +/// 判定只在**投影时**发生,成员行本身不因此被删除:作品下架后该行只是不再出现在响应里, +/// 作品重新公开后同一行会再次出现。 +pub fn game_distribution_theme_member_visible( + is_deleted: bool, + is_published: bool, + has_public_version: bool, +) -> bool { + game_distribution_collection_visible(is_deleted, is_published, has_public_version) +} + +/// 目标作品能否作为主题成员:**只有根作品**(没有血缘行,即第 0 代)。 +/// +/// 事实(该 `game_id` 是否存在血缘行)由调用方用血缘点查给出——`game_distribution_lineage() +/// .game_id().find(&game_id)` 命中即有行 ⇒ 非根,一律拒绝(`409 THEME_MEMBER_NOT_ROOT`)。 +/// 纯函数只表达规则本身,不持有 `ReducerContext`。 +/// +/// 为什么不许任意代作品入主题:主题页要呈现的是**作品树**,而 `/games/{id}/lineage` 是**按根 +/// 聚合**的。若允许子作品也入主题,同一棵树会在主题页里出现多次(子作品自己一条、它的根又一条), +/// 去重与「高亮当前节点」都会打架。要挂就挂它所属的根。 +pub fn game_distribution_theme_root_acceptable(has_lineage_row: bool) -> bool { + !has_lineage_row +} + +/// 公开主题列表一页的默认条数。 +/// +/// 与 `/my-collections`(收录列表)取同一个数值:列表页是卡片网格,一屏放得下 20 张。 +pub const GAME_DISTRIBUTION_THEME_PAGE_LIMIT_DEFAULT: u32 = 20; + +/// 公开主题列表单次响应回传的条数上限。 +/// +/// 与 `/my-collections` 同一数值与同一处理方式:超界一律**截断**而不是报错,客户端拿到的是一个 +/// 完整页,而不是一个需要重试的错误。同时它也是主题详情成员清单的响应体积约束。 +pub const GAME_DISTRIBUTION_THEME_PAGE_LIMIT_MAX: u32 = 50; + +/// 请求里的 `limit` → 实际页大小:`0` 取默认,超界截断到上限。 +/// +/// 抽成独立函数是为了让 api-server 记日志的「生效条数」与模块真正使用的页大小**同源**: +/// 两处各自写一遍 `if == 0 ... min(...)` 迟早会漂移。 +pub fn game_distribution_theme_page_limit(limit: u32) -> usize { + let resolved = if limit == 0 { + GAME_DISTRIBUTION_THEME_PAGE_LIMIT_DEFAULT + } else { + limit.min(GAME_DISTRIBUTION_THEME_PAGE_LIMIT_MAX) + }; + resolved as usize +} + +/// 主题公开列表里的一条:游标 / 排序所需的两个键 + 调用方自己的负载。 +/// +/// 泛型 `payload` 让「排序键」与「这一页要回传的东西」绑在同一个值上,纯函数切页时整条值一起 +/// 移动,因此不可能出现「按 A 排序、按 B 切页」的错位;模块侧把负载填成主题行,测试里填成 +/// 任意轻量值。 +#[derive(Clone, Debug, PartialEq)] +pub struct GameDistributionThemePageItem { + /// 主题主键 `theme-*`;主题表内唯一,因此可作全序兜底键。 + pub theme_id: String, + /// 主题创建时间(Unix 微秒),主排序键。 + pub created_at_micros: i64, + pub payload: T, +} + +/// 主题列表游标编码:`{created_at_micros}:{theme_id}`。 +/// +/// 与后台列表、收藏列表同一套 `"{micros}:{id}"` 惯例。`theme_id` 由本领域签发(`theme-*`)、 +/// **不含冒号**,因此这里比收藏列表更简单;解析仍只切**第一个**冒号,格式对将来更长的 ID +/// 也不会退化。 +pub fn encode_game_distribution_theme_cursor(created_at_micros: i64, theme_id: &str) -> String { + format!("{created_at_micros}:{theme_id}") +} + +/// 主题列表游标解析;格式非法时返回 `Err`(api-server 会把它透传成 400)。 +/// +/// 严格的错误文案是刻意的:它不得含「不存在」「已被删除」「状态」「不匹配」「幂等」「已存在」 +/// 「FORK_」等子串,否则会被 api-server 的既有 `map_spacetime_error` 分支抢先映射成 404 / 409; +/// 「格式无效」只会落到兜底的 400,这正是「客户端传了坏游标」的语义。 +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::() + .map_err(|_| "主题列表游标格式无效".to_string())?; + if theme_id.is_empty() { + return Err("主题列表游标格式无效".to_string()); + } + Ok((micros, theme_id.to_string())) +} + +/// 公开主题列表的稳定排序:`created_at` **倒序** + `theme_id` **升序**兜底。 +/// +/// 为什么这个比较器是**全序**:`created_at` 相同的两条必须还能分出先后,否则同一页边界上的项 +/// 会在翻页时重复或漏掉。`theme_id` 是主键、唯一,因此 `(created_at_micros, theme_id)` 唯一 +/// 决定顺序 —— 相同 `created_at` 不会被吞掉。 +/// +/// 作品详情的 `themes` 增量用**同一份**比较器,避免「列表一种顺序、详情另一种顺序」的第二套语义。 +pub fn sort_public_themes( + mut items: Vec>, +) -> Vec> { + items.sort_by(|left, right| { + right + .created_at_micros + .cmp(&left.created_at_micros) + .then_with(|| left.theme_id.cmp(&right.theme_id)) + }); + items +} + +/// 公开主题列表分页:按上面的比较器排序、跳过游标之前的位置、取 `limit` 条,并返回下一页游标 +/// (没有下一页时为 `None`)。 +/// +/// 调用方必须**先**按可见性过滤、**再**把结果交给本函数:游标位置定义在已过滤的序列上。 +/// 反过来先把未过滤序列切页、再逐页过滤,会让被滤掉的行凭空占掉名额,并使下一页的游标指回 +/// 过滤前的序列——每翻一页都漏掉自己的若干条(与 `/my-collections` 同一条纪律)。 +pub fn page_public_themes( + items: Vec>, + cursor: Option<(i64, String)>, + limit: u32, +) -> (Vec, Option) { + let limit = game_distribution_theme_page_limit(limit); + let mut items = sort_public_themes(items); + if let Some((cursor_micros, cursor_id)) = cursor.as_ref() { + // 严格「在游标之后」:时间更早,或时间相同且 theme_id 更大(升序方向)。 + items.retain(|item| { + item.created_at_micros < *cursor_micros + || (item.created_at_micros == *cursor_micros + && item.theme_id.as_str() > cursor_id.as_str()) + }); + } + // 多取一条判断是否还有下一页;游标只指向这一页真正回传的最后一条。 + let has_more = items.len() > limit; + items.truncate(limit); + let next_cursor = if has_more { + items.last().map(|item| { + encode_game_distribution_theme_cursor(item.created_at_micros, item.theme_id.as_str()) + }) + } else { + None + }; + ( + items.into_iter().map(|item| item.payload).collect(), + next_cursor, + ) +} + +/// 主题内成员的一条:排序所需两个键 + 调用方自己的负载(与主题列表同一写法)。 +#[derive(Clone, Debug, PartialEq)] +pub struct GameDistributionThemeMemberPageItem { + /// 成员主键 `{theme_id}:{root_game_id}`;同一主题内唯一,因此可作全序兜底键。 + pub member_id: String, + /// 运营权重,主排序键;**允许重复**(重复由 `member_id` 兜底成全序)。 + pub sort_order: i64, + pub payload: T, +} + +/// 主题内成员的稳定排序:`sort_order` **升序** + `member_id` **升序**兜底。 +/// +/// 为什么需要兜底键:运营完全可能给多个成员填同一个 `sort_order`(「靠前的这几个先放一起」)。 +/// 若比较器在 `sort_order` 相同时不稳定,同一页边界上的成员会在翻页 / 渲染时重复或漏掉。 +/// `member_id` 是主键、在同一主题内唯一,因此 `(sort_order, member_id)` 唯一决定顺序。 +pub fn sort_theme_members( + mut items: Vec>, +) -> Vec> { + items.sort_by(|left, right| { + left.sort_order + .cmp(&right.sort_order) + .then_with(|| left.member_id.cmp(&right.member_id)) + }); + items +} + +/// 主题详情成员清单:排序后取前 `limit` 条负载。 +/// +/// `limit` 由调用方显式给出(主题详情用 [`GAME_DISTRIBUTION_THEME_PAGE_LIMIT_MAX`] 约束单次响应 +/// 体积),`0` 表示该次不回传成员。可见性过滤同样必须发生在**调用本函数之前**(游标/截断位置 +/// 定义在已过滤序列上,理由与主题列表一致)。 +pub fn page_theme_members( + items: Vec>, + limit: usize, +) -> Vec { + let mut items = sort_theme_members(items); + items.truncate(limit); + items.into_iter().map(|item| item.payload).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn member_id_is_deterministic_and_pair_specific() { + assert_eq!( + game_distribution_theme_member_id("theme_a", "game_a"), + "theme_a:game_a" + ); + // 同一对组合永远得到同一个键(重复 upsert 只留一行、成员幂等删除都依赖这一点)。 + assert_eq!( + game_distribution_theme_member_id("theme_a", "game_a"), + game_distribution_theme_member_id("theme_a", "game_a") + ); + // 主键派生必须对「主题」「根」两个维度都敏感:漏掉任一维度,不同主题 / 不同根的成员就会 + // 共用同一行而互相覆盖(这是结构防重的前提)。 + assert_ne!( + game_distribution_theme_member_id("theme_a", "game_a"), + game_distribution_theme_member_id("theme_b", "game_a") + ); + assert_ne!( + game_distribution_theme_member_id("theme_a", "game_a"), + game_distribution_theme_member_id("theme_a", "game_b") + ); + } + + /// 状态白名单与公开可见性是**两条**不同的规则:`archived` 合法但不可见。 + #[test] + fn status_whitelist_and_public_visibility_are_separate_rules() { + for status in GAME_DISTRIBUTION_THEME_STATUSES { + assert!(game_distribution_theme_status_valid(status), "{status}"); + } + for status in ["", "PUBLISHED", "deleted", "hidden", " all"] { + assert!(!game_distribution_theme_status_valid(status), "{status}"); + } + + // 只有 published 公开可见;draft 与 archived 都不可见,但它们的值仍然合法。 + assert!(game_distribution_theme_public_visible( + GAME_DISTRIBUTION_THEME_STATUS_PUBLISHED + )); + assert!(!game_distribution_theme_public_visible( + GAME_DISTRIBUTION_THEME_STATUS_DRAFT + )); + assert!(!game_distribution_theme_public_visible( + GAME_DISTRIBUTION_THEME_STATUS_ARCHIVED + )); + assert!(game_distribution_theme_status_valid( + GAME_DISTRIBUTION_THEME_STATUS_ARCHIVED + )); + // 非法值当然也不可见(不是「非 published 一律可见」的口子)。 + assert!(!game_distribution_theme_public_visible("published ")); + } + + /// 成员可见性四组合 + 「未删已公开但无当前公开版本」必须 false;并与收藏口径**逐格相等**。 + /// + /// 最后一组断言是这条「委托而不是复制」的守门人:将来若只改收藏判定而忘了成员,或反之, + /// 这个测试会红。 + #[test] + fn member_visibility_matches_the_collection_ruling_cell_by_cell() { + assert!(game_distribution_theme_member_visible(false, true, true)); + assert!(!game_distribution_theme_member_visible(false, false, true)); + assert!(!game_distribution_theme_member_visible(true, true, true)); + assert!(!game_distribution_theme_member_visible(false, true, false)); + + for is_deleted in [false, true] { + for is_published in [false, true] { + for has_public_version in [false, true] { + assert_eq!( + game_distribution_theme_member_visible( + is_deleted, + is_published, + has_public_version + ), + game_distribution_collection_visible( + is_deleted, + is_published, + has_public_version + ), + "成员与收藏必须共用同一条公开可读口径(deleted={is_deleted}, published={is_published}, version={has_public_version})" + ); + } + } + } + } + + /// 「成员下架 → 投影跳过 → 重新公开 → 自动回来」:行不变,只有判定翻转。 + #[test] + fn member_row_survives_unpublish_and_returns_after_republish() { + assert!(game_distribution_theme_member_visible(false, true, true)); + assert!(!game_distribution_theme_member_visible(false, false, true)); + assert!(game_distribution_theme_member_visible(false, true, true)); + } + + /// 成员只允许根:`has_lineage_row == true`(非根)⇒ 拒绝;无血缘行 ⇒ 接受。 + #[test] + fn only_root_games_are_acceptable_members() { + assert!(game_distribution_theme_root_acceptable(false)); + assert!(!game_distribution_theme_root_acceptable(true)); + } + + /// `limit` 语义:`0` 取默认 20(不是「返回 0 条」),超界截断到 50(不是报错)。 + #[test] + fn page_limit_defaults_zero_and_truncates_overflow() { + assert_eq!(game_distribution_theme_page_limit(0), 20); + assert_eq!(game_distribution_theme_page_limit(1), 1); + assert_eq!(game_distribution_theme_page_limit(50), 50); + assert_eq!( + game_distribution_theme_page_limit(51), + 50, + "超界截断而不是报错" + ); + assert_eq!(game_distribution_theme_page_limit(u32::MAX), 50); + } + + /// 游标编解码:往返双射、解析只切第一个冒号、负数微秒可逆,各种非法输入一律 `Err`。 + #[test] + fn cursor_round_trips_and_rejects_malformed_values() { + let cursor = encode_game_distribution_theme_cursor(1_700_000_000_000_000, "theme_a"); + assert_eq!(cursor, "1700000000000000:theme_a"); + assert_eq!( + parse_game_distribution_theme_cursor(&cursor).unwrap(), + (1_700_000_000_000_000, "theme_a".to_string()) + ); + // 负数微秒(仅编码假设,仍必须可逆)。 + assert_eq!( + parse_game_distribution_theme_cursor("-5:theme_a").unwrap(), + (-5, "theme_a".to_string()) + ); + // 解析只切第一个冒号:micros 之后的整段(含更多冒号)都是 theme_id。 + assert_eq!( + parse_game_distribution_theme_cursor("9:theme:a").unwrap(), + (9, "theme:a".to_string()) + ); + + for malformed in [ + "", + "not-a-cursor", + "100", + "abc:theme_a", + "100:", + " :theme_a", + ] { + assert!( + parse_game_distribution_theme_cursor(malformed).is_err(), + "非法游标必须报错:{malformed:?}" + ); + } + } + + /// 非法游标文案必须落 400:不得含会被 api-server 抢先映射成 404 / 409 的子串。 + #[test] + fn invalid_cursor_message_stays_in_the_bad_request_bucket() { + let message = + parse_game_distribution_theme_cursor("不是游标").expect_err("非法游标必须报错"); + assert!(message.contains("格式无效"), "{message}"); + for forbidden in [ + "不存在", + "已被删除", + "状态", + "不匹配", + "幂等", + "已存在", + "FORK_", + ] { + assert!( + !message.contains(forbidden), + "游标错误文案不得含「{forbidden}」:{message}" + ); + } + } + + fn item(theme_id: &str, created_at_micros: i64) -> GameDistributionThemePageItem { + GameDistributionThemePageItem { + theme_id: theme_id.to_string(), + created_at_micros, + payload: theme_id.to_string(), + } + } + + /// 不足一页:全部回传,且**没有**下一页游标(不能给一个指向空页的游标,否则客户端会多翻一次死页)。 + #[test] + fn page_returns_everything_without_cursor_when_under_limit() { + let (page, next) = + page_public_themes(vec![item("theme_a", 300), item("theme_b", 200)], None, 20); + assert_eq!(page, vec!["theme_a", "theme_b"]); + assert_eq!(next, None); + } + + /// 恰好一页:`has_more` 依赖「多取一条」,因此 `len == limit` 时不能再给游标。 + #[test] + fn page_at_exact_limit_has_no_cursor() { + let (page, next) = + page_public_themes(vec![item("theme_a", 300), item("theme_b", 200)], None, 2); + assert_eq!(page.len(), 2); + assert_eq!(next, None); + } + + /// 连续翻页直到消耗完:每页**不重复**上一页的项,也**不跳过**任何项;最后一页游标为 `None`。 + #[test] + fn paging_walks_the_whole_sequence_without_gaps_or_duplicates() { + let entries = vec![ + item("theme_a", 500), + item("theme_b", 400), + item("theme_c", 300), + item("theme_d", 200), + item("theme_e", 100), + ]; + let mut seen: Vec = Vec::new(); + let mut cursor = None; + let mut pages = 0; + loop { + let (page, next) = page_public_themes(entries.clone(), cursor, 2); + assert!(!page.is_empty(), "非末页不得为空"); + if next.is_some() { + assert_eq!(page.len(), 2, "还有下一页时必须填满 limit 条"); + } + seen.extend(page); + pages += 1; + match next { + Some(value) => cursor = Some(parse_game_distribution_theme_cursor(&value).unwrap()), + None => break, + } + } + assert_eq!(pages, 3); + assert_eq!( + seen, + vec!["theme_a", "theme_b", "theme_c", "theme_d", "theme_e"], + "翻页顺序必须严格是「创建时间倒序」且不重不漏" + ); + } + + /// 同一 `created_at` 多条:`theme_id` 升序兜底保证全序,翻页边界不重不漏。 + /// + /// 这是唯一能钉住「排序是全序」的场景:若比较器在 `created_at` 相同时不稳定,三条同刻主题里 + /// 的某一条就会被下一页重复返回或被整段跳过。 + #[test] + fn page_tiebreaks_equal_created_at_by_theme_id_ascending() { + let entries = vec![ + item("theme_c", 100), + item("theme_a", 100), + item("theme_d", 99), + item("theme_b", 100), + ]; + let (first, next) = page_public_themes(entries.clone(), None, 2); + assert_eq!(first, vec!["theme_a", "theme_b"]); + let cursor = next.expect("还有同刻与更早的项"); + assert_eq!(cursor, "100:theme_b"); + let (second, last) = page_public_themes( + entries, + Some(parse_game_distribution_theme_cursor(&cursor).unwrap()), + 2, + ); + assert_eq!(second, vec!["theme_c", "theme_d"]); + assert_eq!(last, None, "第二页就是最后一页"); + } + + /// **先过滤、再排序切页**:被滤掉的行不得凭空占掉名额,也不得出现在任何一页里。 + /// + /// 这里用「可见性过滤在调用方、发生在切页之前」的结构模拟模块侧的真实顺序:把不可见的行 + /// 混在同一批输入里,先 `retain`,再交给 `page_public_themes`;若顺序反过来(先切页再过滤), + /// 第一页就会因为不可见行占位而只回传 1 条,翻页也会漏项。 + #[test] + fn visibility_filter_must_happen_before_paging() { + // theme_b / theme_d 是「不可见」的主题(draft / archived),它们不该占页名额。 + let raw = vec![ + item("theme_a", 500), + item("theme_b", 400), + item("theme_c", 300), + item("theme_d", 200), + item("theme_e", 100), + ]; + let visible = raw + .into_iter() + .filter(|entry| entry.theme_id != "theme_b" && entry.theme_id != "theme_d") + .collect::>(); + + let mut seen: Vec = Vec::new(); + let mut cursor = None; + loop { + let (page, next) = page_public_themes(visible.clone(), cursor, 1); + if next.is_some() { + assert_eq!(page.len(), 1, "过滤后仍有余量时必须填满 limit 条"); + } + seen.extend(page); + match next { + Some(value) => cursor = Some(parse_game_distribution_theme_cursor(&value).unwrap()), + None => break, + } + } + assert_eq!(seen, vec!["theme_a", "theme_c", "theme_e"]); + assert!( + !seen.iter().any(|id| id == "theme_b" || id == "theme_d"), + "不可见主题不得出现在任何一页" + ); + } + + fn member(member_id: &str, sort_order: i64) -> GameDistributionThemeMemberPageItem { + GameDistributionThemeMemberPageItem { + member_id: member_id.to_string(), + sort_order, + payload: member_id.to_string(), + } + } + + /// 成员排序:`sort_order` 升序;同 `sort_order` 按 `member_id` 升序兜底(输入顺序无关)。 + #[test] + fn member_sort_orders_by_rank_then_member_id() { + let sorted = sort_theme_members(vec![ + member("theme_a:game_c", 10), + member("theme_a:game_a", 5), + member("theme_a:game_d", 10), + member("theme_a:game_b", 5), + ]) + .into_iter() + .map(|entry| entry.payload) + .collect::>(); + assert_eq!( + sorted, + vec![ + "theme_a:game_a", + "theme_a:game_b", + "theme_a:game_c", + "theme_a:game_d", + ] + ); + } + + /// 成员排序是全序:同一 `sort_order` 下无论输入怎么打乱,输出都相同(稳定且不重不漏)。 + #[test] + fn member_sort_is_a_total_order_for_duplicate_ranks() { + let expected = vec![ + "theme_a:game_a", + "theme_a:game_b", + "theme_a:game_c", + "theme_a:game_d", + ]; + for rotation in 0..4 { + let mut items = vec![ + member("theme_a:game_b", 7), + member("theme_a:game_d", 7), + member("theme_a:game_a", 7), + member("theme_a:game_c", 7), + ]; + items.rotate_left(rotation); + let sorted = sort_theme_members(items) + .into_iter() + .map(|entry| entry.payload) + .collect::>(); + assert_eq!(sorted, expected, "rotation={rotation}"); + } + } + + /// 成员清单截断:保留排序后的前缀,不改变顺序;`limit` 大于总数时原样回传。 + #[test] + fn member_page_truncates_after_sorting() { + let items = vec![ + member("theme_a:game_c", 30), + member("theme_a:game_a", 10), + member("theme_a:game_b", 20), + ]; + assert_eq!( + page_theme_members(items.clone(), 2), + vec!["theme_a:game_a", "theme_a:game_b"], + "截断必须发生在排序之后,不能按输入顺序取前两条" + ); + assert_eq!( + page_theme_members(items.clone(), 3), + vec!["theme_a:game_a", "theme_a:game_b", "theme_a:game_c"] + ); + assert_eq!( + page_theme_members(items.clone(), usize::MAX).len(), + 3, + "上限大于总数时原样回传" + ); + assert!( + page_theme_members(items, 0).is_empty(), + "limit=0 不回传成员" + ); + } +} diff --git a/server-rs/crates/shared-contracts/src/game_distribution.rs b/server-rs/crates/shared-contracts/src/game_distribution.rs index 9a606b80f..35dfd1e99 100644 --- a/server-rs/crates/shared-contracts/src/game_distribution.rs +++ b/server-rs/crates/shared-contracts/src/game_distribution.rs @@ -223,6 +223,13 @@ pub struct GameDistributionGameSummary { /// 的真实投影,不是客户端本地状态。 #[serde(default, skip_serializing_if = "Option::is_none")] pub collected: Option, + /// 该作品所属的**公开**共创主题:先取作品的根、再按根反查成员行(跨主题多归属,因此是数组)。 + /// + /// **只有公开详情下发**(匿名与登录都发;没有所属主题时发空数组,展示层不渲染空容器); + /// 公开列表与作者侧不发该键。第 N 代作品同样能看到主题——反查用的是**根**,不是作品自身。 + /// 排序与公开主题列表共用同一份比较器(创建时间倒序 + `theme_id` 升序兜底)。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub themes: Option>, } /// 作品级共创(Fork)授权档位:只允许单向提升开放度。 @@ -393,6 +400,121 @@ pub struct GameDistributionMyCollectionsResponse { pub next_cursor: Option, } +/// 共创主题的三态处置:`draft`(未发布)/ `published`(公开可见)/ `archived`(归档下架)。 +/// +/// 后台写接口的入参用这个枚举而不是裸字符串:非法取值在反序列化阶段即被拒绝(api-server 映射 +/// 400),不会带着一个「未定义状态」进入事务。模块侧的数据列仍是字符串,配套白名单是 +/// `module_game_distribution::GAME_DISTRIBUTION_THEME_STATUSES`。 +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum GameDistributionThemeStatus { + /// 已创建但未对外发布:公开侧任何投影都看不到。 + #[default] + Draft, + /// 已发布:公开列表、公开详情与作品详情的 `themes` 都会出现。 + Published, + /// 已归档下架:不再公开,但主题行与成员行都保留。 + Archived, +} + +/// 作品详情「所属主题」增量的单个条目:只够渲染一个跳转入口。 +/// +/// 不含简介与成员数——详情页只需要「这条作品属于哪些主题」和一个能点进去的名字与角标, +/// 完整信息在主题页(`GameDistributionThemeDetail`)取。 +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GameDistributionThemeReference { + pub theme_id: String, + pub name: String, + pub badge: String, +} + +/// 公开主题列表的单条:主题名、简介、角标与**当前公开可见成员数**。 +/// +/// `member_count` 与详情响应的 `roots` 长度是同一个数(同一份成员可见性判定):若回报「全部成员 +/// 行数」,运营就能通过计数变化探测存在草稿 / 被下架成员,且它会与 `roots` 长度对不上。 +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GameDistributionThemeSummary { + pub theme_id: String, + pub name: String, + pub summary: String, + pub badge: String, + pub member_count: u64, +} + +/// 公开主题列表接口(`GET /api/game-distribution/themes`)的响应体。 +/// +/// 只含 `status == published` 的主题。分页沿用既有游标惯例:默认 20 条、上限 50 条、超界截断; +/// `next_cursor` 是真实游标(`"{created_at_micros}:{theme_id}"`),最后一页为 `null`;游标格式 +/// 非法时服务端返回 400。排序为创建时间倒序 + `theme_id` 升序兜底(全序,翻页不重不漏)。 +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GameDistributionThemeListResponse { + pub themes: Vec, + #[serde(default)] + pub next_cursor: Option, +} + +/// 公开主题详情接口(`GET /api/game-distribution/themes/{theme_id}`)的响应体。 +/// +/// 顶层扁平形状与 `GET /games/{gameId}` 同形,不引入第二套包装。`roots` 逐条是**公开目录同一份** +/// 作品投影(`GameDistributionGameSummary`),排序为 `sort_order` 升序 + `member_id` 升序兜底。 +/// 主题不存在或未发布时接口返回 404(不返回空壳);已发布但可见成员为空时返回 200 + 空 `roots`。 +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GameDistributionThemeDetail { + pub theme_id: String, + pub name: String, + pub summary: String, + pub badge: String, + pub member_count: u64, + pub roots: Vec, +} + +/// 后台创建主题(`POST /admin/api/game-distribution/themes`)的请求体。 +/// +/// `name` 必填且非空(空名由 api-server 映射 400);其余字段省略时按「空文本 / 排序 0 / +/// `draft`」落库——与表列默认一致,因此「省略」与「显式传默认」是同一个请求,幂等摘要也相同。 +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GameDistributionCreateThemeRequest { + pub name: String, + #[serde(default)] + pub summary: String, + #[serde(default)] + pub badge: String, + #[serde(default)] + pub sort_order: i64, + #[serde(default)] + pub status: GameDistributionThemeStatus, +} + +/// 后台更新主题(`PUT /admin/api/game-distribution/themes/{theme_id}`)的请求体。 +/// +/// **整体覆盖**这几个字段(不是部分更新):字段都必填,避免「省略 = 保留旧值」与「省略 = 清空」 +/// 两种解释在客户端之间漂移。`updated_at` 每次生效即刷新。 +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GameDistributionUpdateThemeRequest { + pub name: String, + pub summary: String, + pub badge: String, + pub sort_order: i64, + pub status: GameDistributionThemeStatus, +} + +/// 后台增 / 改成员(`PUT /admin/api/game-distribution/themes/{theme_id}/members/{root_game_id}`)的请求体。 +/// +/// 成员身份完全由路径给出(`theme_id` + `root_game_id`),body 只承载运营权重;重复调用由确定性 +/// 主键保证幂等(同主题同根只有一行,`created_at` 首次写入后不再变)。 +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GameDistributionUpsertThemeMemberRequest { + #[serde(default)] + pub sort_order: i64, +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct GameDistributionCreateGameRequest { diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 5381fb118..f41b667ff 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -487,6 +487,10 @@ pub mod game_distribution_review_type; pub mod game_distribution_set_fork_authorization_input_type; pub mod game_distribution_submit_review_input_type; pub mod game_distribution_suspend_input_type; +pub mod game_distribution_theme_member_table; +pub mod game_distribution_theme_member_type; +pub mod game_distribution_theme_table; +pub mod game_distribution_theme_type; pub mod game_distribution_uncollect_game_input_type; pub mod game_distribution_unpublish_input_type; pub mod game_distribution_update_metadata_input_type; @@ -1397,6 +1401,10 @@ pub use game_distribution_review_type::GameDistributionReview; pub use game_distribution_set_fork_authorization_input_type::GameDistributionSetForkAuthorizationInput; pub use game_distribution_submit_review_input_type::GameDistributionSubmitReviewInput; pub use game_distribution_suspend_input_type::GameDistributionSuspendInput; +pub use game_distribution_theme_member_table::*; +pub use game_distribution_theme_member_type::GameDistributionThemeMember; +pub use game_distribution_theme_table::*; +pub use game_distribution_theme_type::GameDistributionTheme; pub use game_distribution_uncollect_game_input_type::GameDistributionUncollectGameInput; pub use game_distribution_unpublish_input_type::GameDistributionUnpublishInput; pub use game_distribution_update_metadata_input_type::GameDistributionUpdateMetadataInput; @@ -1985,6 +1993,8 @@ pub struct DbUpdate { game_distribution_review: __sdk::TableUpdate, game_distribution_review_moderation_log: __sdk::TableUpdate, + game_distribution_theme: __sdk::TableUpdate, + game_distribution_theme_member: __sdk::TableUpdate, game_distribution_version: __sdk::TableUpdate, llm_router_account: __sdk::TableUpdate, llm_router_billing_checkpoint: __sdk::TableUpdate, @@ -2221,6 +2231,14 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { )?, ) } + "game_distribution_theme" => db_update.game_distribution_theme.append( + game_distribution_theme_table::parse_table_update(table_update)?, + ), + "game_distribution_theme_member" => { + db_update.game_distribution_theme_member.append( + game_distribution_theme_member_table::parse_table_update(table_update)?, + ) + } "game_distribution_version" => db_update.game_distribution_version.append( game_distribution_version_table::parse_table_update(table_update)?, ), @@ -2653,6 +2671,18 @@ impl __sdk::DbUpdate for DbUpdate { &self.game_distribution_review_moderation_log, ) .with_updates_by_pk(|row| &row.operation_id); + diff.game_distribution_theme = cache + .apply_diff_to_table::( + "game_distribution_theme", + &self.game_distribution_theme, + ) + .with_updates_by_pk(|row| &row.theme_id); + diff.game_distribution_theme_member = cache + .apply_diff_to_table::( + "game_distribution_theme_member", + &self.game_distribution_theme_member, + ) + .with_updates_by_pk(|row| &row.member_id); diff.game_distribution_version = cache .apply_diff_to_table::( "game_distribution_version", @@ -3044,6 +3074,12 @@ impl __sdk::DbUpdate for DbUpdate { "game_distribution_review_moderation_log" => db_update .game_distribution_review_moderation_log .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "game_distribution_theme" => db_update + .game_distribution_theme + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "game_distribution_theme_member" => db_update + .game_distribution_theme_member + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "game_distribution_version" => db_update .game_distribution_version .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -3339,6 +3375,12 @@ impl __sdk::DbUpdate for DbUpdate { "game_distribution_review_moderation_log" => db_update .game_distribution_review_moderation_log .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "game_distribution_theme" => db_update + .game_distribution_theme + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "game_distribution_theme_member" => db_update + .game_distribution_theme_member + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "game_distribution_version" => db_update .game_distribution_version .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -3542,6 +3584,8 @@ pub struct AppliedDiff<'r> { game_distribution_review: __sdk::TableAppliedDiff<'r, GameDistributionReview>, game_distribution_review_moderation_log: __sdk::TableAppliedDiff<'r, GameDistributionReviewModerationLog>, + game_distribution_theme: __sdk::TableAppliedDiff<'r, GameDistributionTheme>, + game_distribution_theme_member: __sdk::TableAppliedDiff<'r, GameDistributionThemeMember>, game_distribution_version: __sdk::TableAppliedDiff<'r, GameDistributionVersion>, llm_router_account: __sdk::TableAppliedDiff<'r, LlmRouterAccount>, llm_router_billing_checkpoint: __sdk::TableAppliedDiff<'r, LlmRouterBillingCheckpoint>, @@ -3842,6 +3886,16 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.game_distribution_review_moderation_log, event, ); + callbacks.invoke_table_row_callbacks::( + "game_distribution_theme", + &self.game_distribution_theme, + event, + ); + callbacks.invoke_table_row_callbacks::( + "game_distribution_theme_member", + &self.game_distribution_theme_member, + event, + ); callbacks.invoke_table_row_callbacks::( "game_distribution_version", &self.game_distribution_version, @@ -4768,6 +4822,8 @@ impl __sdk::SpacetimeModule for RemoteModule { game_distribution_lineage_table::register_table(client_cache); game_distribution_review_table::register_table(client_cache); game_distribution_review_moderation_log_table::register_table(client_cache); + game_distribution_theme_table::register_table(client_cache); + game_distribution_theme_member_table::register_table(client_cache); game_distribution_version_table::register_table(client_cache); llm_router_account_table::register_table(client_cache); llm_router_billing_checkpoint_table::register_table(client_cache); @@ -4864,6 +4920,8 @@ impl __sdk::SpacetimeModule for RemoteModule { "game_distribution_lineage", "game_distribution_review", "game_distribution_review_moderation_log", + "game_distribution_theme", + "game_distribution_theme_member", "game_distribution_version", "llm_router_account", "llm_router_billing_checkpoint", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_member_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_member_table.rs new file mode 100644 index 000000000..d0f8809b9 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_member_table.rs @@ -0,0 +1,238 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::game_distribution_theme_member_type::GameDistributionThemeMember; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `game_distribution_theme_member`. +/// +/// Obtain a handle from the [`GameDistributionThemeMemberTableAccess::game_distribution_theme_member`] method on [`super::RemoteTables`], +/// like `ctx.db.game_distribution_theme_member()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.game_distribution_theme_member().on_insert(...)`. +pub struct GameDistributionThemeMemberTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +/// Lifetime-aware accessor marker for the table `game_distribution_theme_member`. +pub struct GameDistributionThemeMemberTableAccessor; + +impl __sdk::TableAccessor for GameDistributionThemeMemberTableAccessor { + type Row = GameDistributionThemeMember; + type Handle<'db> = GameDistributionThemeMemberTableHandle<'db>; + + fn get<'db>(db: &'db super::RemoteTables) -> Self::Handle<'db> { + db.game_distribution_theme_member() + } +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `game_distribution_theme_member`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait GameDistributionThemeMemberTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`GameDistributionThemeMemberTableHandle`], which mediates access to the table `game_distribution_theme_member`. + fn game_distribution_theme_member(&self) -> GameDistributionThemeMemberTableHandle<'_>; +} + +impl GameDistributionThemeMemberTableAccess for super::RemoteTables { + fn game_distribution_theme_member(&self) -> GameDistributionThemeMemberTableHandle<'_> { + GameDistributionThemeMemberTableHandle { + imp: self + .imp + .get_table::("game_distribution_theme_member"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct GameDistributionThemeMemberInsertCallbackId(__sdk::CallbackId); +pub struct GameDistributionThemeMemberDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableLike for GameDistributionThemeMemberTableHandle<'ctx> { + type Row = GameDistributionThemeMember; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } +} + +impl<'ctx> __sdk::Table for GameDistributionThemeMemberTableHandle<'ctx> { + type Row = GameDistributionThemeMember; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = GameDistributionThemeMemberInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameDistributionThemeMemberInsertCallbackId { + GameDistributionThemeMemberInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: GameDistributionThemeMemberInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = GameDistributionThemeMemberDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameDistributionThemeMemberDeleteCallbackId { + GameDistributionThemeMemberDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: GameDistributionThemeMemberDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +impl<'ctx> __sdk::WithInsert for GameDistributionThemeMemberTableHandle<'ctx> { + type InsertCallbackId = GameDistributionThemeMemberInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameDistributionThemeMemberInsertCallbackId { + GameDistributionThemeMemberInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: GameDistributionThemeMemberInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } +} + +impl<'ctx> __sdk::WithDelete for GameDistributionThemeMemberTableHandle<'ctx> { + type DeleteCallbackId = GameDistributionThemeMemberDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameDistributionThemeMemberDeleteCallbackId { + GameDistributionThemeMemberDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: GameDistributionThemeMemberDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct GameDistributionThemeMemberUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for GameDistributionThemeMemberTableHandle<'ctx> { + type UpdateCallbackId = GameDistributionThemeMemberUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> GameDistributionThemeMemberUpdateCallbackId { + GameDistributionThemeMemberUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: GameDistributionThemeMemberUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +impl<'ctx> __sdk::WithUpdate for GameDistributionThemeMemberTableHandle<'ctx> { + type UpdateCallbackId = GameDistributionThemeMemberUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> GameDistributionThemeMemberUpdateCallbackId { + GameDistributionThemeMemberUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: GameDistributionThemeMemberUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `member_id` unique index on the table `game_distribution_theme_member`, +/// which allows point queries on the field of the same name +/// via the [`GameDistributionThemeMemberMemberIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.game_distribution_theme_member().member_id().find(...)`. +pub struct GameDistributionThemeMemberMemberIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> GameDistributionThemeMemberTableHandle<'ctx> { + /// Get a handle on the `member_id` unique index on the table `game_distribution_theme_member`. + pub fn member_id(&self) -> GameDistributionThemeMemberMemberIdUnique<'ctx> { + GameDistributionThemeMemberMemberIdUnique { + imp: self.imp.get_unique_constraint::("member_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> GameDistributionThemeMemberMemberIdUnique<'ctx> { + /// Find the subscribed row whose `member_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache + .get_or_make_table::("game_distribution_theme_member"); + _table.add_unique_constraint::("member_id", |row| &row.member_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse( + "TableUpdate", + "TableUpdate", + ) + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `GameDistributionThemeMember`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait game_distribution_theme_memberQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `GameDistributionThemeMember`. + fn game_distribution_theme_member( + &self, + ) -> __sdk::__query_builder::Table; +} + +impl game_distribution_theme_memberQueryTableAccess for __sdk::QueryTableAccessor { + fn game_distribution_theme_member( + &self, + ) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("game_distribution_theme_member") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_member_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_member_type.rs new file mode 100644 index 000000000..677d2984d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_member_type.rs @@ -0,0 +1,65 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct GameDistributionThemeMember { + pub member_id: String, + pub theme_id: String, + pub root_game_id: String, + pub sort_order: i64, + pub created_at: __sdk::Timestamp, +} + +impl __sdk::InModule for GameDistributionThemeMember { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `GameDistributionThemeMember`. +/// +/// Provides typed access to columns for query building. +pub struct GameDistributionThemeMemberCols { + pub member_id: __sdk::__query_builder::Col, + pub theme_id: __sdk::__query_builder::Col, + pub root_game_id: __sdk::__query_builder::Col, + pub sort_order: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for GameDistributionThemeMember { + type Cols = GameDistributionThemeMemberCols; + fn cols(table_name: &'static str) -> Self::Cols { + GameDistributionThemeMemberCols { + member_id: __sdk::__query_builder::Col::new(table_name, "member_id"), + theme_id: __sdk::__query_builder::Col::new(table_name, "theme_id"), + root_game_id: __sdk::__query_builder::Col::new(table_name, "root_game_id"), + sort_order: __sdk::__query_builder::Col::new(table_name, "sort_order"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + } + } +} + +/// Indexed column accessor struct for the table `GameDistributionThemeMember`. +/// +/// Provides typed access to indexed columns for query building. +pub struct GameDistributionThemeMemberIxCols { + pub member_id: __sdk::__query_builder::IxCol, + pub root_game_id: __sdk::__query_builder::IxCol, + pub theme_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for GameDistributionThemeMember { + type IxCols = GameDistributionThemeMemberIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + GameDistributionThemeMemberIxCols { + member_id: __sdk::__query_builder::IxCol::new(table_name, "member_id"), + root_game_id: __sdk::__query_builder::IxCol::new(table_name, "root_game_id"), + theme_id: __sdk::__query_builder::IxCol::new(table_name, "theme_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for GameDistributionThemeMember {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_table.rs new file mode 100644 index 000000000..c8bbd13b3 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_table.rs @@ -0,0 +1,230 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::game_distribution_theme_type::GameDistributionTheme; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `game_distribution_theme`. +/// +/// Obtain a handle from the [`GameDistributionThemeTableAccess::game_distribution_theme`] method on [`super::RemoteTables`], +/// like `ctx.db.game_distribution_theme()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.game_distribution_theme().on_insert(...)`. +pub struct GameDistributionThemeTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +/// Lifetime-aware accessor marker for the table `game_distribution_theme`. +pub struct GameDistributionThemeTableAccessor; + +impl __sdk::TableAccessor for GameDistributionThemeTableAccessor { + type Row = GameDistributionTheme; + type Handle<'db> = GameDistributionThemeTableHandle<'db>; + + fn get<'db>(db: &'db super::RemoteTables) -> Self::Handle<'db> { + db.game_distribution_theme() + } +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `game_distribution_theme`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait GameDistributionThemeTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`GameDistributionThemeTableHandle`], which mediates access to the table `game_distribution_theme`. + fn game_distribution_theme(&self) -> GameDistributionThemeTableHandle<'_>; +} + +impl GameDistributionThemeTableAccess for super::RemoteTables { + fn game_distribution_theme(&self) -> GameDistributionThemeTableHandle<'_> { + GameDistributionThemeTableHandle { + imp: self + .imp + .get_table::("game_distribution_theme"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct GameDistributionThemeInsertCallbackId(__sdk::CallbackId); +pub struct GameDistributionThemeDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableLike for GameDistributionThemeTableHandle<'ctx> { + type Row = GameDistributionTheme; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } +} + +impl<'ctx> __sdk::Table for GameDistributionThemeTableHandle<'ctx> { + type Row = GameDistributionTheme; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = GameDistributionThemeInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameDistributionThemeInsertCallbackId { + GameDistributionThemeInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: GameDistributionThemeInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = GameDistributionThemeDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameDistributionThemeDeleteCallbackId { + GameDistributionThemeDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: GameDistributionThemeDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +impl<'ctx> __sdk::WithInsert for GameDistributionThemeTableHandle<'ctx> { + type InsertCallbackId = GameDistributionThemeInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameDistributionThemeInsertCallbackId { + GameDistributionThemeInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: GameDistributionThemeInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } +} + +impl<'ctx> __sdk::WithDelete for GameDistributionThemeTableHandle<'ctx> { + type DeleteCallbackId = GameDistributionThemeDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameDistributionThemeDeleteCallbackId { + GameDistributionThemeDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: GameDistributionThemeDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct GameDistributionThemeUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for GameDistributionThemeTableHandle<'ctx> { + type UpdateCallbackId = GameDistributionThemeUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> GameDistributionThemeUpdateCallbackId { + GameDistributionThemeUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: GameDistributionThemeUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +impl<'ctx> __sdk::WithUpdate for GameDistributionThemeTableHandle<'ctx> { + type UpdateCallbackId = GameDistributionThemeUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> GameDistributionThemeUpdateCallbackId { + GameDistributionThemeUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: GameDistributionThemeUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `theme_id` unique index on the table `game_distribution_theme`, +/// which allows point queries on the field of the same name +/// via the [`GameDistributionThemeThemeIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.game_distribution_theme().theme_id().find(...)`. +pub struct GameDistributionThemeThemeIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> GameDistributionThemeTableHandle<'ctx> { + /// Get a handle on the `theme_id` unique index on the table `game_distribution_theme`. + pub fn theme_id(&self) -> GameDistributionThemeThemeIdUnique<'ctx> { + GameDistributionThemeThemeIdUnique { + imp: self.imp.get_unique_constraint::("theme_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> GameDistributionThemeThemeIdUnique<'ctx> { + /// Find the subscribed row whose `theme_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("game_distribution_theme"); + _table.add_unique_constraint::("theme_id", |row| &row.theme_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `GameDistributionTheme`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait game_distribution_themeQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `GameDistributionTheme`. + fn game_distribution_theme(&self) -> __sdk::__query_builder::Table; +} + +impl game_distribution_themeQueryTableAccess for __sdk::QueryTableAccessor { + fn game_distribution_theme(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("game_distribution_theme") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_type.rs new file mode 100644 index 000000000..39e12ff97 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_type.rs @@ -0,0 +1,75 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct GameDistributionTheme { + pub theme_id: String, + pub name: String, + pub summary: String, + pub badge: String, + pub sort_order: i64, + pub status: String, + pub created_by_user_id: String, + pub created_at: __sdk::Timestamp, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for GameDistributionTheme { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `GameDistributionTheme`. +/// +/// Provides typed access to columns for query building. +pub struct GameDistributionThemeCols { + pub theme_id: __sdk::__query_builder::Col, + pub name: __sdk::__query_builder::Col, + pub summary: __sdk::__query_builder::Col, + pub badge: __sdk::__query_builder::Col, + pub sort_order: __sdk::__query_builder::Col, + pub status: __sdk::__query_builder::Col, + pub created_by_user_id: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for GameDistributionTheme { + type Cols = GameDistributionThemeCols; + fn cols(table_name: &'static str) -> Self::Cols { + GameDistributionThemeCols { + theme_id: __sdk::__query_builder::Col::new(table_name, "theme_id"), + name: __sdk::__query_builder::Col::new(table_name, "name"), + summary: __sdk::__query_builder::Col::new(table_name, "summary"), + badge: __sdk::__query_builder::Col::new(table_name, "badge"), + sort_order: __sdk::__query_builder::Col::new(table_name, "sort_order"), + status: __sdk::__query_builder::Col::new(table_name, "status"), + created_by_user_id: __sdk::__query_builder::Col::new(table_name, "created_by_user_id"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `GameDistributionTheme`. +/// +/// Provides typed access to indexed columns for query building. +pub struct GameDistributionThemeIxCols { + pub created_at: __sdk::__query_builder::IxCol, + pub theme_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for GameDistributionTheme { + type IxCols = GameDistributionThemeIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + GameDistributionThemeIxCols { + created_at: __sdk::__query_builder::IxCol::new(table_name, "created_at"), + theme_id: __sdk::__query_builder::IxCol::new(table_name, "theme_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for GameDistributionTheme {} diff --git a/server-rs/crates/spacetime-module/src/game_distribution.rs b/server-rs/crates/spacetime-module/src/game_distribution.rs index 9801d1728..2c3da65fc 100644 --- a/server-rs/crates/spacetime-module/src/game_distribution.rs +++ b/server-rs/crates/spacetime-module/src/game_distribution.rs @@ -836,6 +836,76 @@ pub struct GameDistributionCollection { pub(crate) created_at: Timestamp, } +/// 平台 / 运营命名的「共创主题」:一棵(或一组)作品树的归组实体。 +/// +/// 主题不是作品的属性,而是**运营叙事**:作品在游戏 Tab 里仍作为独立作品展示,主题只是额外 +/// 的归组维度(一个根可以同时属于多个主题)。归属关系在多对多的 `game_distribution_theme_member` +/// 里,因此 `game` 主行(详情页热路径)不挂任何运营字段。 +/// +/// `status` 三态 `draft` / `published` / `archived`:公开侧只出现 `published`( +/// `module_game_distribution::game_distribution_theme_public_visible`),归档不是删除——成员行 +/// 保留,只是不再进入公开投影。`created_by_user_id` 只用于审计「谁建的」,不参与鉴权判定。 +#[spacetimedb::table( + accessor = game_distribution_theme, + index( + accessor = by_game_distribution_theme_created_at, + btree(columns = [created_at]) + ), +)] +#[derive(Clone)] +pub struct GameDistributionTheme { + /// 平台签发的主题 ID,形如 `theme-*`;由服务端生成,不接受客户端指定。 + #[primary_key] + pub(crate) theme_id: String, + /// 主题名(运营命名,不是根作品标题的派生)。 + pub(crate) name: String, + pub(crate) summary: String, + /// 角标短文本(本轮用它 + 名称承担「封面」的呈现职责)。 + pub(crate) badge: String, + /// 运营排序权重(成员排序使用)。 + pub(crate) sort_order: i64, + /// `draft` / `published` / `archived`。 + pub(crate) status: String, + /// 创建该主题的运营账号(= 后台会话主体),用于审计,不参与鉴权判定。 + pub(crate) created_by_user_id: String, + pub(crate) created_at: Timestamp, + pub(crate) updated_at: Timestamp, +} + +/// 主题成员:只允许**根作品**(该作品没有血缘行,即第 0 代)。 +/// +/// 主键是确定性构造的 `{theme_id}:{root_game_id}` +/// (`module_game_distribution::game_distribution_theme_member_id`):同一主题内同一根在 +/// **结构上**不可能出现第二行,去重由主键约束本身承担,而不是靠「事务里先查后写」。因此这里 +/// **不需要** `(theme_id, root_game_id)` 唯一索引;两条具名索引只服务两个真实读取方向 +/// (主题页按主题取成员、作品详情按根反查所属主题)。 +/// +/// 跨主题的同一根会有**多行**(每主题一行),这是预期行为而不是重复数据。作品下架 / 软删除 +/// **不删除**成员行:读取时按当前公开性过滤(`game_distribution_theme_member_visible`), +/// 作品重新公开后成员自动回到主题页。`sort_order` 允许重复,排序由 `member_id` 兜底成全序。 +#[spacetimedb::table( + accessor = game_distribution_theme_member, + index( + accessor = by_game_distribution_theme_member_theme_id, + btree(columns = [theme_id]) + ), + index( + accessor = by_game_distribution_theme_member_root_game_id, + btree(columns = [root_game_id]) + ), +)] +#[derive(Clone)] +pub struct GameDistributionThemeMember { + /// 确定性主键 `"{theme_id}:{root_game_id}"`(同 `game_distribution_collection` 的写法)。 + #[primary_key] + pub(crate) member_id: String, + pub(crate) theme_id: String, + /// 只允许**根作品**。 + pub(crate) root_game_id: String, + pub(crate) sort_order: i64, + pub(crate) created_at: Timestamp, +} + /// 游戏发行版本。包摘要、确认字节数和送审资料在创建后作为不可变快照保留;状态和 /// 审核/对象安装时间由后续事务推进。 #[spacetimedb::table( diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index a82ebd555..dc4ddb090 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -225,6 +225,10 @@ macro_rules! migration_tables { // 用户对公开作品的收藏(收录):真实用户态投影,随迁移导出/导入; // 作品下架不删除行(读取时按当前公开性过滤,重新公开后自动回来)。 game_distribution_collection, + // 共创主题与成员:主题是运营命名实体、成员是「主题 ↔ 根作品」的运营归组事实, + // 随迁移导出/导入;归档 / 下架只改变公开投影,**不删除成员行**。 + game_distribution_theme, + game_distribution_theme_member, llm_router_account, llm_router_billing_checkpoint, agc_model_catalog,