diff --git a/scripts/check-game-distribution-dto-parity.mjs b/scripts/check-game-distribution-dto-parity.mjs index 5428d5589..91efa9a5a 100644 --- a/scripts/check-game-distribution-dto-parity.mjs +++ b/scripts/check-game-distribution-dto-parity.mjs @@ -158,6 +158,9 @@ const RESPONSE_BUILDERS = [ fn: 'public_game_detail_payload', ts: 'GameDistributionGame', base: 'public_game_payload', + // `themes` 与 `collected` 相反:**匿名与登录都发**(主题入口是公开页的一部分,不是 per-user + // 数据),没有所属主题时发空数组而不是缺键,因此它必须进 mustEmit。 + mustEmit: ['themes'], }, { fn: 'private_version_payload', ts: 'GameDistributionPrivateVersion' }, { fn: 'version_summary_payload', ts: 'GameDistributionVersionSummary' }, @@ -168,6 +171,34 @@ const RESPONSE_BUILDERS = [ ts: 'GameDistributionMyCollectionsResponse', mustEmit: ['games', 'nextCursor'], }, + { + // 公开共创主题列表:`themes` / `nextCursor` 两个键都必须发出(末页 `nextCursor: null`)。 + // 单条的形状由 `public_theme_summary_payload` 拼,这里只证明顶层键与 TS 契约一致。 + fn: 'public_themes_payload', + ts: 'GameDistributionThemeListResponse', + mustEmit: ['themes', 'nextCursor'], + }, + { + // 公开共创主题详情:顶层扁平(与 `GET /games/{gameId}` 同形),六个键都由这一条路径发出。 + // `roots` 逐条复用公开目录同一份 `public_game_payload`(不是第二套作品摘要)。 + fn: 'public_theme_detail_payload', + ts: 'GameDistributionThemeDetail', + mustEmit: ['themeId', 'name', 'summary', 'badge', 'memberCount', 'roots'], + }, + { + // 主题条目(列表单条与详情头部共用):`public_themes_payload` 里逐条调用它,`roots` 之外 + // 的嵌套对象在顶层键解析里看不到,所以单独登记,让「条目到底发哪几个键」有独立证据。 + fn: 'public_theme_summary_payload', + ts: 'GameDistributionThemeSummary', + mustEmit: ['themeId', 'name', 'summary', 'badge', 'memberCount'], + }, + { + // 作品详情 `themes` 增量的单条:`{ themeId, name, badge }`(不含简介与成员数——那是主题页的事)。 + // 它是**数组元素**,顶层键解析抓不到它,因此单独登记,让这条增量条目的形状也有证据。 + fn: 'theme_reference_payload', + ts: 'GameDistributionThemeReference', + mustEmit: ['themeId', 'name', 'badge'], + }, ]; 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 267297ef4..ecbaf91d1 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -20,9 +20,11 @@ use axum::{ use flate2::{Compression as GzipCompression, write::GzEncoder}; use module_game_distribution::{ GAME_DISTRIBUTION_COLLECTION_PAGE_LIMIT_DEFAULT, GAME_DISTRIBUTION_COLLECTION_PAGE_LIMIT_MAX, + GAME_DISTRIBUTION_THEME_PAGE_LIMIT_DEFAULT, GAME_DISTRIBUTION_THEME_PAGE_LIMIT_MAX, MAX_PACKAGE_BYTES, MAX_PROJECT_BUNDLE_BYTES, ProjectBundleError, ProjectBundleManifest, ReleaseAssetError, ReleasePackageError, ReleasePackageManifest, compute_request_digest, - extract_release_asset, game_distribution_collection_page_limit, normalize_review_comment, + extract_release_asset, game_distribution_collection_page_limit, + game_distribution_theme_page_limit, normalize_review_comment, normalize_review_moderation_reason, release_asset_content_type, validate_project_bundle_zip, validate_release_zip, validate_review_list_status, }; @@ -42,7 +44,10 @@ use shared_contracts::admin::{ AdminGameReviewsResponse, }; use shared_contracts::game_distribution::{ - GAME_DISTRIBUTION_CATEGORIES, GAME_DISTRIBUTION_VERSION_NUMBER_CONFLICT, + GAME_DISTRIBUTION_CATEGORIES, GAME_DISTRIBUTION_THEME_BAD_REQUEST, + GAME_DISTRIBUTION_THEME_IDEMPOTENCY_CONFLICT, GAME_DISTRIBUTION_THEME_INVALID_CURSOR, + GAME_DISTRIBUTION_THEME_MEMBER_GAME_NOT_FOUND, GAME_DISTRIBUTION_THEME_MEMBER_NOT_ROOT, + GAME_DISTRIBUTION_THEME_NOT_FOUND, GAME_DISTRIBUTION_VERSION_NUMBER_CONFLICT, GameDistributionAuthor, GameDistributionCollectionState, GameDistributionCreateGameRequest, GameDistributionCreateVersionRequest, GameDistributionDerivedResponse, GameDistributionForkAuthorization, GameDistributionForkSource, GameDistributionForkSourceKind, @@ -68,9 +73,11 @@ use spacetime_client::{ GameDistributionRestoreRecordInput, GameDistributionReviewGameListRecordInput, GameDistributionReviewModerationOperationRecord, GameDistributionReviewModerationRecordInput, GameDistributionSetForkAuthorizationRecordInput, GameDistributionSubmitReviewRecordInput, - GameDistributionSuspendRecordInput, GameDistributionUncollectGameRecordInput, - GameDistributionUnpublishRecordInput, GameDistributionUpdateMetadataRecordInput, - GameDistributionUserReviewRecord, GameDistributionVersionRecord, SpacetimeClientError, + GameDistributionSuspendRecordInput, GameDistributionThemeDetailRecord, + GameDistributionThemeReferenceRecord, GameDistributionThemeSummaryRecord, + GameDistributionUncollectGameRecordInput, GameDistributionUnpublishRecordInput, + GameDistributionUpdateMetadataRecordInput, GameDistributionUserReviewRecord, + GameDistributionVersionRecord, SpacetimeClientError, }; use tracing::{debug, info, warn}; use uuid::Uuid; @@ -273,6 +280,16 @@ struct MyCollectionsQuery { cursor: Option, } +/// 公开共创主题列表的查询串:`limit` + `cursor`。 +/// +/// 与 `/my-collections` 同一套数值与处理方式(默认 20 / 上限 50 / 超界截断 / 非法游标 400)—— +/// 两条都是公开网格列表,没有理由用两套分页口径。数值只在 `module_game_distribution` 定义一次。 +#[derive(Debug, Deserialize)] +struct ThemeListQuery { + limit: Option, + cursor: Option, +} + /// 作者软删除游戏:CAS 修订号走查询串,删除本身没有请求体。 #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -497,6 +514,16 @@ pub fn router(state: AppState) -> Router { "/api/game-distribution/games/{game_id}/plays", post(record_game_play), ) + // 共创主题(公开族):与公开目录同一支,匿名可读、整组 `no-store`。 + // - 列表沿用 `/my-collections` 的游标惯例(默认 20 / 上限 50 / 非法游标 400); + // - 详情顶层扁平,主题不存在 / 未发布 404,**不**返回空壳; + // - 作品详情的 `themes` 增量挂在既有 `GET /games/{game_id}` 上(见 `get_game`), + // 不新增第四个公开路由。 + .route("/api/game-distribution/themes", get(list_public_themes)) + .route( + "/api/game-distribution/themes/{theme_id}", + get(get_public_theme), + ) .route_layer(middleware::from_fn(add_no_store_response_headers)); Router::new() @@ -1358,29 +1385,45 @@ async fn get_game( None } }; + // 所属公开主题:对匿名与登录**都**下发(主题入口是公开页的一部分,不是 per-user 数据)。 + // 事务内部先取该作品的根再按根反查,因此第 N 代作品也能拿到它所属根的主题;没有所属主题时 + // 是空数组(不是缺键)——「没有主题」与「没登录」因此不会被混成同一种缺键。 + let themes = state + .spacetime_client() + .list_game_distribution_theme_refs_for_root(game_id.clone()) + .await + .map_err(map_spacetime_error)?; Ok(json_success_body( Some(&ctx), - public_game_detail_payload(game, collected), + public_game_detail_payload(game, collected, themes), )) } -/// 公开详情负载:在公开目录那条 `public_game_payload` 之上按可选登录态追加 `collected`。 +/// 公开详情负载:在公开目录那条 `public_game_payload` 之上按可选登录态追加 `collected`, +/// 并追加该作品所属的公开主题 `themes`(匿名也发)。 /// -/// 抽成函数而不是写在 handler 里,一是让「登录才加键、匿名不加键」能被单测钉住,二是它同时是 -/// DTO parity 脚本登记的响应构建器(证明这条路径确实会发出 `collected`)。 +/// 抽成函数而不是写在 handler 里,一是让「登录才加 `collected`、`themes` 恒发」能被单测钉住, +/// 二是它同时是 DTO parity 脚本登记的响应构建器(证明这条路径确实会发出这两个键)。 /// /// `collected == None`(匿名 / 无效 token 按匿名)时**不加键**,而不是发 `false`:`false` 会把 /// 「未登录」说成「没收藏」,客户端无法区分,会渲染出错误的收藏按钮态。 fn public_game_detail_payload( game: GameDistributionPublicGameRecord, collected: Option, + themes: Vec, ) -> Value { let mut payload = public_game_payload(game); + let Some(object) = payload.as_object_mut() else { + return payload; + }; if let Some(collected) = collected { - if let Value::Object(object) = &mut payload { - object.insert("collected".to_string(), json!(collected)); - } + object.insert("collected".to_string(), json!(collected)); } + // 没有所属主题时发空数组:展示层据此不渲染空容器,也不会把缺键误读成「加载失败」。 + object.insert( + "themes".to_string(), + Value::Array(themes.iter().map(theme_reference_payload).collect()), + ); payload } @@ -1547,6 +1590,138 @@ fn my_collections_page_limit(limit: Option) -> u32 { ) as u32 } +/// 公开共创主题列表:`GET /api/game-distribution/themes?limit=&cursor=`。 +/// +/// 匿名可读;只含已发布主题。分页口径与 `/my-collections` 完全一致:默认 20、上限 50、超界 +/// **截断**(客户端拿到一个完整页,而不是一个需要重试的错误);游标格式非法由模块侧报错, +/// 这里透传成 400(不吞掉、也不自己造一种 200 的空页)。 +async fn list_public_themes( + State(state): State, + Extension(ctx): Extension, + Query(query): Query, +) -> Result, AppError> { + // 与模块侧同一套归一化(同一函数),因此日志里的 `limit` 就是真正生效的页大小。 + let limit = themes_page_limit(query.limit); + let cursor = normalize_optional(query.cursor); + let (themes, next_cursor) = state + .spacetime_client() + .list_game_distribution_themes(limit, cursor.clone()) + .await + .map_err(map_spacetime_error)?; + info!( + request_id = ctx.request_id(), + operation = "game_themes_listed", + themes = themes.len(), + limit, + max_limit = GAME_DISTRIBUTION_THEME_PAGE_LIMIT_MAX, + has_cursor = cursor.is_some(), + has_more = next_cursor.is_some(), + elapsed_ms = ctx.elapsed(), + "读取公开共创主题列表" + ); + Ok(json_success_body( + Some(&ctx), + public_themes_payload(themes, next_cursor), + )) +} + +/// 公开共创主题详情:`GET /api/game-distribution/themes/{theme_id}`。 +/// +/// 匿名可读。主题不存在 / 未发布一律 404(模块侧回同一条「主题不存在」文案,由既有映射落 404), +/// **不返回空壳主题**——空壳会让客户端把「不存在」渲染成一个空白但正常的主题页。 +/// 已发布但可见成员为空是正常结果(200 + 空 `roots`),展示层按空态而不是错误渲染。 +async fn get_public_theme( + State(state): State, + Extension(ctx): Extension, + Path(theme_id): Path, +) -> Result, AppError> { + let theme = state + .spacetime_client() + .get_game_distribution_theme_detail(theme_id.clone()) + .await + .map_err(map_spacetime_error)?; + info!( + request_id = ctx.request_id(), + operation = "game_theme_read", + theme_id = %theme_id, + roots = theme.roots.len(), + elapsed_ms = ctx.elapsed(), + "读取公开共创主题详情" + ); + Ok(json_success_body( + Some(&ctx), + public_theme_detail_payload(theme), + )) +} + +/// 公开主题列表响应:`{ themes: [...], nextCursor }`。 +/// +/// `nextCursor` 是**真实**游标:还有下一页时给出,最后一页为 `null`,客户端据此决定是否继续拉。 +/// 同步纯函数:既是 handler 的组装点,也是 DTO parity 脚本登记的响应构建器。 +fn public_themes_payload( + themes: Vec, + next_cursor: Option, +) -> Value { + json!({ + "themes": themes + .into_iter() + .map(public_theme_summary_payload) + .collect::>(), + "nextCursor": next_cursor, + }) +} + +/// 主题条目:`{ themeId, name, summary, badge, memberCount }`(列表与详情头部共用同一形状)。 +/// +/// `memberCount` 是**当前公开可见成员数**(与详情 `roots` 长度同一个数):回报「成员行总数」会让 +/// 运营通过计数变化探测到「存在草稿或被下架成员」,且它会与 `roots` 长度对不上。 +fn public_theme_summary_payload(theme: GameDistributionThemeSummaryRecord) -> Value { + json!({ + "themeId": theme.theme_id, + "name": theme.name, + "summary": theme.summary, + "badge": theme.badge, + "memberCount": theme.member_count, + }) +} + +/// 公开主题详情响应:顶层扁平(与 `GET /games/{gameId}` 同形,不引入第二套包装), +/// `roots` 逐条是公开目录**同一份** `public_game_payload` 投影。 +fn public_theme_detail_payload(theme: GameDistributionThemeDetailRecord) -> Value { + json!({ + "themeId": theme.theme_id, + "name": theme.name, + "summary": theme.summary, + "badge": theme.badge, + "memberCount": theme.member_count, + "roots": theme + .roots + .into_iter() + .map(public_game_payload) + .collect::>(), + }) +} + +/// 作品详情 `themes` 增量的单条:`{ themeId, name, badge }`。 +/// +/// 只够渲染一个跳转入口:简介与成员数在主题页取,避免详情页为每个主题多查一次成员投影。 +fn theme_reference_payload(theme: &GameDistributionThemeReferenceRecord) -> Value { + json!({ + "themeId": theme.theme_id, + "name": theme.name, + "badge": theme.badge, + }) +} + +/// 主题列表查询串的 `limit` → 生效页大小:缺省取默认 20,超界截断到上限 50(`0` 也取默认)。 +/// +/// 归一化委托给 `module_game_distribution::game_distribution_theme_page_limit`,与事务里真正切页 +/// 用的是**同一个函数**,避免「日志写 50、实际发了 20」这类漂移。 +fn themes_page_limit(limit: Option) -> u32 { + game_distribution_theme_page_limit(limit.unwrap_or(GAME_DISTRIBUTION_THEME_PAGE_LIMIT_DEFAULT)) + as u32 +} + /// 读接口的「不可读即 404」:族谱与衍生列表的锚点必须公开可读,否则按「不存在」处理。 /// /// 抽成函数是为了让这条映射可被单测钉住(不可读 → 404),而不是散落在两个 handler 里。 @@ -4811,6 +4986,50 @@ fn map_spacetime_error(error: SpacetimeClientError) -> AppError { .with_code(code) .with_details(json!({ "provider": "game-distribution", "message": message })) } + // 共创主题(`THEME_*`)错误码:与上一段同构,同样必须先于下面的「不匹配 / 不存在 / + // 状态」子串分支,否则主题错误会被误映射成通用 409 / 404。 + // + // 映射顺序同时决定了两条文案约束(都有单测钉住):落 404 的文案**不得**含「状态」, + // 否则会先命中 409 分支;落 409 的文案**不得**含「不存在」,否则会先命中 404 分支。 + // 公开侧刻意不发这些码(不存在 / 未发布一律只回「主题不存在」文案)。 + SpacetimeClientError::Procedure(message) if message.contains("THEME_") => { + let code = message + .split(':') + .next() + .map(str::trim) + .filter(|code| code.starts_with("THEME_")) + .unwrap_or("THEME_ERROR"); + let (status, code) = match code { + GAME_DISTRIBUTION_THEME_NOT_FOUND => { + (StatusCode::NOT_FOUND, GAME_DISTRIBUTION_THEME_NOT_FOUND) + } + GAME_DISTRIBUTION_THEME_BAD_REQUEST => { + (StatusCode::BAD_REQUEST, GAME_DISTRIBUTION_THEME_BAD_REQUEST) + } + GAME_DISTRIBUTION_THEME_INVALID_CURSOR => ( + StatusCode::BAD_REQUEST, + GAME_DISTRIBUTION_THEME_INVALID_CURSOR, + ), + GAME_DISTRIBUTION_THEME_IDEMPOTENCY_CONFLICT => ( + StatusCode::CONFLICT, + GAME_DISTRIBUTION_THEME_IDEMPOTENCY_CONFLICT, + ), + GAME_DISTRIBUTION_THEME_MEMBER_NOT_ROOT => ( + StatusCode::CONFLICT, + GAME_DISTRIBUTION_THEME_MEMBER_NOT_ROOT, + ), + GAME_DISTRIBUTION_THEME_MEMBER_GAME_NOT_FOUND => ( + StatusCode::NOT_FOUND, + GAME_DISTRIBUTION_THEME_MEMBER_GAME_NOT_FOUND, + ), + // 未登记的码按「请求非法」保守处理:主题错误里参数类占多数,400 比 409 更少 + // 误导客户端去重试;无论如何都不会落到 axum 默认的 422 纯文本。 + _ => (StatusCode::BAD_REQUEST, "THEME_ERROR"), + }; + AppError::from_status(status) + .with_code(code) + .with_details(json!({ "provider": "game-distribution", "message": message })) + } SpacetimeClientError::Procedure(message) if message.contains("owner 不匹配") => { AppError::from_status(StatusCode::FORBIDDEN) .with_details(json!({ "provider": "game-distribution", "message": message })) @@ -8036,24 +8255,334 @@ mod tests { /// 公开详情的 `collected` 可见性:登录才加键;匿名**不加键**(不是 `false`)。 /// /// `false` 会把「未登录」说成「没收藏」,客户端无法区分,会渲染出错误的按钮态。 + /// `themes` 与之相反:**匿名与登录都发**(主题入口是公开页的一部分,不是 per-user 数据), + /// 没有所属主题时是空数组而不是缺键——「没有主题」与「没登录」因此不会被混成同一种缺键。 #[test] - fn public_game_detail_payload_only_adds_collected_for_signed_in_viewer() { - let anonymous = public_game_detail_payload(public_game_record_fixture(), None); + fn public_game_detail_payload_adds_collected_only_when_signed_in_and_always_adds_themes() { + let anonymous = public_game_detail_payload(public_game_record_fixture(), None, Vec::new()); assert!( anonymous.get("collected").is_none(), "匿名请求不得带 collected:{anonymous}" ); - // 匿名负载与公开目录负载逐字节一致(这条路径不引入任何 per-user 字段)。 - assert_eq!(anonymous, public_game_payload(public_game_record_fixture())); + assert_eq!( + anonymous["themes"], + Value::Array(Vec::new()), + "没有所属主题时必须发空数组(不是缺键):{anonymous}" + ); + // 匿名负载 = 公开目录负载 + 一个恒发的 `themes` 键(这条路径不引入任何 per-user 字段)。 + let mut expected = public_game_payload(public_game_record_fixture()); + expected + .as_object_mut() + .expect("公开目录负载是对象") + .insert("themes".to_string(), Value::Array(Vec::new())); + assert_eq!(anonymous, expected); - let collected = public_game_detail_payload(public_game_record_fixture(), Some(true)); + let collected = + public_game_detail_payload(public_game_record_fixture(), Some(true), Vec::new()); assert_eq!(collected["collected"], Value::Bool(true)); - let not_collected = public_game_detail_payload(public_game_record_fixture(), Some(false)); + let not_collected = + public_game_detail_payload(public_game_record_fixture(), Some(false), Vec::new()); assert_eq!(not_collected["collected"], Value::Bool(false)); // 收藏态只加这一个键,不会顺手把别的私有字段带出去。 assert_eq!( not_collected.as_object().map(|object| object.len()), anonymous.as_object().map(|object| object.len() + 1) ); + // `themes` 逐条只发跳转入口需要的三个键:简介与成员数属于主题页,详情页不发(也不多查一次)。 + let with_themes = public_game_detail_payload( + public_game_record_fixture(), + None, + vec![theme_reference_fixture("theme_a")], + ); + assert_eq!( + with_themes["themes"], + json!([{ "themeId": "theme_a", "name": "主题 theme_a", "badge": "精选" }]) + ); + let mut reference_keys = with_themes["themes"][0] + .as_object() + .expect("条目必须是对象") + .keys() + .cloned() + .collect::>(); + reference_keys.sort(); + assert_eq!(reference_keys, vec!["badge", "name", "themeId"]); + } + + fn theme_summary_fixture( + theme_id: &str, + member_count: u64, + ) -> GameDistributionThemeSummaryRecord { + GameDistributionThemeSummaryRecord { + theme_id: theme_id.to_string(), + name: format!("主题 {theme_id}"), + summary: "运营简介".to_string(), + badge: "精选".to_string(), + member_count, + } + } + + fn theme_reference_fixture(theme_id: &str) -> GameDistributionThemeReferenceRecord { + GameDistributionThemeReferenceRecord { + theme_id: theme_id.to_string(), + name: format!("主题 {theme_id}"), + badge: "精选".to_string(), + } + } + + /// 主题列表 `limit` 口径:与 `/my-collections` 同值(缺省 20 / 超界截断到 50 / `0` 取默认)。 + /// + /// 归一化就是模块里的那一个函数,因此 handler 记的 `limit` 与事务真正用的页大小不可能对不上。 + #[test] + fn themes_limit_defaults_and_truncates_like_collections() { + assert_eq!(themes_page_limit(None), 20); + assert_eq!(themes_page_limit(Some(0)), 20); + assert_eq!(themes_page_limit(Some(5)), 5); + assert_eq!(themes_page_limit(Some(50)), 50); + assert_eq!(themes_page_limit(Some(51)), 50, "超界截断而不是报错"); + assert_eq!(themes_page_limit(Some(u32::MAX)), 50); + assert_eq!(GAME_DISTRIBUTION_THEME_PAGE_LIMIT_MAX, 50); + assert_eq!(GAME_DISTRIBUTION_THEME_PAGE_LIMIT_DEFAULT, 20); + } + + /// 主题列表非法游标必须落 400:模块侧文案经 `map_spacetime_error` 的兜底分支, + /// 不得被 404 / 409 的子串规则抢先命中(与 `/my-collections` 同一条纪律)。 + #[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(forbidden), + "游标错误文案不得含「{forbidden}」:{message}" + ); + } + assert_eq!( + map_spacetime_error(SpacetimeClientError::Procedure(message.clone())).status_code(), + StatusCode::BAD_REQUEST, + "{message}" + ); + } + + /// 主题列表响应形状:`themes` / `nextCursor` 一定发出;游标是真实值,最后一页为 `null`; + /// 单条只有契约里那五个键(`memberCount` 是可见成员数,不是成员行总数)。 + #[test] + fn public_themes_payload_shape_matches_contract() { + let with_more = public_themes_payload( + vec![theme_summary_fixture("theme_a", 2)], + Some("100:theme_a".to_string()), + ); + assert_eq!( + with_more, + json!({ + "themes": [ + { + "themeId": "theme_a", + "name": "主题 theme_a", + "summary": "运营简介", + "badge": "精选", + "memberCount": 2, + } + ], + "nextCursor": "100:theme_a", + }) + ); + let last_page = public_themes_payload(Vec::new(), None); + assert_eq!( + last_page, + json!({ "themes": [], "nextCursor": Value::Null }) + ); + let mut item_keys = with_more["themes"][0] + .as_object() + .expect("条目必须是对象") + .keys() + .cloned() + .collect::>(); + item_keys.sort(); + assert_eq!( + item_keys, + vec!["badge", "memberCount", "name", "summary", "themeId"] + ); + } + + /// 主题详情响应:顶层扁平(六个键),`roots` 走公开目录**同一份**投影,不另造条目形状。 + #[test] + fn public_theme_detail_payload_is_flat_and_reuses_public_game_projection() { + let detail = public_theme_detail_payload(GameDistributionThemeDetailRecord { + theme_id: "theme_a".to_string(), + name: "主题 theme_a".to_string(), + summary: "运营简介".to_string(), + badge: "精选".to_string(), + member_count: 1, + roots: vec![public_game_record_fixture()], + }); + let mut keys = detail + .as_object() + .expect("详情负载是对象") + .keys() + .cloned() + .collect::>(); + keys.sort(); + assert_eq!( + keys, + vec![ + "badge", + "memberCount", + "name", + "roots", + "summary", + "themeId" + ] + ); + assert_eq!(detail["themeId"], "theme_a"); + assert_eq!(detail["memberCount"], 1); + // `roots` 的条目与公开目录逐字节一致:同一条投影链路,不存在第二套作品摘要。 + assert_eq!( + detail["roots"][0], + public_game_payload(public_game_record_fixture()) + ); + // 已发布但可见成员为空:200 + 空 `roots`(不是 404、不是缺键)。 + let empty = public_theme_detail_payload(GameDistributionThemeDetailRecord { + theme_id: "theme_b".to_string(), + name: "主题 theme_b".to_string(), + summary: String::new(), + badge: String::new(), + member_count: 0, + roots: Vec::new(), + }); + assert_eq!(empty["memberCount"], 0); + assert_eq!(empty["roots"], Value::Array(Vec::new())); + } + + /// 主题公开路由必须挂载且整组 `no-store`:匿名可读(没有鉴权层),未连库时走到 SpacetimeDB + /// 才失败(502),而不是 404 / 401 / 405。 + #[tokio::test] + async fn theme_routes_are_public_and_no_store() { + use axum::{body::Body, http::Request}; + use tower::ServiceExt; + + let app = crate::app::build_router( + crate::state::AppState::new(crate::config::AppConfig::default()) + .expect("测试状态应可构建"), + ); + + for uri in [ + "/api/game-distribution/themes", + "/api/game-distribution/themes/theme_a", + "/api/game-distribution/themes?limit=5&cursor=100%3Atheme_a", + ] { + let response = app + .clone() + .oneshot( + Request::builder() + .uri(uri) + .body(Body::empty()) + .expect("请求"), + ) + .await + .expect("路由响应"); + assert_eq!( + response.status(), + StatusCode::BAD_GATEWAY, + "{uri} 必须挂载并走到 SpacetimeDB" + ); + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("no-store"), + "{uri} 是公开读接口,必须不缓存" + ); + } + } + + /// 主题错误码映射:公开侧「主题不存在」落 404;`THEME_*` 码逐条落表里那个状态码, + /// 且**不**落到 axum 默认的 422 纯文本。 + /// + /// 顺序约束是这段映射的前提:先于「状态」(409)与「不存在」(404)两个子串分支。 + #[test] + fn theme_errors_map_to_documented_status_codes() { + // 公开侧:不存在 / 未发布共用同一句文案,落 404。 + let public_not_found = + map_spacetime_error(SpacetimeClientError::Procedure("主题不存在".to_string())); + assert_eq!(public_not_found.status_code(), StatusCode::NOT_FOUND); + + for (code, expected) in [ + (GAME_DISTRIBUTION_THEME_NOT_FOUND, StatusCode::NOT_FOUND), + (GAME_DISTRIBUTION_THEME_BAD_REQUEST, StatusCode::BAD_REQUEST), + ( + GAME_DISTRIBUTION_THEME_INVALID_CURSOR, + StatusCode::BAD_REQUEST, + ), + ( + GAME_DISTRIBUTION_THEME_IDEMPOTENCY_CONFLICT, + StatusCode::CONFLICT, + ), + ( + GAME_DISTRIBUTION_THEME_MEMBER_NOT_ROOT, + StatusCode::CONFLICT, + ), + ( + GAME_DISTRIBUTION_THEME_MEMBER_GAME_NOT_FOUND, + StatusCode::NOT_FOUND, + ), + ] { + let error = + map_spacetime_error(SpacetimeClientError::Procedure(format!("{code}: 具体原因"))); + assert_eq!(error.status_code(), expected, "{code}"); + assert_eq!(error.code(), code, "{code} 的错误码必须原样透传"); + } + + // 未登记的码按请求非法保守处理,并给出可检索的稳定码(不是 axum 的 422 纯文本)。 + let unknown = map_spacetime_error(SpacetimeClientError::Procedure( + "THEME_NEW: 未登记".to_string(), + )); + assert_eq!(unknown.status_code(), StatusCode::BAD_REQUEST); + assert_eq!(unknown.code(), "THEME_ERROR"); + + // 顺序约束:`THEME_*` 分支排在所有子串分支之前,因此主题错误码的语义永远不会被 + // 「不存在 / 状态 / 不匹配」这类字样抢走(用一个刻意带污染字样的码证明这一点)。 + let not_root = map_spacetime_error(SpacetimeClientError::Procedure( + "THEME_MEMBER_NOT_ROOT: 目标作品有血缘行(不是根;它不能说“不存在”)".to_string(), + )); + assert_eq!(not_root.status_code(), StatusCode::CONFLICT); + assert_eq!(not_root.code(), GAME_DISTRIBUTION_THEME_MEMBER_NOT_ROOT); + // 反过来,**不带**前缀的文案会落到子串规则上,而分支顺序是「不存在」在「不匹配 / 状态」之前: + // ① 409 语义的文案一旦带上「不存在」就会被映射成 404——所以模块侧写 409 文案时必须避开它; + // ② 公开侧的 404 文案因为含「不存在」,即使多写一个「状态」也稳落 404(不会翻成 409)。 + assert_eq!( + map_spacetime_error(SpacetimeClientError::Procedure( + "成员状态不匹配".to_string() + )) + .status_code(), + StatusCode::CONFLICT, + "「不匹配」子串落在 409 分支" + ); + assert_eq!( + map_spacetime_error(SpacetimeClientError::Procedure( + "成员状态冲突:目标作品不存在".to_string() + )) + .status_code(), + StatusCode::NOT_FOUND, + "含「不存在」的文案先命中 404:409 文案不得含「不存在」" + ); + assert_eq!( + map_spacetime_error(SpacetimeClientError::Procedure( + "主题不存在(状态已归档)".to_string() + )) + .status_code(), + StatusCode::NOT_FOUND, + "公开侧 404 文案含「不存在」,多写「状态」也不会被抢到 409" + ); } } diff --git a/server-rs/crates/shared-contracts/src/game_distribution.rs b/server-rs/crates/shared-contracts/src/game_distribution.rs index 35dfd1e99..796461f8c 100644 --- a/server-rs/crates/shared-contracts/src/game_distribution.rs +++ b/server-rs/crates/shared-contracts/src/game_distribution.rs @@ -33,6 +33,24 @@ pub const GAME_DISTRIBUTION_VERSION_NUMBER_CONFLICT: &str = "VERSION_NUMBER_CONF pub const GAME_DISTRIBUTION_COLLECTION_STATE_CONFLICT: &str = "作品状态不允许收藏(未公开或已软删除)"; +/// 共创主题相关错误的稳定前缀码(`模块前缀_原因`,与 `FORK_*` 同族写法)。 +/// +/// 领域侧(`spacetime-module`)与 api-server 的集中映射**共用这些常量**:新增一种主题错误只需 +/// 在这里加一个码并在映射表里加一行,不会出现「事务里写了一个没人认识的字符串」这种静默漂移。 +/// 公开侧刻意**不带**这些码:主题不存在 / 未发布一律只回「主题不存在」文案(不确认未发布主题的 +/// 存在性),因此公开的 404 与这里的 `THEME_NOT_FOUND` 是两条不同的路径。 +pub const GAME_DISTRIBUTION_THEME_NOT_FOUND: &str = "THEME_NOT_FOUND"; +/// 后台请求参数非法:空 `name` / 非法 `status`(含列表过滤值)。 +pub const GAME_DISTRIBUTION_THEME_BAD_REQUEST: &str = "THEME_BAD_REQUEST"; +/// 主题列表游标格式非法。 +pub const GAME_DISTRIBUTION_THEME_INVALID_CURSOR: &str = "THEME_INVALID_CURSOR"; +/// 同 `Idempotency-Key` 不同请求摘要。 +pub const GAME_DISTRIBUTION_THEME_IDEMPOTENCY_CONFLICT: &str = "THEME_IDEMPOTENCY_CONFLICT"; +/// 目标作品有血缘行(非根),不能作为主题成员。 +pub const GAME_DISTRIBUTION_THEME_MEMBER_NOT_ROOT: &str = "THEME_MEMBER_NOT_ROOT"; +/// 目标作品不存在。 +pub const GAME_DISTRIBUTION_THEME_MEMBER_GAME_NOT_FOUND: &str = "THEME_MEMBER_GAME_NOT_FOUND"; + /// 发布页免费生成简介与分类的输入。 /// /// 只传经过裁剪的项目摘要,不传本地绝对路径、聊天记录、凭据或完整 manifest。 diff --git a/server-rs/crates/spacetime-client/src/active/mapper.rs b/server-rs/crates/spacetime-client/src/active/mapper.rs index a50547920..00d25537e 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper.rs @@ -105,8 +105,10 @@ pub use self::game_distribution::{ GameDistributionPublicGameRecord, GameDistributionRatingSummaryRecord, GameDistributionReviewGameListRecord, GameDistributionReviewGameRecord, GameDistributionReviewModerationOperationRecord, GameDistributionReviewModerationRecord, - GameDistributionUserReviewListRecord, GameDistributionUserReviewRecord, - GameDistributionUserReviewSaveRecord, GameDistributionVersionRecord, + GameDistributionThemeDetailRecord, GameDistributionThemeReferenceRecord, + GameDistributionThemeSummaryRecord, GameDistributionUserReviewListRecord, + GameDistributionUserReviewRecord, GameDistributionUserReviewSaveRecord, + GameDistributionVersionRecord, }; pub use self::llm_router_account::{LlmRouterAccountRecord, LlmRouterAccountUpsertRecordInput}; pub use self::payment::{ @@ -177,8 +179,9 @@ pub(crate) use self::game_distribution::{ map_game_distribution_fork_source_result, map_game_distribution_game_result, map_game_distribution_lineage_result, map_game_distribution_owner_game_list_result, map_game_distribution_public_game_list_result, map_game_distribution_public_game_result, - map_game_distribution_review_list_result, map_game_distribution_version_result, - map_review_moderation_operation, map_user_review, + map_game_distribution_review_list_result, map_game_distribution_theme_detail_result, + map_game_distribution_theme_list_result, map_game_distribution_theme_refs_result, + map_game_distribution_version_result, map_review_moderation_operation, map_user_review, }; pub(crate) use self::payment::{ map_payment_api_key_list_result, map_payment_api_key_result, map_payment_app_list_result, diff --git a/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs b/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs index 35fd4356e..62ef700ac 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs @@ -351,6 +351,40 @@ pub struct GameDistributionCollectionMutationRecord { pub replayed: bool, } +/// 公开共创主题的对外投影:列表条目与详情头部**共用同一个形状**。 +/// +/// `member_count` 是**当前公开可见成员数**(与详情 `roots` 长度同一个数),不是成员行总数。 +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct GameDistributionThemeSummaryRecord { + pub theme_id: String, + pub name: String, + pub summary: String, + pub badge: String, + pub member_count: u64, +} + +/// 作品详情 `themes` 增量的单个条目:只够渲染一个跳转入口(不含简介与成员数)。 +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct GameDistributionThemeReferenceRecord { + pub theme_id: String, + pub name: String, + pub badge: String, +} + +/// 公开共创主题详情:主题头 + 可见成员根(逐条公开目录同一份公开投影)。 +/// +/// 主题不存在 / 未发布时模块侧 `ok == false`,这里折成 `Procedure` 错误,由 api-server 的既有 +/// 映射落 404(不返回空壳);已发布但可见成员为空是**正常**结果(空 `roots`)。 +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct GameDistributionThemeDetailRecord { + pub theme_id: String, + pub name: String, + pub summary: String, + pub badge: String, + pub member_count: u64, + pub roots: Vec, +} + fn map_game( value: crate::module_bindings::GameDistributionGameSnapshot, ) -> GameDistributionGameRecord { @@ -596,6 +630,73 @@ fn map_public_game( } } +/// 公开主题的对外投影(列表条目与详情头部共用同一个形状)。 +fn map_theme( + value: crate::module_bindings::GameDistributionThemeSnapshot, +) -> GameDistributionThemeSummaryRecord { + GameDistributionThemeSummaryRecord { + theme_id: value.theme_id, + name: value.name, + summary: value.summary, + badge: value.badge, + member_count: value.member_count, + } +} + +/// 作品详情 `themes` 增量的单个条目。 +fn map_theme_reference( + value: crate::module_bindings::GameDistributionThemeReferenceSnapshot, +) -> GameDistributionThemeReferenceRecord { + GameDistributionThemeReferenceRecord { + theme_id: value.theme_id, + name: value.name, + badge: value.badge, + } +} + +/// 公开主题列表:`ok == false` 是服务端失败(游标非法等,走既有错误映射 → 400)。 +/// +/// 同时透传真实的下页游标(`None` = 最后一页),让 api-server 能原样发出 `nextCursor`。 +pub(crate) fn map_game_distribution_theme_list_result( + result: crate::module_bindings::GameDistributionThemeListResult, +) -> Result<(Vec, Option), SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + Ok(( + result.themes.into_iter().map(map_theme).collect(), + result.next_cursor, + )) +} + +/// 公开主题详情:`ok == false` 表示主题不存在 / 未发布,折成 `Procedure` 错误由 api-server 映射 +/// 404(这里**不**构造空壳主题,否则「不存在」会被渲染成一个空白的正常主题页)。 +pub(crate) fn map_game_distribution_theme_detail_result( + result: crate::module_bindings::GameDistributionThemeDetailResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + Ok(GameDistributionThemeDetailRecord { + theme_id: result.theme_id, + name: result.name, + summary: result.summary, + badge: result.badge, + member_count: result.member_count, + roots: result.roots.into_iter().map(map_public_game).collect(), + }) +} + +/// 作品详情 `themes` 增量:没有所属主题时是空数组(不是错误,也不是 `None`)。 +pub(crate) fn map_game_distribution_theme_refs_result( + result: crate::module_bindings::GameDistributionThemeRefsResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + Ok(result.themes.into_iter().map(map_theme_reference).collect()) +} + pub(crate) fn map_game_distribution_admin_game_list_result( result: crate::module_bindings::GameDistributionAdminGameListResult, ) -> Result<(Vec, Option), SpacetimeClientError> { @@ -738,3 +839,79 @@ mod lineage_result_tests { assert!(derived.truncated); } } + +/// 主题相关结果的映射:失败必须折成 `Procedure` 错误(api-server 才能映射 404 / 400), +/// **不能**折成空壳主题或空列表——空壳会把「主题不存在」渲染成「主题存在但没有内容」, +/// 而这正是公开详情那条 404 纪律要避免的。`ok = true` + 空 `roots` 则是正常结果(空态)。 +#[cfg(test)] +mod theme_result_tests { + use super::*; + + fn detail_result(ok: bool) -> crate::module_bindings::GameDistributionThemeDetailResult { + crate::module_bindings::GameDistributionThemeDetailResult { + ok, + theme_id: if ok { "theme_a" } else { "" }.to_string(), + name: if ok { "主题 theme_a" } else { "" }.to_string(), + summary: if ok { "运营简介" } else { "" }.to_string(), + badge: if ok { "精选" } else { "" }.to_string(), + member_count: if ok { 1 } else { 0 }, + roots: Vec::new(), + error_message: (!ok).then(|| "主题不存在".to_string()), + } + } + + #[test] + fn theme_not_found_maps_to_procedure_error_instead_of_an_empty_theme() { + let error = map_game_distribution_theme_detail_result(detail_result(false)) + .expect_err("ok = false 必须是错误"); + assert_eq!(error.to_string(), "主题不存在"); + let detail = map_game_distribution_theme_detail_result(detail_result(true)) + .expect("已发布主题(哪怕可见成员为空)是正常结果"); + assert_eq!(detail.theme_id, "theme_a"); + assert_eq!(detail.member_count, 1); + assert!(detail.roots.is_empty(), "空成员不是错误"); + } + + #[test] + fn theme_list_keeps_the_real_cursor_and_refs_map_to_an_empty_array() { + let (themes, next_cursor) = map_game_distribution_theme_list_result( + crate::module_bindings::GameDistributionThemeListResult { + ok: true, + themes: vec![crate::module_bindings::GameDistributionThemeSnapshot { + theme_id: "theme_a".to_string(), + name: "主题 theme_a".to_string(), + summary: "运营简介".to_string(), + badge: "精选".to_string(), + member_count: 3, + }], + next_cursor: Some("100:theme_a".to_string()), + error_message: None, + }, + ) + .expect("列表结果"); + assert_eq!(themes.len(), 1); + assert_eq!(themes[0].member_count, 3); + assert_eq!(next_cursor.as_deref(), Some("100:theme_a")); + + let refs = map_game_distribution_theme_refs_result( + crate::module_bindings::GameDistributionThemeRefsResult { + ok: true, + themes: Vec::new(), + error_message: None, + }, + ) + .expect("增量结果"); + assert!(refs.is_empty(), "没有所属主题是空数组,不是错误"); + + let list_error = map_game_distribution_theme_list_result( + crate::module_bindings::GameDistributionThemeListResult { + ok: false, + themes: Vec::new(), + next_cursor: None, + error_message: Some("主题列表游标格式无效".to_string()), + }, + ) + .expect_err("非法游标必须是错误(api-server 才能映射 400)"); + assert_eq!(list_error.to_string(), "主题列表游标格式无效"); + } +} diff --git a/server-rs/crates/spacetime-client/src/game_distribution.rs b/server-rs/crates/spacetime-client/src/game_distribution.rs index a0ff52ff3..e8546c1a8 100644 --- a/server-rs/crates/spacetime-client/src/game_distribution.rs +++ b/server-rs/crates/spacetime-client/src/game_distribution.rs @@ -841,6 +841,86 @@ impl SpacetimeClient { .await } + /// 公开共创主题列表(游标分页);只含 `published` 主题。 + /// + /// 返回 `(themes, next_cursor)`:`next_cursor` 为 `None` 表示这一页就是最后一页。 + /// 游标格式非法由模块侧报错,api-server 透传成 400(不吞掉、也不自己造一种 200 的空页)。 + pub async fn list_game_distribution_themes( + &self, + limit: u32, + cursor: Option, + ) -> Result<(Vec, Option), SpacetimeClientError> + { + let input = crate::module_bindings::GameDistributionThemeListInput { limit, cursor }; + self.call_after_connect( + "list_game_distribution_themes", + move |connection, sender| { + connection + .procedures() + .list_game_distribution_themes_and_return_then(input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_game_distribution_theme_list_result); + send_once(&sender, mapped); + }); + }, + ) + .await + } + + /// 公开共创主题详情:主题头 + 可见成员根(逐条公开目录同一份公开投影)。 + /// + /// 主题不存在 / 未发布时模块侧 `ok == false`,这里折成 `Procedure` 错误,由 api-server 的 + /// 既有映射落 404(不返回空壳主题);已发布但可见成员为空是正常结果(空 `roots`)。 + pub async fn get_game_distribution_theme_detail( + &self, + theme_id: String, + ) -> Result { + let input = crate::module_bindings::GameDistributionThemeDetailInput { theme_id }; + self.call_after_connect( + "get_game_distribution_theme_detail", + move |connection, sender| { + connection + .procedures() + .get_game_distribution_theme_detail_and_return_then(input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_game_distribution_theme_detail_result); + send_once(&sender, mapped); + }); + }, + ) + .await + } + + /// 某作品所属的公开共创主题(作品详情 `themes` 增量)。 + /// + /// 入参是**作品** ID(任意代):根解析在事务内用血缘行完成,因此第 N 代作品也能拿到它所属根 + /// 的主题。没有所属主题时是空数组,不是错误。 + pub async fn list_game_distribution_theme_refs_for_root( + &self, + game_id: String, + ) -> Result, SpacetimeClientError> { + let input = crate::module_bindings::GameDistributionThemeRefsInput { game_id }; + self.call_after_connect( + "list_game_distribution_theme_refs_for_root", + move |connection, sender| { + connection + .procedures() + .list_game_distribution_theme_refs_for_root_and_return_then( + input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_game_distribution_theme_refs_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + /// 批量累加已公开游戏的游玩次数;非公开游戏由事务静默跳过。 pub async fn increment_game_distribution_game_play_counts( &self, diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index f41b667ff..6f696dd7f 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -487,8 +487,16 @@ 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_detail_input_type; +pub mod game_distribution_theme_detail_result_type; +pub mod game_distribution_theme_list_input_type; +pub mod game_distribution_theme_list_result_type; pub mod game_distribution_theme_member_table; pub mod game_distribution_theme_member_type; +pub mod game_distribution_theme_reference_snapshot_type; +pub mod game_distribution_theme_refs_input_type; +pub mod game_distribution_theme_refs_result_type; +pub mod game_distribution_theme_snapshot_type; pub mod game_distribution_theme_table; pub mod game_distribution_theme_type; pub mod game_distribution_uncollect_game_input_type; @@ -528,6 +536,7 @@ pub mod get_game_distribution_fork_source_and_return_procedure; pub mod get_game_distribution_game_and_return_procedure; pub mod get_game_distribution_lineage_and_return_procedure; pub mod get_game_distribution_my_review_and_return_procedure; +pub mod get_game_distribution_theme_detail_and_return_procedure; pub mod get_game_distribution_version_and_return_procedure; pub mod get_llm_router_account_and_return_procedure; pub mod get_owner_game_distribution_version_and_return_procedure; @@ -568,6 +577,8 @@ pub mod list_game_distribution_derived_games_and_return_procedure; pub mod list_game_distribution_games_and_return_procedure; pub mod list_game_distribution_review_games_and_return_procedure; pub mod list_game_distribution_reviews_and_return_procedure; +pub mod list_game_distribution_theme_refs_for_root_and_return_procedure; +pub mod list_game_distribution_themes_and_return_procedure; pub mod list_game_distribution_user_reviews_and_return_procedure; pub mod list_owner_game_distribution_games_and_return_procedure; pub mod list_payment_api_keys_and_return_procedure; @@ -1401,8 +1412,16 @@ 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_detail_input_type::GameDistributionThemeDetailInput; +pub use game_distribution_theme_detail_result_type::GameDistributionThemeDetailResult; +pub use game_distribution_theme_list_input_type::GameDistributionThemeListInput; +pub use game_distribution_theme_list_result_type::GameDistributionThemeListResult; pub use game_distribution_theme_member_table::*; pub use game_distribution_theme_member_type::GameDistributionThemeMember; +pub use game_distribution_theme_reference_snapshot_type::GameDistributionThemeReferenceSnapshot; +pub use game_distribution_theme_refs_input_type::GameDistributionThemeRefsInput; +pub use game_distribution_theme_refs_result_type::GameDistributionThemeRefsResult; +pub use game_distribution_theme_snapshot_type::GameDistributionThemeSnapshot; pub use game_distribution_theme_table::*; pub use game_distribution_theme_type::GameDistributionTheme; pub use game_distribution_uncollect_game_input_type::GameDistributionUncollectGameInput; @@ -1442,6 +1461,7 @@ pub use get_game_distribution_fork_source_and_return_procedure::get_game_distrib pub use get_game_distribution_game_and_return_procedure::get_game_distribution_game_and_return; pub use get_game_distribution_lineage_and_return_procedure::get_game_distribution_lineage_and_return; pub use get_game_distribution_my_review_and_return_procedure::get_game_distribution_my_review_and_return; +pub use get_game_distribution_theme_detail_and_return_procedure::get_game_distribution_theme_detail_and_return; pub use get_game_distribution_version_and_return_procedure::get_game_distribution_version_and_return; pub use get_llm_router_account_and_return_procedure::get_llm_router_account_and_return; pub use get_owner_game_distribution_version_and_return_procedure::get_owner_game_distribution_version_and_return; @@ -1482,6 +1502,8 @@ pub use list_game_distribution_derived_games_and_return_procedure::list_game_dis pub use list_game_distribution_games_and_return_procedure::list_game_distribution_games_and_return; pub use list_game_distribution_review_games_and_return_procedure::list_game_distribution_review_games_and_return; pub use list_game_distribution_reviews_and_return_procedure::list_game_distribution_reviews_and_return; +pub use list_game_distribution_theme_refs_for_root_and_return_procedure::list_game_distribution_theme_refs_for_root_and_return; +pub use list_game_distribution_themes_and_return_procedure::list_game_distribution_themes_and_return; pub use list_game_distribution_user_reviews_and_return_procedure::list_game_distribution_user_reviews_and_return; pub use list_owner_game_distribution_games_and_return_procedure::list_owner_game_distribution_games_and_return; pub use list_payment_api_keys_and_return_procedure::list_payment_api_keys_and_return; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_detail_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_detail_input_type.rs new file mode 100644 index 000000000..a09c598ea --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_detail_input_type.rs @@ -0,0 +1,15 @@ +// 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 GameDistributionThemeDetailInput { + pub theme_id: String, +} + +impl __sdk::InModule for GameDistributionThemeDetailInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_detail_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_detail_result_type.rs new file mode 100644 index 000000000..fc3035f70 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_detail_result_type.rs @@ -0,0 +1,24 @@ +// 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}; + +use super::game_distribution_public_game_snapshot_type::GameDistributionPublicGameSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct GameDistributionThemeDetailResult { + pub ok: bool, + pub theme_id: String, + pub name: String, + pub summary: String, + pub badge: String, + pub member_count: u64, + pub roots: Vec, + pub error_message: Option, +} + +impl __sdk::InModule for GameDistributionThemeDetailResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_list_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_list_input_type.rs new file mode 100644 index 000000000..d0dc8e2c0 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_list_input_type.rs @@ -0,0 +1,16 @@ +// 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 GameDistributionThemeListInput { + pub limit: u32, + pub cursor: Option, +} + +impl __sdk::InModule for GameDistributionThemeListInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_list_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_list_result_type.rs new file mode 100644 index 000000000..4a3b48b1e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_list_result_type.rs @@ -0,0 +1,20 @@ +// 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}; + +use super::game_distribution_theme_snapshot_type::GameDistributionThemeSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct GameDistributionThemeListResult { + pub ok: bool, + pub themes: Vec, + pub next_cursor: Option, + pub error_message: Option, +} + +impl __sdk::InModule for GameDistributionThemeListResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_reference_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_reference_snapshot_type.rs new file mode 100644 index 000000000..50ced40db --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_reference_snapshot_type.rs @@ -0,0 +1,17 @@ +// 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 GameDistributionThemeReferenceSnapshot { + pub theme_id: String, + pub name: String, + pub badge: String, +} + +impl __sdk::InModule for GameDistributionThemeReferenceSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_refs_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_refs_input_type.rs new file mode 100644 index 000000000..6bbab4389 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_refs_input_type.rs @@ -0,0 +1,15 @@ +// 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 GameDistributionThemeRefsInput { + pub game_id: String, +} + +impl __sdk::InModule for GameDistributionThemeRefsInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_refs_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_refs_result_type.rs new file mode 100644 index 000000000..d920c38af --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_refs_result_type.rs @@ -0,0 +1,19 @@ +// 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}; + +use super::game_distribution_theme_reference_snapshot_type::GameDistributionThemeReferenceSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct GameDistributionThemeRefsResult { + pub ok: bool, + pub themes: Vec, + pub error_message: Option, +} + +impl __sdk::InModule for GameDistributionThemeRefsResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_snapshot_type.rs new file mode 100644 index 000000000..9d390e2ec --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_snapshot_type.rs @@ -0,0 +1,19 @@ +// 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 GameDistributionThemeSnapshot { + pub theme_id: String, + pub name: String, + pub summary: String, + pub badge: String, + pub member_count: u64, +} + +impl __sdk::InModule for GameDistributionThemeSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_game_distribution_theme_detail_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_game_distribution_theme_detail_and_return_procedure.rs new file mode 100644 index 000000000..e3f5a4ba0 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_game_distribution_theme_detail_and_return_procedure.rs @@ -0,0 +1,62 @@ +// 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}; + +use super::game_distribution_theme_detail_input_type::GameDistributionThemeDetailInput; +use super::game_distribution_theme_detail_result_type::GameDistributionThemeDetailResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetGameDistributionThemeDetailAndReturnArgs { + pub input: GameDistributionThemeDetailInput, +} + +impl __sdk::InModule for GetGameDistributionThemeDetailAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_game_distribution_theme_detail_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_game_distribution_theme_detail_and_return { + fn get_game_distribution_theme_detail_and_return( + &self, + input: GameDistributionThemeDetailInput, + ) { + self.get_game_distribution_theme_detail_and_return_then(input, |_, _| {}); + } + + fn get_game_distribution_theme_detail_and_return_then( + &self, + input: GameDistributionThemeDetailInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_game_distribution_theme_detail_and_return for super::RemoteProcedures { + fn get_game_distribution_theme_detail_and_return_then( + &self, + input: GameDistributionThemeDetailInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, GameDistributionThemeDetailResult>( + "get_game_distribution_theme_detail_and_return", + GetGameDistributionThemeDetailAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_game_distribution_theme_refs_for_root_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_game_distribution_theme_refs_for_root_and_return_procedure.rs new file mode 100644 index 000000000..8b81d9a61 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_game_distribution_theme_refs_for_root_and_return_procedure.rs @@ -0,0 +1,62 @@ +// 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}; + +use super::game_distribution_theme_refs_input_type::GameDistributionThemeRefsInput; +use super::game_distribution_theme_refs_result_type::GameDistributionThemeRefsResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ListGameDistributionThemeRefsForRootAndReturnArgs { + pub input: GameDistributionThemeRefsInput, +} + +impl __sdk::InModule for ListGameDistributionThemeRefsForRootAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `list_game_distribution_theme_refs_for_root_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait list_game_distribution_theme_refs_for_root_and_return { + fn list_game_distribution_theme_refs_for_root_and_return( + &self, + input: GameDistributionThemeRefsInput, + ) { + self.list_game_distribution_theme_refs_for_root_and_return_then(input, |_, _| {}); + } + + fn list_game_distribution_theme_refs_for_root_and_return_then( + &self, + input: GameDistributionThemeRefsInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl list_game_distribution_theme_refs_for_root_and_return for super::RemoteProcedures { + fn list_game_distribution_theme_refs_for_root_and_return_then( + &self, + input: GameDistributionThemeRefsInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, GameDistributionThemeRefsResult>( + "list_game_distribution_theme_refs_for_root_and_return", + ListGameDistributionThemeRefsForRootAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_game_distribution_themes_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_game_distribution_themes_and_return_procedure.rs new file mode 100644 index 000000000..8b4fb0f3b --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_game_distribution_themes_and_return_procedure.rs @@ -0,0 +1,59 @@ +// 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}; + +use super::game_distribution_theme_list_input_type::GameDistributionThemeListInput; +use super::game_distribution_theme_list_result_type::GameDistributionThemeListResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ListGameDistributionThemesAndReturnArgs { + pub input: GameDistributionThemeListInput, +} + +impl __sdk::InModule for ListGameDistributionThemesAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `list_game_distribution_themes_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait list_game_distribution_themes_and_return { + fn list_game_distribution_themes_and_return(&self, input: GameDistributionThemeListInput) { + self.list_game_distribution_themes_and_return_then(input, |_, _| {}); + } + + fn list_game_distribution_themes_and_return_then( + &self, + input: GameDistributionThemeListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl list_game_distribution_themes_and_return for super::RemoteProcedures { + fn list_game_distribution_themes_and_return_then( + &self, + input: GameDistributionThemeListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, GameDistributionThemeListResult>( + "list_game_distribution_themes_and_return", + ListGameDistributionThemesAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-module/src/game_distribution.rs b/server-rs/crates/spacetime-module/src/game_distribution.rs index 2c3da65fc..86c33fb9d 100644 --- a/server-rs/crates/spacetime-module/src/game_distribution.rs +++ b/server-rs/crates/spacetime-module/src/game_distribution.rs @@ -1058,6 +1058,15 @@ const GAME_DISTRIBUTION_ADMIN_STATUS_DELETED: &str = "deleted"; /// 软删除游戏的统一拒绝文案;api-server 按其中的"已被删除"映射成 404。 const GAME_DISTRIBUTION_GAME_DELETED_MESSAGE: &str = "游戏已被删除"; const GAME_DISTRIBUTION_RECEIPT_RETENTION_MICROS: i64 = 30 * 24 * 60 * 60 * 1_000_000; +/// 公开主题详情的「不存在 / 未发布」统一文案。 +/// +/// 两种情形**共用同一句话**:客户端无法区分「主题不存在」与「主题存在但未发布」,这正是目的 +/// (不对外确认未发布主题的存在性),与族谱「不可读锚点一律按不存在处理」同一条纪律。 +/// +/// 文案落在 api-server 既有错误映射的「不存在」子串分支上 → 404;因此它**不得**出现「状态」 +/// 「不匹配」「幂等」等会把映射抢到 409 的词(`module-game-distribution` 的纯函数测试与 +/// api-server 的映射测试各钉一遍)。 +const GAME_DISTRIBUTION_THEME_NOT_FOUND_MESSAGE: &str = "主题不存在"; const GAME_DISTRIBUTION_MAX_LIST_LIMIT: u32 = 48; const GAME_DISTRIBUTION_MAX_OWNER_VERSIONS_PER_GAME: usize = 10; @@ -1369,6 +1378,32 @@ pub struct GameDistributionCollectionStateInput { pub user_id: String, } +/// 公开主题列表输入(匿名可读)。 +/// +/// 分页口径与 `/my-collections` 同构:`limit` 为 0 时取默认、超界截断到上限( +/// `module_game_distribution` 的 `GAME_DISTRIBUTION_THEME_PAGE_LIMIT_*` 是唯一口径), +/// `cursor` 为 `"{created_at_micros}:{theme_id}"`。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionThemeListInput { + pub limit: u32, + pub cursor: Option, +} + +/// 公开主题详情输入;只按主题 ID 查询。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionThemeDetailInput { + pub theme_id: String, +} + +/// 作品详情 `themes` 增量输入:给**作品** ID(任意代),由事务先取根再按根反查。 +/// +/// 调用方(api-server)不需要自己懂血缘:根解析在事务内用血缘行完成,避免「BFF 猜根」这种 +/// 第二份规则。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionThemeRefsInput { + pub game_id: String, +} + /// 单次游玩计数增量;api-server 已按 `game_id` 聚合,同一输入内不重复。 #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct GameDistributionPlayCountIncrement { @@ -1691,6 +1726,109 @@ impl GameDistributionCollectionStateResult { } } +/// 公开主题的对外投影:列表条目与详情头部**共用同一个形状**。 +/// +/// `member_count` 是**当前公开可见成员数**,不是成员行总数:回报行数会让运营通过计数变化探测 +/// 出「存在草稿或被下架的成员」,而且它会与详情 `roots` 的长度对不上,客户端无法解释两个数 +/// 为什么不一致。两处都用同一份可见性判定(`game_distribution_theme_member_visible`)算, +/// 因此永远是同一个数。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionThemeSnapshot { + pub theme_id: String, + pub name: String, + pub summary: String, + pub badge: String, + pub member_count: u64, +} + +/// 作品详情 `themes` 增量的单个条目:只够渲染一个跳转入口。 +/// +/// 不带简介与成员数——详情页只需要「这条作品属于哪些主题」和一个能点进去的名字与角标, +/// 完整信息在主题详情里取。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionThemeReferenceSnapshot { + pub theme_id: String, + pub name: String, + pub badge: String, +} + +/// 公开主题列表结果;只含 `published` 主题。 +/// +/// `next_cursor` 是真实的下一页游标:`None` 表示这一页就是最后一页(handler 原样透传成 +/// `nextCursor`,末页发 `null`)。游标格式非法时 `ok = false`,api-server 映射 400。 +#[derive(Clone, Debug, PartialEq, SpacetimeType)] +pub struct GameDistributionThemeListResult { + pub ok: bool, + pub themes: Vec, + pub next_cursor: Option, + pub error_message: Option, +} + +impl GameDistributionThemeListResult { + fn failed(error: String) -> Self { + Self { + ok: false, + themes: Vec::new(), + next_cursor: None, + error_message: Some(error), + } + } +} + +/// 公开主题详情结果:字段与响应体**同形**(顶层扁平,与 `GET /games/{gameId}` 同风格), +/// 不引入第二套包装,因此 handler 不需要再拼一次字段。 +/// +/// `ok == false` 表示主题不存在或未发布(api-server 映射 404);两种情形**共用同一句文案**, +/// 客户端无法区分,这正是目的。已发布但可见成员为空是**正常**结果(`ok = true` + 空 `roots`): +/// 主题是运营实体,不是「不可读锚点」,空主题不指向任何作品、不构成泄露。 +#[derive(Clone, Debug, PartialEq, SpacetimeType)] +pub struct GameDistributionThemeDetailResult { + pub ok: bool, + pub theme_id: String, + pub name: String, + pub summary: String, + pub badge: String, + pub member_count: u64, + pub roots: Vec, + pub error_message: Option, +} + +impl GameDistributionThemeDetailResult { + /// 主题不存在 / 未发布:只带错误文案,其余字段留空(不回空壳,免得客户端把它当正常主题渲染)。 + fn not_found(error: String) -> Self { + Self { + ok: false, + theme_id: String::new(), + name: String::new(), + summary: String::new(), + badge: String::new(), + member_count: 0, + roots: Vec::new(), + error_message: Some(error), + } + } +} + +/// 作品详情 `themes` 增量结果。 +/// +/// 没有所属主题时是**空数组**(不是 `None`、也不是错误):展示层据此不渲染空容器。 +#[derive(Clone, Debug, PartialEq, SpacetimeType)] +pub struct GameDistributionThemeRefsResult { + pub ok: bool, + pub themes: Vec, + pub error_message: Option, +} + +impl GameDistributionThemeRefsResult { + fn failed(error: String) -> Self { + Self { + ok: false, + themes: Vec::new(), + error_message: Some(error), + } + } +} + impl GameDistributionLineageResult { /// 目标作品不存在或已软删除:`ok` 仍为 true,由 api-server 映射成 404。 fn not_found() -> Self { @@ -2462,6 +2600,68 @@ pub fn is_game_distribution_collected_and_return( } } +/// 公开共创主题列表(匿名可读、游标分页);只含 `published` 主题。 +/// +/// 与公开目录 / 族谱一样是**纯读**通道:不要求调用方持有服务身份,也不改任何行。可见性过滤在 +/// 切页之前完成,成员数逐条现算,规则都写在 `module_game_distribution::theme` 里。 +#[spacetimedb::procedure] +pub fn list_game_distribution_themes_and_return( + ctx: &mut ProcedureContext, + input: GameDistributionThemeListInput, +) -> GameDistributionThemeListResult { + match ctx.try_with_tx(|tx| list_game_distribution_themes_tx(tx, input.clone())) { + Ok((themes, next_cursor)) => GameDistributionThemeListResult { + ok: true, + themes, + next_cursor, + error_message: None, + }, + Err(error) => GameDistributionThemeListResult::failed(error), + } +} + +/// 公开共创主题详情(匿名可读):主题头 + 可见成员根清单(逐条公开目录同一份投影)。 +/// +/// 主题不存在 / 未发布 → `ok = false`,由 api-server 映射 404;已发布但可见成员为空是正常结果。 +#[spacetimedb::procedure] +pub fn get_game_distribution_theme_detail_and_return( + ctx: &mut ProcedureContext, + input: GameDistributionThemeDetailInput, +) -> GameDistributionThemeDetailResult { + match ctx.try_with_tx(|tx| get_game_distribution_theme_detail_tx(tx, input.clone())) { + Ok((theme, roots)) => GameDistributionThemeDetailResult { + ok: true, + theme_id: theme.theme_id, + name: theme.name, + summary: theme.summary, + badge: theme.badge, + member_count: theme.member_count, + roots, + error_message: None, + }, + Err(error) => GameDistributionThemeDetailResult::not_found(error), + } +} + +/// 作品详情 `themes` 增量(匿名可读):先取作品的根,再列出包含该根的**公开**主题。 +/// +/// 按根反查(不是按作品自身)是契约的一部分:成员只允许根,第 N 代作品若按自身查会永远查不到 +/// 任何主题,而它恰恰是最需要「跳回主题页」的那一批。 +#[spacetimedb::procedure] +pub fn list_game_distribution_theme_refs_for_root_and_return( + ctx: &mut ProcedureContext, + input: GameDistributionThemeRefsInput, +) -> GameDistributionThemeRefsResult { + match ctx.try_with_tx(|tx| list_game_distribution_theme_refs_for_root_tx(tx, input.clone())) { + Ok(themes) => GameDistributionThemeRefsResult { + ok: true, + themes, + error_message: None, + }, + Err(error) => GameDistributionThemeRefsResult::failed(error), + } +} + #[spacetimedb::procedure] pub fn get_game_distribution_version_and_return( ctx: &mut ProcedureContext, @@ -4622,14 +4822,9 @@ fn game_distribution_lineage_tx( ) { return Ok(None); } - // 有血缘行就按行的根走;没有血缘行说明目标自身就是母版(0 代)。 - let root_game_id = ctx - .db - .game_distribution_lineage() - .game_id() - .find(&game_id) - .map(|lineage| lineage.root_game_id) - .unwrap_or_else(|| game_id.clone()); + // 有血缘行就按行的根走;没有血缘行说明目标自身就是母版(0 代)。根解析只有一份实现, + // 与作品详情的 `themes` 增量共用(见 `game_distribution_theme_root_of`)。 + let root_game_id = game_distribution_theme_root_of(ctx, game_id.as_str()); let entries = game_distribution_lineage_entries(ctx, root_game_id.as_str()); let tree = module_game_distribution::build_lineage_tree( &entries, @@ -4944,6 +5139,221 @@ fn list_game_distribution_collections_tx( )) } +/// 取某作品的**根**:有血缘行就用行上的 `root_game_id`,没有血缘行说明目标自身就是母版(0 代)。 +/// +/// 这里就是 `game_distribution_lineage_tx` 里那段根解析的抽取,两处共用同一个函数:根的语义只 +/// 有一份,主题归属反查与族谱锚点因此不可能得出不同的根(「拿不到血缘行就回退用自身」这类 +/// 降级只在一处)。 +fn game_distribution_theme_root_of(ctx: &ReducerContext, game_id: &str) -> String { + ctx.db + .game_distribution_lineage() + .game_id() + .find(&game_id.to_string()) + .map(|lineage| lineage.root_game_id) + .unwrap_or_else(|| game_id.to_string()) +} + +/// 主题当前可见成员清单:按主题索引取成员行,只保留**当前公开可读**的根,并带出排序键。 +/// +/// 可见性判定委托 `module_game_distribution::game_distribution_theme_member_visible`(与公开目录 / +/// 收藏同一条「未删 + 已公开 + 有当前公开版本」口径),被滤掉的行**不删除**——作品重新公开后 +/// 同一行会自动回来。返回的是「排序键 + 游戏行」的绑定值,交给 `sort_theme_members` / +/// `page_theme_members`,因此不可能出现「按 A 排序、按 B 切页」的错位。 +fn game_distribution_theme_visible_members( + ctx: &ReducerContext, + theme_id: &str, +) -> Vec> { + ctx.db + .game_distribution_theme_member() + .by_game_distribution_theme_member_theme_id() + .filter(theme_id) + .filter_map(|member| { + let game = ctx + .db + .game_distribution_game() + .game_id() + .find(&member.root_game_id)?; + let visible = module_game_distribution::game_distribution_theme_member_visible( + game.deleted_at.is_some(), + game.visibility == GAME_DISTRIBUTION_VISIBILITY_PUBLISHED, + public_game_distribution_version(ctx, &game).is_some(), + ); + if !visible { + return None; + } + Some( + module_game_distribution::GameDistributionThemeMemberPageItem { + member_id: member.member_id.clone(), + sort_order: member.sort_order, + payload: game, + }, + ) + }) + .collect() +} + +/// 主题的 `member_count`:当前公开可见成员数,与详情 `roots` 的长度**是同一个数**。 +/// +/// 两处都走 `game_distribution_theme_visible_members`(同一份可见性判定)+ `page_theme_members` +/// (同一个响应体积上限),所以列表里的数字与详情里的根数不会漂移;也不会因为「存在草稿或 +/// 被下架的成员」而虚高——那正是运营不该被外部探测到的信息。不新增物化计数字段(与仓库 +/// 「实时算」的既有取舍一致)。 +fn game_distribution_theme_member_count(ctx: &ReducerContext, theme_id: &str) -> u64 { + module_game_distribution::page_theme_members( + game_distribution_theme_visible_members(ctx, theme_id), + module_game_distribution::GAME_DISTRIBUTION_THEME_PAGE_LIMIT_MAX as usize, + ) + .len() as u64 +} + +/// 主题行 → 对外投影(含当前可见成员数)。 +fn game_distribution_theme_snapshot( + ctx: &ReducerContext, + theme: &GameDistributionTheme, +) -> GameDistributionThemeSnapshot { + GameDistributionThemeSnapshot { + theme_id: theme.theme_id.clone(), + name: theme.name.clone(), + summary: theme.summary.clone(), + badge: theme.badge.clone(), + member_count: game_distribution_theme_member_count(ctx, theme.theme_id.as_str()), + } +} + +/// 公开主题列表(游标分页)。 +/// +/// 只取 `status == published` 的主题(`game_distribution_theme_public_visible`),并且**先过滤 +/// 可见性、再排序切页**:游标位置定义在已过滤序列上。反过来先把未过滤的主题切页、再逐页过滤, +/// 被滤掉的行会凭空占掉名额,下一页的游标又指回过滤前的序列,于是每翻一页都漏掉自己的若干条 +/// (与 `/my-collections` 同一条纪律)。排序 / 切页走 `page_public_themes`(`created_at` 倒序 + +/// `theme_id` 升序兜底的全序),翻页不重不漏;`member_count` 只为**这一页**的主题现算。 +fn list_game_distribution_themes_tx( + ctx: &ReducerContext, + input: GameDistributionThemeListInput, +) -> Result<(Vec, Option), String> { + let cursor = input + .cursor + .and_then(normalize_game_distribution_optional) + .map(|value| module_game_distribution::parse_game_distribution_theme_cursor(value.as_str())) + .transpose()?; + let visible = ctx + .db + .game_distribution_theme() + .iter() + .filter(|theme| { + module_game_distribution::game_distribution_theme_public_visible(theme.status.as_str()) + }) + .map( + |theme| module_game_distribution::GameDistributionThemePageItem { + theme_id: theme.theme_id.clone(), + created_at_micros: theme.created_at.to_micros_since_unix_epoch(), + payload: theme, + }, + ) + .collect::>(); + let (page, next_cursor) = + module_game_distribution::page_public_themes(visible, cursor, input.limit); + Ok(( + page.iter() + .map(|theme| game_distribution_theme_snapshot(ctx, theme)) + .collect(), + next_cursor, + )) +} + +/// 公开主题详情:主题行 + 可见成员根清单(逐条公开目录同一份 `public_game_distribution_snapshot`)。 +/// +/// 主题不存在 / 未发布(`draft` / `archived`)一律 `Err` 同一句文案,由 api-server 映射 404, +/// 且**不区分**两种情形(不对外确认未发布主题的存在性)。 +/// +/// **已发布但可见成员为空**是正常结果(`Ok` + 空 `roots`):主题是运营实体,不是「不可读锚点」—— +/// 它的存在由运营的发布行为对外确认,空树不指向任何作品、不构成泄露;用 404 反而会把「刚建好 +/// 还没挂作品」这种正常运营状态误报成故障。成员清单按 `sort_order` 升序 + `member_id` 升序稳定 +/// 排序(`sort_order` 允许重复,兜底键保证全序),并受与列表同一个响应体积上限约束。 +fn get_game_distribution_theme_detail_tx( + ctx: &ReducerContext, + input: GameDistributionThemeDetailInput, +) -> Result< + ( + GameDistributionThemeSnapshot, + Vec, + ), + String, +> { + let theme_id = required_game_distribution_text(input.theme_id, "theme_id")?; + let Some(theme) = ctx.db.game_distribution_theme().theme_id().find(&theme_id) else { + return Err(GAME_DISTRIBUTION_THEME_NOT_FOUND_MESSAGE.to_string()); + }; + if !module_game_distribution::game_distribution_theme_public_visible(theme.status.as_str()) { + return Err(GAME_DISTRIBUTION_THEME_NOT_FOUND_MESSAGE.to_string()); + } + let roots = module_game_distribution::page_theme_members( + game_distribution_theme_visible_members(ctx, theme.theme_id.as_str()), + module_game_distribution::GAME_DISTRIBUTION_THEME_PAGE_LIMIT_MAX as usize, + ) + .iter() + .filter_map(|game| public_game_distribution_snapshot(ctx, game)) + .collect::>(); + Ok(( + GameDistributionThemeSnapshot { + theme_id: theme.theme_id.clone(), + name: theme.name.clone(), + summary: theme.summary.clone(), + badge: theme.badge.clone(), + // 与 `roots` 同一个数(同一份可见性判定 + 同一个上限),客户端不会看到两个自相矛盾的计数。 + member_count: roots.len() as u64, + }, + roots, + )) +} + +/// 作品详情 `themes` 增量:先取该作品的**根**,再按根反查所属的**公开**主题。 +/// +/// 按根(而不是按作品自身)反查是契约的一部分:成员只允许根,若按 `game_id` 自身查,第 N 代 +/// 作品会永远查不到任何主题,而它恰恰是最需要「跳回主题页」的那一批。 +/// 排序用与公开主题列表**同一份**比较器(创建时间倒序 + `theme_id` 升序兜底),避免「列表一种 +/// 顺序、详情另一种顺序」的第二套语义;数组顺序不承载运营意图(运营侧顺序是成员的 `sort_order`)。 +/// 没有所属主题时返回空数组,不是错误。 +fn list_game_distribution_theme_refs_for_root_tx( + ctx: &ReducerContext, + input: GameDistributionThemeRefsInput, +) -> Result, String> { + let game_id = required_game_distribution_text(input.game_id, "game_id")?; + let root_game_id = game_distribution_theme_root_of(ctx, game_id.as_str()); + let visible = ctx + .db + .game_distribution_theme_member() + .by_game_distribution_theme_member_root_game_id() + .filter(root_game_id.as_str()) + .filter_map(|member| { + let theme = ctx + .db + .game_distribution_theme() + .theme_id() + .find(&member.theme_id)?; + if !module_game_distribution::game_distribution_theme_public_visible( + theme.status.as_str(), + ) { + return None; + } + Some(module_game_distribution::GameDistributionThemePageItem { + theme_id: theme.theme_id.clone(), + created_at_micros: theme.created_at.to_micros_since_unix_epoch(), + payload: theme, + }) + }) + .collect::>(); + // 同一份全序比较器;这条路径不分页(详情入口是全集),所以不套 `page_public_themes`。 + Ok(module_game_distribution::sort_public_themes(visible) + .into_iter() + .map(|item| GameDistributionThemeReferenceSnapshot { + theme_id: item.payload.theme_id, + name: item.payload.name, + badge: item.payload.badge, + }) + .collect()) +} + /// /// 这里只负责取行(根游戏行、按根索引命中的血缘行、以及每行引用的游戏行), /// 「家族归属校验、根自身入集合、游戏行缺失跳过、重复行去重」全部交给 @@ -5708,4 +6118,207 @@ mod tests { assert!(!conflict_message.contains("不存在")); assert!(!conflict_message.contains("已被删除")); } + + /// 根解析只有**一份**实现:族谱锚点与作品详情 `themes` 增量共用同一个函数。 + /// + /// 两处各自写一遍 `lineage().game_id().find(...)...unwrap_or(自身)` 迟早会漂移成「主题查不到 + /// 第 N 代作品的归属」这类只在一侧发生的 bug;这条断言让第二份实现无法存在。 + #[test] + fn root_resolution_has_a_single_implementation() { + let source = include_str!("game_distribution.rs"); + let helper = function_body(source, "fn game_distribution_theme_root_of("); + assert!(helper.contains(".game_distribution_lineage()")); + assert!(helper.contains(".game_id()")); + assert!( + helper.contains(".map(|lineage| lineage.root_game_id)"), + "有血缘行就用行上的根" + ); + assert!( + helper.contains(".unwrap_or_else(|| game_id.to_string())"), + "没有血缘行说明目标自身就是母版(0 代)" + ); + let lineage = function_body(source, "fn game_distribution_lineage_tx("); + assert!( + lineage.contains("game_distribution_theme_root_of(ctx, game_id.as_str())"), + "族谱锚点必须复用同一份根解析" + ); + assert!( + !lineage.contains(".game_distribution_lineage()"), + "族谱事务里不得再写第二份血缘查根" + ); + let refs = function_body(source, "fn list_game_distribution_theme_refs_for_root_tx("); + assert!( + refs.contains("game_distribution_theme_root_of(ctx, game_id.as_str())"), + "作品详情 themes 增量必须先取根再反查" + ); + assert!( + !refs.contains(".game_distribution_lineage()"), + "反查事务里不得自己解析根" + ); + } + + /// 主题公开列表:只取 `published`、**先过滤再排序切页**、投影不删行、成员数只算可见成员。 + #[test] + fn public_theme_list_filters_before_paging_and_never_deletes_rows() { + let source = include_str!("game_distribution.rs"); + let body = function_body(source, "fn list_game_distribution_themes_tx("); + assert!( + body.contains("module_game_distribution::game_distribution_theme_public_visible"), + "可见性判定必须走共享纯函数(status == published)" + ); + assert!( + body.contains("module_game_distribution::parse_game_distribution_theme_cursor"), + "游标解析必须走共享纯函数(非法游标 → 400)" + ); + assert!( + body.contains("module_game_distribution::page_public_themes"), + "排序切页必须走共享纯函数(created_at 倒序 + theme_id 升序兜底)" + ); + let visible_at = body + .find("game_distribution_theme_public_visible") + .expect("可见性判定必须在事务内"); + let page_at = body + .find("module_game_distribution::page_public_themes(") + .expect("切页必须在事务内"); + assert!( + visible_at < page_at, + "必须先按可见性过滤、再排序切页:反过来会每翻一页漏掉自己的若干条主题" + ); + assert!( + !body.contains(".delete("), + "投影阶段不得删除主题行:归档 / 未发布只是不进入公开投影" + ); + // `memberCount` 只算当前可见成员,且只在**这一页**的主题上现算(不为被切掉的条目白算)。 + assert!(body.contains("game_distribution_theme_snapshot")); + assert!(body.contains("page.iter()"), "成员数必须在切页之后再算"); + let counter = function_body(source, "fn game_distribution_theme_member_count("); + assert!( + counter.contains("game_distribution_theme_visible_members"), + "成员数必须与 roots 共用同一份可见成员清单" + ); + assert!( + counter.contains("GAME_DISTRIBUTION_THEME_PAGE_LIMIT_MAX"), + "成员数与 roots 必须共用同一个响应体积上限,否则两个数会对不上" + ); + let members = function_body(source, "fn game_distribution_theme_visible_members("); + assert!( + members.contains("by_game_distribution_theme_member_theme_id()"), + "按主题索引取成员,不扫描全表" + ); + assert!( + members.contains("module_game_distribution::game_distribution_theme_member_visible"), + "成员可见性必须委托共享判定(未删 + 已公开 + 有当前公开版本)" + ); + assert!( + !members.contains(".delete("), + "投影阶段不得删除成员行:作品重新公开后同一行必须自动回来" + ); + assert!( + members.contains("GameDistributionThemeMemberPageItem"), + "排序键与负载必须绑定成一条值(避免按 A 排序、按 B 切页)" + ); + } + + /// 主题详情:不存在 / 未发布用同一句 404 文案;已发布空成员是正常结果;roots 复用公开投影。 + #[test] + fn public_theme_detail_maps_missing_to_not_found_and_empty_members_to_success() { + let source = include_str!("game_distribution.rs"); + let body = function_body(source, "fn get_game_distribution_theme_detail_tx("); + // 两种不可见情形共用同一句文案:客户端无法区分「不存在」与「未发布」,这正是目的。 + assert_eq!( + body.matches("GAME_DISTRIBUTION_THEME_NOT_FOUND_MESSAGE") + .count(), + 2, + "不存在与未发布必须各有一处(且共用同一条文案)" + ); + assert!( + body.contains("game_distribution_theme_public_visible"), + "未发布(draft / archived)也必须按 404 处理" + ); + assert!( + body.contains("module_game_distribution::page_theme_members"), + "成员排序切页必须走共享纯函数(sort_order 升序 + member_id 升序兜底)" + ); + assert!( + body.contains("public_game_distribution_snapshot"), + "roots 必须复用公开目录同一份投影,不自己拼字段" + ); + assert_eq!( + body.matches("return Err(GAME_DISTRIBUTION_THEME_NOT_FOUND_MESSAGE.to_string());") + .count(), + 2, + "不存在与未发布各一处,且共用同一条 404 文案" + ); + assert_eq!( + body.matches("Err(").count(), + 2, + "详情只允许这两种失败:不得为「已发布但可见成员为空」再造一种错误(那是 200 + 空 roots)" + ); + assert!( + body.contains("member_count: roots.len() as u64"), + "memberCount 必须与同响应的 roots 长度是同一次计算的结果" + ); + assert!(!body.contains(".delete("), "详情是纯读,不得删除任何行"); + // 公开 404 文案必须落在 api-server 的 404 子串分支上,且不得带会把映射抢到 409 的词。 + for forbidden in [ + "状态", + "不匹配", + "幂等", + "已存在", + "已被删除", + "FORK_", + "THEME_", + ] { + assert!( + !GAME_DISTRIBUTION_THEME_NOT_FOUND_MESSAGE.contains(forbidden), + "公开 404 文案不得含「{forbidden}」" + ); + } + assert!(GAME_DISTRIBUTION_THEME_NOT_FOUND_MESSAGE.contains("不存在")); + } + + /// 作品详情 `themes` 增量:按**根**索引反查、只保留公开主题、排序复用同一份比较器、不写库。 + #[test] + fn theme_refs_for_root_uses_root_index_and_public_themes_only() { + let source = include_str!("game_distribution.rs"); + let body = function_body(source, "fn list_game_distribution_theme_refs_for_root_tx("); + assert!( + body.contains("by_game_distribution_theme_member_root_game_id()"), + "必须走按根反查的具名索引,不扫描全表" + ); + assert!( + body.contains("game_distribution_theme_public_visible"), + "只保留已发布主题(草稿 / 归档主题不得出现在作品详情里)" + ); + assert!( + body.contains("module_game_distribution::sort_public_themes"), + "排序必须与公开主题列表共用同一份比较器" + ); + assert!( + !body.contains("page_public_themes("), + "详情入口是全集,不套列表分页" + ); + assert!( + !body.contains(".insert(") && !body.contains(".update(") && !body.contains(".delete("), + "作品详情 themes 是纯读路径" + ); + // 结果类型必须给出真实游标字段(与收藏列表同一纪律):成功路径透传,失败路径显式回 None。 + let procedure = function_body(source, "pub fn list_game_distribution_themes_and_return("); + assert!(procedure.contains("Ok((themes, next_cursor))")); + assert!(procedure.contains("next_cursor,")); + assert!( + procedure.contains("GameDistributionThemeListResult::failed(error)"), + "失败路径必须显式给出空结果(不能靠缺省值)" + ); + let list_result_start = source + .find("impl GameDistributionThemeListResult {") + .expect("主题列表结果类型必须有 failed()"); + let list_result = &source[list_result_start..]; + let list_result = &list_result[..list_result.find("\nimpl ").unwrap_or(list_result.len())]; + assert!(list_result.contains("themes: Vec::new()")); + assert!( + list_result.contains("next_cursor: None"), + "失败路径必须显式给出 next_cursor(不能靠缺省值)" + ); + } }