From 033e3aa79a0c5d9900e86cc006b61757fef12df5 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 6 Oct 2026 02:45:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=B8=B8=E6=88=8F=E5=85=B1=E5=88=9B):=20?= =?UTF-8?q?=E5=85=B1=E5=88=9B=E4=B8=BB=E9=A2=98=EF=BC=88=E4=B8=89=EF=BC=89?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E5=86=99=E8=B7=AF=E5=BE=84=EF=BC=885=20?= =?UTF-8?q?=E6=9D=A1=20admin=20=E8=B7=AF=E7=94=B1=20+=20=E4=BA=8B=E5=8A=A1?= =?UTF-8?q?=20+=20=E5=B9=82=E7=AD=89=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 §3.10.8:后台 UI 不在本轮,本轮只落**接口与事务**。 - **鉴权(照仓库既有 admin 体系,不自造)**:5 条路由挂在既有 `/admin/api/game-distribution/*` 支,统一 `route_layer(require_admin_auth)`,handler 取 `Extension`;未带 / 失效会话 → **401 `UNAUTHORIZED`**(与同组 games/reviews 路由同码同形),非 admin role → 403。模块侧第二层:写 procedure 与后台列表都要求受信服务身份(`require_editor_generation_runtime_service_identity`),只有 api-server 能调。 - **5 条路由**:`POST /themes`(创建;服务端生成 `theme-{uuid}`;要求 `Idempotency-Key`,摘要只覆盖客户端可见请求体,**不含重试时重新生成的 `theme_id`**)、`PUT /themes/{theme_id}`(整体覆盖 name/summary/badge/sortOrder/status,生效即刷新 `updated_at`,重放不写库不刷新)、`PUT /themes/{theme_id}/members/{root_game_id}`(**不要求幂等键**——确定性主键天然幂等;可带 `sortOrder`)、`DELETE …/members/{root_game_id}`(不要求幂等键;成员不存在也算 200,响应刻意不含「之前存不存在」→ 重复调用逐字节相同)、`GET /themes?limit=&status=`(**含 draft/archived**,status 白名单 `all|draft|published|archived`,limit 缺省与上限 200、超界截断、无游标)。 - **成员只允许根作品**:用 `game_distribution_theme_root_acceptable(has_lineage_row)`,非根 → **409 `THEME_MEMBER_NOT_ROOT`**;作品不存在 → 404 `THEME_MEMBER_GAME_NOT_FOUND`;主题不存在 → 404 `THEME_NOT_FOUND`。**不做「成员必须已公开」的前置校验**(运营可先挂草稿根,作品公开后自动出现在公开投影里);归档主题**不清理**成员行(与公开投影「跳过不删行」同一口径)。 - **领域错误码**:`module-game-distribution/src/errors.rs` 新增 5 个带码错误(`ThemeNotFound` / `ThemeBadRequest{reason}` / `ThemeIdempotencyConflict` / `ThemeMemberNotRoot{game_id}` / `ThemeMemberGameNotFound{game_id}`),Display 一律 `CODE: 文案`,并由测试钉住「码后必须跟冒号」;api-server 集中映射,不落 axum 默认 422 纯文本。 - **纯函数补充**:后台 status 过滤器白名单(`all` + 三态的单一实现)、文本上限常量(名称 40 / 简介 200 / 角标 16,按 UI 一行与卡片摘要的量级取,写进注释)与 `game_distribution_theme_text_violation`(trim 后判空名与超长,返回可拼进 `THEME_BAD_REQUEST:` 的原因)。 - **测试**:`theme.rs` +2 纯函数(status 过滤器白名单、文本违规判定)、`domain.rs` +1(错误码前缀)、spacetime-module +8 事务结构断言、client mapper +3、api-server +6(401 同码同形、创建/编辑/成员增删的形状与错误码、后台列表含 draft、重复 PUT 幂等、非根成员被拒)→ `cargo test -p api-server game_distribution` **85 passed**、`module-game-distribution` **100 passed**、`spacetime-module` **286 passed**、`spacetime-client` 37 passed。 - **门禁**:wasm build 0;`cargo check --all-targets` 0;`cargo test -p api-server game_distribution` 85 passed;`cargo test -p module-game-distribution` 100 passed;`cargo test -p spacetime-module` 286 passed / 1 ignored;`cargo test -p spacetime-client` 37 passed;DTO parity 58 组 / 10 构建器;`check:spacetime-schema` 0(96 tables);`check:encoding` 0(5414 files);`cargo fmt --all -- --check` 0;`git diff --check` 0。 - 生成绑定:新增 15 个 `module_bindings/*theme*/admin_theme*` 文件 + 入口声明(无重排);其余 202 个文件的 rustfmt 漂移按上一轮做法逐字节还原,未跑任何 git 写命令。 --- .../src/modules/game_distribution.rs | 715 +++++++++++- .../module-game-distribution/src/domain.rs | 51 + .../module-game-distribution/src/errors.rs | 42 + .../module-game-distribution/src/lib.rs | 17 +- .../module-game-distribution/src/theme.rs | 147 +++ .../crates/spacetime-client/src/active.rs | 12 +- .../spacetime-client/src/active/mapper.rs | 19 +- .../src/active/mapper/game_distribution.rs | 250 ++++ .../spacetime-client/src/game_distribution.rs | 211 ++++ .../spacetime-client/src/module_bindings.rs | 30 + ...distribution_theme_and_return_procedure.rs | 59 + ...istribution_admin_theme_list_input_type.rs | 16 + ...stribution_admin_theme_list_result_type.rs | 19 + ...admin_theme_member_mutation_result_type.rs | 19 + ...bution_admin_theme_member_snapshot_type.rs | 18 + ...bution_admin_theme_mutation_result_type.rs | 20 + ..._distribution_admin_theme_snapshot_type.rs | 23 + ...me_distribution_create_theme_input_type.rs | 24 + ...ribution_theme_member_remove_input_type.rs | 16 + ...ribution_theme_member_upsert_input_type.rs | 18 + ...me_distribution_update_theme_input_type.rs | 24 + ...istribution_themes_and_return_procedure.rs | 62 + ...ution_theme_member_and_return_procedure.rs | 62 + ...distribution_theme_and_return_procedure.rs | 59 + ...ution_theme_member_and_return_procedure.rs | 62 + .../spacetime-module/src/game_distribution.rs | 1016 +++++++++++++++++ 26 files changed, 2968 insertions(+), 43 deletions(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/create_game_distribution_theme_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_theme_list_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_theme_list_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_theme_member_mutation_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_theme_member_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_theme_mutation_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_theme_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_create_theme_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_member_remove_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_member_upsert_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_update_theme_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/list_admin_game_distribution_themes_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/remove_game_distribution_theme_member_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/update_game_distribution_theme_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/upsert_game_distribution_theme_member_and_return_procedure.rs 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 ecbaf91d1..46ee5b1e3 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -24,9 +24,9 @@ use module_game_distribution::{ MAX_PACKAGE_BYTES, MAX_PROJECT_BUNDLE_BYTES, ProjectBundleError, ProjectBundleManifest, ReleaseAssetError, ReleasePackageError, ReleasePackageManifest, compute_request_digest, 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, + game_distribution_theme_admin_status_filter_valid, 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, }; use platform_auth::read_refresh_session_token; use platform_llm::{EDITOR_AGENT_GPT5_MODEL, LlmMessage, LlmRunRequest}; @@ -49,34 +49,40 @@ use shared_contracts::game_distribution::{ 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, - GameDistributionForkSourceResponse, GameDistributionInputMode, GameDistributionLineageNode, - GameDistributionLineageResponse, GameDistributionMyReviewResponse, GameDistributionOrientation, - GameDistributionPublishMetadataSuggestion, GameDistributionPublishMetadataSuggestionRequest, - GameDistributionRatingSummary, GameDistributionReview, GameDistributionReviewsResponse, - GameDistributionSaveReviewRequest, GameDistributionSaveReviewResponse, - GameDistributionSetForkAuthorizationRequest, GameDistributionUpdateGameMetadataRequest, + GameDistributionCreateThemeRequest, GameDistributionCreateVersionRequest, + GameDistributionDerivedResponse, GameDistributionForkAuthorization, GameDistributionForkSource, + GameDistributionForkSourceKind, GameDistributionForkSourceResponse, GameDistributionInputMode, + GameDistributionLineageNode, GameDistributionLineageResponse, GameDistributionMyReviewResponse, + GameDistributionOrientation, GameDistributionPublishMetadataSuggestion, + GameDistributionPublishMetadataSuggestionRequest, GameDistributionRatingSummary, + GameDistributionReview, GameDistributionReviewsResponse, GameDistributionSaveReviewRequest, + GameDistributionSaveReviewResponse, GameDistributionSetForkAuthorizationRequest, + GameDistributionThemeStatus, GameDistributionUpdateGameMetadataRequest, + GameDistributionUpdateThemeRequest, GameDistributionUpsertThemeMemberRequest, GameDistributionVisibility, }; use spacetime_client::{ GameDistributionAdminGameListRecordInput, GameDistributionAdminGameRecord, + GameDistributionAdminThemeListRecordInput, GameDistributionAdminThemeMemberRecord, + GameDistributionAdminThemeMutationRecord, GameDistributionAdminThemeRecord, GameDistributionAdminUserReviewListRecordInput, GameDistributionAdminUserReviewRecord, GameDistributionAdminVersionRecord, GameDistributionApproveRecordInput, GameDistributionCancelVersionRecordInput, GameDistributionCollectGameRecordInput, - GameDistributionDeleteGameRecordInput, GameDistributionDerivedGamesRecord, - GameDistributionForkSourceRecord, GameDistributionGameRecord, - GameDistributionGetGameRecordInput, GameDistributionLineageNodeRecord, - GameDistributionLineageTreeRecord, GameDistributionOwnerGameRecord, - GameDistributionPublicGameListRecordInput, GameDistributionPublicGameRecord, - GameDistributionRatingSummaryRecord, GameDistributionRejectRecordInput, - GameDistributionRestoreRecordInput, GameDistributionReviewGameListRecordInput, - GameDistributionReviewModerationOperationRecord, GameDistributionReviewModerationRecordInput, - GameDistributionSetForkAuthorizationRecordInput, GameDistributionSubmitReviewRecordInput, - GameDistributionSuspendRecordInput, GameDistributionThemeDetailRecord, - GameDistributionThemeReferenceRecord, GameDistributionThemeSummaryRecord, - GameDistributionUncollectGameRecordInput, GameDistributionUnpublishRecordInput, - GameDistributionUpdateMetadataRecordInput, GameDistributionUserReviewRecord, + GameDistributionCreateThemeRecordInput, GameDistributionDeleteGameRecordInput, + GameDistributionDerivedGamesRecord, GameDistributionForkSourceRecord, + GameDistributionGameRecord, GameDistributionGetGameRecordInput, + GameDistributionLineageNodeRecord, GameDistributionLineageTreeRecord, + GameDistributionOwnerGameRecord, GameDistributionPublicGameListRecordInput, + GameDistributionPublicGameRecord, GameDistributionRatingSummaryRecord, + GameDistributionRejectRecordInput, GameDistributionRestoreRecordInput, + GameDistributionReviewGameListRecordInput, GameDistributionReviewModerationOperationRecord, + GameDistributionReviewModerationRecordInput, GameDistributionSetForkAuthorizationRecordInput, + GameDistributionSubmitReviewRecordInput, GameDistributionSuspendRecordInput, + GameDistributionThemeDetailRecord, GameDistributionThemeMemberRemoveRecordInput, + GameDistributionThemeMemberUpsertRecordInput, GameDistributionThemeReferenceRecord, + GameDistributionThemeSummaryRecord, GameDistributionUncollectGameRecordInput, + GameDistributionUnpublishRecordInput, GameDistributionUpdateMetadataRecordInput, + GameDistributionUpdateThemeRecordInput, GameDistributionUserReviewRecord, GameDistributionVersionRecord, SpacetimeClientError, }; use tracing::{debug, info, warn}; @@ -109,6 +115,9 @@ const PACKAGE_UPLOAD_OFFSET_HEADER: &str = "x-genarrative-upload-offset"; const MAX_LIST_LIMIT: u32 = 48; /// 后台游戏管理页全量列表上限,与 spacetime-module 的 admin game list limit 保持同口径。 const MAX_ADMIN_GAME_LIST_LIMIT: u32 = 200; +/// 后台共创主题列表上限,与 spacetime-module 的 admin theme list limit 保持同口径 +/// (事务侧还会再截断一次;这里只是为了日志里的 `limit` 就是真正生效的条数)。 +const MAX_ADMIN_THEME_LIST_LIMIT: u32 = 200; /// 后台作品状态过滤的白名单;`deleted` 表示已软删除的作品。 const ADMIN_GAME_LIST_STATUSES: [&str; 4] = ["published", "unpublished", "suspended", "deleted"]; const MAX_IDEMPOTENCY_KEY_CHARS: usize = 128; @@ -290,6 +299,17 @@ struct ThemeListQuery { cursor: Option, } +/// 后台共创主题列表的查询串:`limit` + `status`,**没有游标**。 +/// +/// 与后台作品列表同口径的 `limit`(缺省与上限 200、超界截断);`status` 白名单是 +/// `all` / `draft` / `published` / `archived`(缺省 = 全量),校验走模块侧那个纯函数, +/// 不在 handler 里再抄一份白名单。 +#[derive(Debug, Deserialize)] +struct AdminThemeListQuery { + limit: Option, + status: Option, +} + /// 作者软删除游戏:CAS 修订号走查询串,删除本身没有请求体。 #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -494,6 +514,23 @@ pub fn router(state: AppState) -> Router { "/admin/api/game-distribution/games/{game_id}/restore", post(admin_restore_game), ) + // 共创主题后台族:与同组其它后台路由共用同一条 `require_admin_auth`(handler 取 + // `Extension`),不自造第二套鉴权。 + // - 创建 / 更新要求 `Idempotency-Key`(同键重放 `replayed: true`,同键不同请求 409); + // - 成员 PUT 靠确定性主键幂等、DELETE 靠「不存在也算成功」,两条都不需要幂等键; + // - 列表含 `draft` / `archived`,无游标(主题是运营维护的小集合)。 + .route( + "/admin/api/game-distribution/themes", + get(admin_list_themes).post(admin_create_theme), + ) + .route( + "/admin/api/game-distribution/themes/{theme_id}", + put(admin_update_theme), + ) + .route( + "/admin/api/game-distribution/themes/{theme_id}/members/{root_game_id}", + put(admin_upsert_theme_member).delete(admin_remove_theme_member), + ) .route_layer(middleware::from_fn_with_state( state.clone(), require_admin_auth, @@ -1722,6 +1759,303 @@ fn themes_page_limit(limit: Option) -> u32 { as u32 } +/// 后台共创主题的 400:带稳定错误码 `THEME_BAD_REQUEST`(与事务侧同一串),不是裸文案。 +/// +/// 两处用它:① `status` 过滤值不在白名单;② 请求体反序列化失败(未知 `status` 枚举值 / 坏 JSON)。 +/// ②尤其重要——不接管的话 axum 会回 422 纯文本,而契约里这一格写的是 400。 +fn theme_bad_request(message: impl Into) -> AppError { + AppError::from_status(StatusCode::BAD_REQUEST) + .with_code(GAME_DISTRIBUTION_THEME_BAD_REQUEST) + .with_message(message) +} + +/// 后台创建主题:`POST /admin/api/game-distribution/themes`。 +/// +/// `theme_id` 由**服务端**生成(请求体里没有这个字段,外部无法指定);`created_by_user_id` 取 +/// admin 会话主体。幂等语义与既有后台写接口一致:同键同摘要 → `replayed: true` 且回库里那一行, +/// 同键不同请求 → 409。 +/// +/// 请求摘要刻意**只覆盖客户端可见的请求体**(不含每次重试都会重新生成的 `theme_id`):否则同一次 +/// 重试会算出不同摘要,把合法的重试变成 409。 +async fn admin_create_theme( + State(state): State, + Extension(ctx): Extension, + Extension(admin): Extension, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result, AppError> { + let Json(payload) = payload.map_err(|_| theme_bad_request("主题请求体不合法"))?; + let idempotency_key = idempotency_key(&headers)?; + let theme_id = format!("theme-{}", Uuid::new_v4().simple()); + let request_digest = compute_request_digest( + &serde_json::to_vec(&payload).map_err(|error| internal(error.to_string()))?, + ); + let status = theme_status_value(payload.status); + let admin_user_id = admin.session().subject.clone(); + let log_theme_id = theme_id.clone(); + let log_status = status.clone(); + let record = state + .spacetime_client() + .create_game_distribution_theme(GameDistributionCreateThemeRecordInput { + theme_id, + name: payload.name, + summary: payload.summary, + badge: payload.badge, + sort_order: payload.sort_order, + status, + admin_user_id: admin_user_id.clone(), + idempotency_key, + request_digest, + now_micros: now_micros(), + }) + .await + .map_err(map_spacetime_error)?; + info!( + request_id = ctx.request_id(), + operation = "game_theme_created", + theme_id = %log_theme_id, + admin_user_id = %admin_user_id, + status = %log_status, + replayed = record.replayed, + elapsed_ms = ctx.elapsed(), + "后台创建共创主题" + ); + Ok(json_success_body( + Some(&ctx), + admin_theme_mutation_payload(&record), + )) +} + +/// 后台整体覆盖主题字段:`PUT /admin/api/game-distribution/themes/{theme_id}`。 +/// +/// `theme_id` 不存在由事务回带码错误(`THEME_NOT_FOUND` → 404);字段非法(空名 / 超长 / 未知状态) +/// → 400。同键不同请求 409,同键重放 `replayed: true`(且不写库、不刷新 `updated_at`)。 +async fn admin_update_theme( + State(state): State, + Extension(ctx): Extension, + Extension(admin): Extension, + headers: HeaderMap, + Path(theme_id): Path, + payload: Result, JsonRejection>, +) -> Result, AppError> { + let Json(payload) = payload.map_err(|_| theme_bad_request("主题请求体不合法"))?; + let idempotency_key = idempotency_key(&headers)?; + // 主题 ID 在路径里,因此摘要带上它是稳定的(重试仍是同一个 ID)。 + let request_digest = compute_request_digest( + &serde_json::to_vec(&(theme_id.as_str(), &payload)) + .map_err(|error| internal(error.to_string()))?, + ); + let status = theme_status_value(payload.status); + let admin_user_id = admin.session().subject.clone(); + let log_theme_id = theme_id.clone(); + let log_status = status.clone(); + let record = state + .spacetime_client() + .update_game_distribution_theme(GameDistributionUpdateThemeRecordInput { + theme_id, + name: payload.name, + summary: payload.summary, + badge: payload.badge, + sort_order: payload.sort_order, + status, + admin_user_id: admin_user_id.clone(), + idempotency_key, + request_digest, + now_micros: now_micros(), + }) + .await + .map_err(map_spacetime_error)?; + info!( + request_id = ctx.request_id(), + operation = "game_theme_updated", + theme_id = %log_theme_id, + admin_user_id = %admin_user_id, + status = %log_status, + replayed = record.replayed, + elapsed_ms = ctx.elapsed(), + "后台更新共创主题" + ); + Ok(json_success_body( + Some(&ctx), + admin_theme_mutation_payload(&record), + )) +} + +/// 后台增 / 改主题成员:`PUT …/themes/{theme_id}/members/{root_game_id}`。 +/// +/// **不要求幂等键**:成员身份完全由路径给出,确定性主键保证「同主题同根一行」,重复调用只更新 +/// `sort_order`(`created_at` 不变)。只允许根作品(非根 → 409);不做「作品必须公开」的前置校验。 +async fn admin_upsert_theme_member( + State(state): State, + Extension(ctx): Extension, + Extension(_admin): Extension, + Path((theme_id, root_game_id)): Path<(String, String)>, + payload: Result, JsonRejection>, +) -> Result, AppError> { + // body 只承载运营权重(`sortOrder` 缺省 0,`{}` 即「不排序」);坏 JSON / 未知字段仍然 400, + // 不静默当成 `{}`——那会把客户端的拼写错误变成一次「看起来成功」的写入。 + let Json(payload) = payload.map_err(|_| theme_bad_request("主题成员请求体不合法"))?; + let log_theme_id = theme_id.clone(); + let log_root_game_id = root_game_id.clone(); + let member = state + .spacetime_client() + .upsert_game_distribution_theme_member(GameDistributionThemeMemberUpsertRecordInput { + theme_id, + root_game_id, + sort_order: payload.sort_order, + now_micros: now_micros(), + }) + .await + .map_err(map_spacetime_error)?; + info!( + request_id = ctx.request_id(), + operation = "game_theme_member_upserted", + theme_id = %log_theme_id, + root_game_id = %log_root_game_id, + sort_order = member.sort_order, + elapsed_ms = ctx.elapsed(), + "后台挂载 / 调整共创主题成员" + ); + Ok(json_success_body( + Some(&ctx), + admin_theme_member_payload(&member), + )) +} + +/// 后台移除主题成员:`DELETE …/themes/{theme_id}/members/{root_game_id}`。 +/// +/// **不要求幂等键**:按确定性主键删除,成员不存在也算成功(200)。响应刻意不含「之前存不存在」, +/// 重复调用的响应因此逐字节相同。主题不存在仍是 404(那是路径里的主题 ID 错了)。 +async fn admin_remove_theme_member( + State(state): State, + Extension(ctx): Extension, + Extension(_admin): Extension, + Path((theme_id, root_game_id)): Path<(String, String)>, +) -> Result, AppError> { + let log_theme_id = theme_id.clone(); + let log_root_game_id = root_game_id.clone(); + state + .spacetime_client() + .remove_game_distribution_theme_member(GameDistributionThemeMemberRemoveRecordInput { + theme_id: theme_id.clone(), + root_game_id: root_game_id.clone(), + }) + .await + .map_err(map_spacetime_error)?; + info!( + request_id = ctx.request_id(), + operation = "game_theme_member_removed", + theme_id = %log_theme_id, + root_game_id = %log_root_game_id, + elapsed_ms = ctx.elapsed(), + "后台移除共创主题成员" + ); + // 幂等:删掉的与「本来就没有」回同一个形状,客户端不可能据此推断成员是否曾经存在。 + Ok(json_success_body( + Some(&ctx), + json!({ "themeId": theme_id, "rootGameId": root_game_id }), + )) +} + +/// 后台主题列表:`GET /admin/api/game-distribution/themes?limit=&status=`(含 `draft` / `archived`)。 +/// +/// `status` 非法(不在 `all` / `draft` / `published` / `archived` 里)→ 400,由模块侧的纯函数判定; +/// 事务里还会再判一次,保证非 HTTP 调用方也不会拿到一份「悄悄按全量返回」的列表。 +async fn admin_list_themes( + State(state): State, + Extension(ctx): Extension, + Extension(_admin): Extension, + Query(query): Query, +) -> Result, AppError> { + let limit = query + .limit + .unwrap_or(MAX_ADMIN_THEME_LIST_LIMIT) + .min(MAX_ADMIN_THEME_LIST_LIMIT); + let status = normalize_optional(query.status); + if let Some(status) = status.as_deref() { + if !game_distribution_theme_admin_status_filter_valid(status) { + return Err(theme_bad_request( + "后台主题状态过滤只支持 all / draft / published / archived", + )); + } + } + let themes = state + .spacetime_client() + .list_admin_game_distribution_themes(GameDistributionAdminThemeListRecordInput { + status: status.clone(), + limit, + }) + .await + .map_err(map_spacetime_error)?; + info!( + request_id = ctx.request_id(), + operation = "admin_game_themes_listed", + themes = themes.len(), + limit, + status = status.as_deref().unwrap_or("all"), + elapsed_ms = ctx.elapsed(), + "后台读取共创主题列表" + ); + Ok(json_success_body(Some(&ctx), admin_themes_payload(&themes))) +} + +/// DTO 枚举 → 线上字符串:只此一处转换(`serde` 的 `camelCase` 与模块侧白名单同值,有单测钉住)。 +fn theme_status_value(status: GameDistributionThemeStatus) -> String { + match status { + GameDistributionThemeStatus::Draft => "draft", + GameDistributionThemeStatus::Published => "published", + GameDistributionThemeStatus::Archived => "archived", + } + .to_string() +} + +/// 后台主题条目:`{ themeId, name, summary, badge, sortOrder, status, memberCount, createdAt, updatedAt }`。 +/// +/// `memberCount` 是**成员行总数**(含当前对外不可见的成员):后台没有「不该被探测」的顾虑, +/// 运营需要知道这个主题挂了几行;公开投影里的同名键则是当前可见成员数。 +fn admin_theme_payload(theme: &GameDistributionAdminThemeRecord) -> Value { + json!({ + "themeId": theme.theme_id, + "name": theme.name, + "summary": theme.summary, + "badge": theme.badge, + "sortOrder": theme.sort_order, + "status": theme.status, + "memberCount": theme.member_count, + "createdAt": theme.created_at, + "updatedAt": theme.updated_at, + }) +} + +/// 后台主题列表响应:`{ themes: [...] }`(**没有** `nextCursor`:这条路径不分页)。 +fn admin_themes_payload(themes: &[GameDistributionAdminThemeRecord]) -> Value { + json!({ + "themes": themes.iter().map(admin_theme_payload).collect::>(), + }) +} + +/// 后台创建 / 更新主题的响应:`{ theme, replayed }`。 +/// +/// `replayed` 必须原样发出:客户端据此区分「这次真的写了」与「同键重放,库里没动」。 +fn admin_theme_mutation_payload(record: &GameDistributionAdminThemeMutationRecord) -> Value { + json!({ + "theme": admin_theme_payload(&record.theme), + "replayed": record.replayed, + }) +} + +/// 后台成员写入的响应:`{ themeId, rootGameId, sortOrder, createdAt }`。 +/// +/// `createdAt` 是**首次挂载**的时间:重复 PUT 只改 `sortOrder`,不会刷新它。 +fn admin_theme_member_payload(member: &GameDistributionAdminThemeMemberRecord) -> Value { + json!({ + "themeId": member.theme_id, + "rootGameId": member.root_game_id, + "sortOrder": member.sort_order, + "createdAt": member.created_at, + }) +} + /// 读接口的「不可读即 404」:族谱与衍生列表的锚点必须公开可读,否则按「不存在」处理。 /// /// 抽成函数是为了让这条映射可被单测钉住(不可读 → 404),而不是散落在两个 handler 里。 @@ -8585,4 +8919,337 @@ mod tests { "公开侧 404 文案含「不存在」,多写「状态」也不会被抢到 409" ); } + + /// 领域错误类型产出的文案必须被 HTTP 映射认出来:两半契约(`module-game-distribution` 写码、 + /// api-server 映射状态码)在这里缝合。任何一侧改字都会红。 + #[test] + fn domain_theme_errors_map_to_the_documented_http_status_codes() { + use module_game_distribution::GameDistributionError; + let cases = [ + ( + GameDistributionError::ThemeNotFound, + StatusCode::NOT_FOUND, + GAME_DISTRIBUTION_THEME_NOT_FOUND, + ), + ( + GameDistributionError::ThemeBadRequest { + reason: "主题名不能为空".to_string(), + }, + StatusCode::BAD_REQUEST, + GAME_DISTRIBUTION_THEME_BAD_REQUEST, + ), + ( + GameDistributionError::ThemeIdempotencyConflict, + StatusCode::CONFLICT, + GAME_DISTRIBUTION_THEME_IDEMPOTENCY_CONFLICT, + ), + ( + GameDistributionError::ThemeMemberNotRoot { + game_id: "game_child".to_string(), + }, + StatusCode::CONFLICT, + GAME_DISTRIBUTION_THEME_MEMBER_NOT_ROOT, + ), + ( + GameDistributionError::ThemeMemberGameNotFound { + game_id: "game_missing".to_string(), + }, + StatusCode::NOT_FOUND, + GAME_DISTRIBUTION_THEME_MEMBER_GAME_NOT_FOUND, + ), + ]; + for (error, status, code) in cases { + let mapped = map_spacetime_error(SpacetimeClientError::Procedure(error.to_string())); + assert_eq!(mapped.status_code(), status, "{error}"); + assert_eq!(mapped.code(), code, "{error}"); + } + } + + /// DTO 的 `status` 枚举取值必须与模块侧白名单**逐一相等**:枚举是 HTTP 入参的唯一来源, + /// 白名单是事务落库的唯一判据,两者一旦分叉就会出现「请求收下了、落库判非法」的 500 级事故。 + #[test] + fn theme_status_values_match_the_module_whitelist() { + let mut values = [ + theme_status_value(GameDistributionThemeStatus::Draft), + theme_status_value(GameDistributionThemeStatus::Published), + theme_status_value(GameDistributionThemeStatus::Archived), + ]; + values.sort(); + let mut whitelist = module_game_distribution::GAME_DISTRIBUTION_THEME_STATUSES; + whitelist.sort(); + assert_eq!(values, whitelist); + } + + /// 后台主题 payload:键集合与契约一一对应(`memberCount` 是成员行总数),列表**不带** + /// `nextCursor`(这条路径不分页),写接口的 `replayed` 必须原样发出。 + #[test] + fn admin_theme_payloads_match_the_documented_shape() { + let record = GameDistributionAdminThemeRecord { + theme_id: "theme_1".to_string(), + name: "共创主题".to_string(), + summary: "简介".to_string(), + badge: "精选".to_string(), + sort_order: 7, + status: "draft".to_string(), + member_count: 3, + created_at: "2026-10-06T00:00:00Z".to_string(), + updated_at: "2026-10-06T01:00:00Z".to_string(), + }; + let payload = admin_theme_payload(&record); + let mut keys = payload + .as_object() + .expect("主题条目必须是对象") + .keys() + .cloned() + .collect::>(); + keys.sort(); + assert_eq!( + keys, + vec![ + "badge", + "createdAt", + "memberCount", + "name", + "sortOrder", + "status", + "summary", + "themeId", + "updatedAt" + ] + ); + // 后台不套公开可见性过滤:`draft` 原样出现(这正是「后台列表含 draft」的可断言部分)。 + assert_eq!(payload["status"], "draft"); + assert_eq!(payload["memberCount"], 3); + assert_eq!(payload["sortOrder"], 7); + + // 列表:只有 `themes` 一个键(无游标),且逐条形状与单条一致。 + let list = admin_themes_payload(std::slice::from_ref(&record)); + let mut list_keys = list + .as_object() + .expect("列表必须是对象") + .keys() + .cloned() + .collect::>(); + list_keys.sort(); + assert_eq!(list_keys, vec!["themes"]); + assert_eq!(list["themes"][0], payload); + + // 写接口:`theme` + `replayed`;重放与新建的差异只在 `replayed`。 + let mutation = admin_theme_mutation_payload(&GameDistributionAdminThemeMutationRecord { + theme: record.clone(), + replayed: true, + }); + assert_eq!(mutation["theme"], payload); + assert_eq!(mutation["replayed"], true); + + // 成员写入:不含「之前存不存在」,因此重复 PUT / DELETE 的响应逐字节相同。 + let member = admin_theme_member_payload(&GameDistributionAdminThemeMemberRecord { + theme_id: "theme_1".to_string(), + root_game_id: "game_1".to_string(), + sort_order: 2, + created_at: "2026-10-06T00:00:00Z".to_string(), + }); + let mut member_keys = member + .as_object() + .expect("成员条目必须是对象") + .keys() + .cloned() + .collect::>(); + member_keys.sort(); + assert_eq!( + member_keys, + vec!["createdAt", "rootGameId", "sortOrder", "themeId"] + ); + } + + /// `theme_bad_request` 必须带稳定码 `THEME_BAD_REQUEST`:与事务侧同一串,客户端可据此分支。 + #[test] + fn theme_bad_request_carries_the_shared_machine_code() { + let error = theme_bad_request("主题名不能为空"); + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + assert_eq!(error.code(), GAME_DISTRIBUTION_THEME_BAD_REQUEST); + } + + /// 五条后台主题路由未带 admin 会话一律 401(与同组既有后台路由同码同形):鉴权复用既有的 + /// `require_admin_auth`,不自造第二套。 + #[tokio::test] + async fn admin_theme_routes_require_admin_authentication() { + use axum::http::Request; + use tower::ServiceExt; + let app = crate::app::build_router( + AppState::new(crate::config::AppConfig { + admin_username: Some("theme-owner".into()), + admin_password: Some("theme-test-password".into()), + ..Default::default() + }) + .expect("state should build"), + ); + for (method, uri) in [ + ("GET", "/admin/api/game-distribution/themes"), + ("POST", "/admin/api/game-distribution/themes"), + ("PUT", "/admin/api/game-distribution/themes/theme_1"), + ( + "PUT", + "/admin/api/game-distribution/themes/theme_1/members/game_1", + ), + ( + "DELETE", + "/admin/api/game-distribution/themes/theme_1/members/game_1", + ), + ] { + let response = app + .clone() + .oneshot( + Request::builder() + .method(method) + .uri(uri) + .header(shared_contracts::api::API_RESPONSE_ENVELOPE_HEADER, "1") + .body(Body::empty()) + .expect("请求"), + ) + .await + .expect("路由响应"); + assert_eq!( + response.status(), + StatusCode::UNAUTHORIZED, + "{method} {uri}" + ); + let body = axum::body::to_bytes(response.into_body(), 32_768) + .await + .unwrap(); + let body: Value = serde_json::from_slice(&body).expect("必须是平台信封 JSON"); + assert_eq!(body["ok"], false, "{method} {uri}"); + assert_eq!(body["error"]["code"], "UNAUTHORIZED", "{method} {uri}"); + } + } + + /// 后台主题 handler 的 400 路径:坏 JSON / 缺必填字段 / 未知 `status` 枚举值 / 非法 `status` + /// 过滤值。全部是**平台信封 + 稳定码**,绝不是 axum 默认的 422 纯文本。 + /// + /// 这些分支都在触达 SpacetimeDB 之前返回,因此在没有真库的 host 测试里可断言。 + #[tokio::test] + async fn admin_theme_handlers_reject_malformed_input_with_theme_bad_request() { + use axum::http::Request; + use shared_contracts::admin::{AdminAccountRole, AdminSessionPayload}; + use tower::ServiceExt; + let admin = AuthenticatedAdmin::new(AdminSessionPayload { + subject: "authenticated-admin".into(), + username: "theme-admin".into(), + display_name: "主题管理员".into(), + roles: vec!["admin".into()], + account_role: AdminAccountRole::Owner, + tab_permissions: vec![], + action_permissions: vec![], + issued_at: "2026-10-01T00:00:00Z".into(), + expires_at: "2026-10-02T00:00:00Z".into(), + }); + let app = Router::new() + .route("/themes", get(admin_list_themes).post(admin_create_theme)) + .route("/themes/{theme_id}", put(admin_update_theme)) + .route( + "/themes/{theme_id}/members/{root_game_id}", + put(admin_upsert_theme_member).delete(admin_remove_theme_member), + ) + .layer(Extension(admin)) + // 与生产同一套 request context 中间件:错误信封的 `ok` / `meta` 由它决定。 + .layer(middleware::from_fn( + crate::request_context::attach_request_context, + )) + .with_state(AppState::new(crate::config::AppConfig::default()).expect("state")); + + for (method, uri, body) in [ + // 未知 status 取值:DTO 枚举在反序列化阶段就挡下(不得落到 axum 422)。 + ("POST", "/themes", r#"{"name":"主题","status":"hidden"}"#), + ("POST", "/themes", "{"), + ("POST", "/themes", r#"{"summary":"缺少 name"}"#), + ( + "PUT", + "/themes/theme_1", + r#"{"name":"主题","status":"draft"}"#, + ), + ("PUT", "/themes/theme_1/members/game_1", "{"), + ] { + let response = app + .clone() + .oneshot( + Request::builder() + .method(method) + .uri(uri) + .header(header::CONTENT_TYPE, "application/json") + .header("idempotency-key", "theme-test-key") + .header(shared_contracts::api::API_RESPONSE_ENVELOPE_HEADER, "1") + .body(Body::from(body)) + .expect("请求"), + ) + .await + .expect("路由响应"); + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "{method} {uri} {body}" + ); + let bytes = axum::body::to_bytes(response.into_body(), 32_768) + .await + .unwrap(); + let text = String::from_utf8(bytes.to_vec()).expect("响应必须是 UTF-8"); + assert!( + !text.contains("Failed to deserialize"), + "{method} {uri} 不得返回框架的纯文本拒绝:{text}" + ); + let envelope: Value = serde_json::from_str(&text).expect("必须是平台信封 JSON"); + assert_eq!( + envelope["error"]["code"], + Value::String(GAME_DISTRIBUTION_THEME_BAD_REQUEST.into()), + "{method} {uri} {body}" + ); + } + + // 非法 status 过滤值:在打数据库之前就挡住(否则会变成 5xx,把 400 契约掩盖掉)。 + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/themes?status=hidden") + .header(shared_contracts::api::API_RESPONSE_ENVELOPE_HEADER, "1") + .body(Body::empty()) + .expect("请求"), + ) + .await + .expect("路由响应"); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let bytes = axum::body::to_bytes(response.into_body(), 32_768) + .await + .unwrap(); + let envelope: Value = serde_json::from_str(&String::from_utf8(bytes.to_vec()).unwrap()) + .expect("必须是平台信封 JSON"); + assert_eq!( + envelope["error"]["code"], + Value::String(GAME_DISTRIBUTION_THEME_BAD_REQUEST.into()) + ); + + // 缺 `Idempotency-Key` 沿用既有写接口的既有口径(`BAD_REQUEST`),不为主题另开一套。 + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/themes") + .header(header::CONTENT_TYPE, "application/json") + .header(shared_contracts::api::API_RESPONSE_ENVELOPE_HEADER, "1") + .body(Body::from(r#"{"name":"主题"}"#)) + .expect("请求"), + ) + .await + .expect("路由响应"); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let bytes = axum::body::to_bytes(response.into_body(), 32_768) + .await + .unwrap(); + let envelope: Value = serde_json::from_str(&String::from_utf8(bytes.to_vec()).unwrap()) + .expect("必须是平台信封 JSON"); + assert_eq!( + envelope["error"]["code"], + Value::String("BAD_REQUEST".into()) + ); + } } diff --git a/server-rs/crates/module-game-distribution/src/domain.rs b/server-rs/crates/module-game-distribution/src/domain.rs index 7ba0280f7..0837c0605 100644 --- a/server-rs/crates/module-game-distribution/src/domain.rs +++ b/server-rs/crates/module-game-distribution/src/domain.rs @@ -437,4 +437,55 @@ mod fork_authorization_tests { ); } } + + /// 共创主题同理:后台写接口的错误码同样靠前缀被 HTTP 层识别,文案格式一变就静默退化成通用 4xx。 + /// + /// 这里钉的是**领域侧**的半张表(字面量),另一半(`shared-contracts` 的常量 + 状态码)由 + /// api-server 的映射测试钉;两半都对着同一组字符串,因此任何一侧改字都会红。 + #[test] + fn theme_errors_are_prefixed_with_their_machine_code() { + let cases = [ + (GameDistributionError::ThemeNotFound, "THEME_NOT_FOUND"), + ( + GameDistributionError::ThemeBadRequest { + reason: "主题名不能为空".to_string(), + }, + "THEME_BAD_REQUEST", + ), + ( + GameDistributionError::ThemeIdempotencyConflict, + "THEME_IDEMPOTENCY_CONFLICT", + ), + ( + GameDistributionError::ThemeMemberNotRoot { + game_id: "game_child".to_string(), + }, + "THEME_MEMBER_NOT_ROOT", + ), + ( + GameDistributionError::ThemeMemberGameNotFound { + game_id: "game_missing".to_string(), + }, + "THEME_MEMBER_GAME_NOT_FOUND", + ), + ]; + for (error, code) in cases { + let message = error.to_string(); + assert!( + message.starts_with(code), + "expected {code} prefix, got {message}" + ); + // 前缀后面必须真的跟冒号:api-server 用 `split(':').next()` 取码,缺了冒号就会把整句 + // 文案当成码,落到未登记分支变成 `THEME_ERROR`。 + assert!( + message.starts_with(&format!("{code}:")), + "expected {code} to be followed by ':', got {message}" + ); + } + // 空名原因要原样透出:400 只有一个状态码,区分「空名」与「超长」只有靠这句。 + let empty_name = GameDistributionError::ThemeBadRequest { + reason: "主题名不能为空".to_string(), + }; + assert!(empty_name.to_string().contains("主题名不能为空")); + } } diff --git a/server-rs/crates/module-game-distribution/src/errors.rs b/server-rs/crates/module-game-distribution/src/errors.rs index 12bb8d7f4..56a00779c 100644 --- a/server-rs/crates/module-game-distribution/src/errors.rs +++ b/server-rs/crates/module-game-distribution/src/errors.rs @@ -79,6 +79,31 @@ pub enum GameDistributionError { ForkSourceVersionMismatch, /// 共创授权:已存在的作品不能再声明改编来源。 ForkDeclarationOnExistingGame, + /// 共创主题:后台按 `theme_id` 找不到主题。 + /// + /// 与公开侧的「主题不存在 / 未发布」文案**不是**同一条:公开侧刻意不带错误码,也不确认未发布 + /// 主题的存在性(见 `spacetime-module` 的 `GAME_DISTRIBUTION_THEME_NOT_FOUND_MESSAGE`)。 + ThemeNotFound, + /// 共创主题:请求参数非法(空名 / 超长 / 状态不在白名单)。 + /// + /// 带 `reason` 而不是固定文案:空名与「名字超过 40 个字符」都要让运营一眼看懂改哪里,而 400 + /// 这一个状态码不足以区分它们。 + ThemeBadRequest { + reason: String, + }, + /// 共创主题:同 `Idempotency-Key` 不同请求摘要。 + /// + /// 与通用的「幂等键对应的请求摘要不一致」是同一条判定,只是换成主题自己的码:客户端可以按码 + /// 分支,而不是从 409 的文案里猜这是哪一类冲突。 + ThemeIdempotencyConflict, + /// 共创主题:目标作品有血缘行(不是根作品),不能作为成员。 + ThemeMemberNotRoot { + game_id: String, + }, + /// 共创主题:作为成员的目标作品不存在。 + ThemeMemberGameNotFound { + game_id: String, + }, } impl fmt::Display for GameDistributionError { @@ -129,6 +154,23 @@ impl fmt::Display for GameDistributionError { .write_str("FORK_SOURCE_VERSION_MISMATCH: 改编来源版本不是该作品的当前公开版本"), Self::ForkDeclarationOnExistingGame => formatter .write_str("FORK_DECLARATION_ON_EXISTING_GAME: 已存在的作品不能再声明改编来源"), + // 共创主题同理:文案以错误码开头,HTTP 层按码映射(不做子串猜测)。 + Self::ThemeNotFound => formatter.write_str("THEME_NOT_FOUND: 主题不存在"), + Self::ThemeBadRequest { reason } => { + write!(formatter, "THEME_BAD_REQUEST: {reason}") + } + Self::ThemeIdempotencyConflict => formatter + .write_str("THEME_IDEMPOTENCY_CONFLICT: 同一个 Idempotency-Key 对应了不同的请求"), + Self::ThemeMemberNotRoot { game_id } => write!( + formatter, + "THEME_MEMBER_NOT_ROOT: 作品 {game_id} 有血缘行(不是根作品),不能作为主题成员" + ), + Self::ThemeMemberGameNotFound { game_id } => { + write!( + formatter, + "THEME_MEMBER_GAME_NOT_FOUND: 作品 {game_id} 不存在" + ) + } } } } diff --git a/server-rs/crates/module-game-distribution/src/lib.rs b/server-rs/crates/module-game-distribution/src/lib.rs index fde5216fb..c0b46c73e 100644 --- a/server-rs/crates/module-game-distribution/src/lib.rs +++ b/server-rs/crates/module-game-distribution/src/lib.rs @@ -59,13 +59,16 @@ pub use release::{ 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, + GAME_DISTRIBUTION_THEME_ADMIN_STATUS_ALL, GAME_DISTRIBUTION_THEME_BADGE_MAX_CHARS, + GAME_DISTRIBUTION_THEME_NAME_MAX_CHARS, 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, GAME_DISTRIBUTION_THEME_SUMMARY_MAX_CHARS, 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, + encode_game_distribution_theme_cursor, game_distribution_theme_admin_status_filter_valid, + 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, + game_distribution_theme_text_violation, 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 index 188f7fbe6..408e50091 100644 --- a/server-rs/crates/module-game-distribution/src/theme.rs +++ b/server-rs/crates/module-game-distribution/src/theme.rs @@ -32,6 +32,71 @@ pub fn game_distribution_theme_status_valid(status: &str) -> bool { GAME_DISTRIBUTION_THEME_STATUSES.contains(&status) } +/// 后台列表 `status` 过滤里代表「全量」的取值。 +/// +/// 与 `GAME_DISTRIBUTION_THEME_STATUSES` 并列而不是塞进白名单:`all` 是**过滤视图**的取值, +/// 不是主题行上的合法状态(没有任何一行的 `status` 会是 `all`);混进去会让 +/// [`game_distribution_theme_status_valid`] 反过来接受一个落不进表的字符串。 +pub const GAME_DISTRIBUTION_THEME_ADMIN_STATUS_ALL: &str = "all"; + +/// 后台列表 `status` 过滤值是否合法:`all` + 三个真实状态。 +/// +/// 单一实现,避免「api-server 放行、事务里当全量」这类两侧各写一遍白名单的漂移;非法值由 +/// api-server 映射 400(`THEME_BAD_REQUEST`)。 +pub fn game_distribution_theme_admin_status_filter_valid(status: &str) -> bool { + status == GAME_DISTRIBUTION_THEME_ADMIN_STATUS_ALL + || game_distribution_theme_status_valid(status) +} + +/// 主题名长度上限(字符数,不是字节数)。 +/// +/// 设计文档(§3.10.8 / 里程碑 E)只写了「`name` 必填非空」,没有给上限。这里取 40:主题名与 +/// 作品标题(`title` 40)处在同一层级——都是卡片上一行标题——沿用同一量级可以让「一屏能放下 +/// 什么」在两条列表里一致;再长会被前端截断,运营写了也白写。 +pub const GAME_DISTRIBUTION_THEME_NAME_MAX_CHARS: usize = 40; + +/// 主题简介长度上限(字符数)。 +/// +/// 设计文档没写上限。取 200:主题简介是主题页头部的一段引导文案,比作品简介(120)略长 +/// (主题是运营叙事,需要多说一句「这一组作品为什么放在一起」),又远小于作品详细介绍 +/// (2000)——它不是正文,多出的字数只会把成员清单挤出首屏。 +pub const GAME_DISTRIBUTION_THEME_SUMMARY_MAX_CHARS: usize = 200; + +/// 主题角标长度上限(字符数)。 +/// +/// 设计文档没写上限。取 16:角标是卡片角上的短标签(「精选」「编辑推荐」「新春」),16 个字符 +/// 足够表达一个标签,同时短到不可能被当成第二段正文——真要写长文案的位置是 `summary`。 +pub const GAME_DISTRIBUTION_THEME_BADGE_MAX_CHARS: usize = 16; + +/// 后台主题文本字段的校验:合法返回 `None`,否则返回可直接拼进 `THEME_BAD_REQUEST:` 的原因。 +/// +/// 规则本身(长度上限、空名)只写在这里一处:api-server 与事务都调它,因此「HTTP 挡住的」 +/// 与「落库挡住的」永远是同一套判据。三个字段都先 `trim` 再判定:一串空格与空名同罪,调用方 +/// 忘掉 trim 也不会把「只有空白的主题名」写进库(长度也按 trim 后的字符数算,尾部空格不占额度)。 +pub fn game_distribution_theme_text_violation( + name: &str, + summary: &str, + badge: &str, +) -> Option<&'static str> { + let name = name.trim(); + let summary = summary.trim(); + let badge = badge.trim(); + let name_chars = name.chars().count(); + if name_chars == 0 { + return Some("主题名不能为空"); + } + if name_chars > GAME_DISTRIBUTION_THEME_NAME_MAX_CHARS { + return Some("主题名不能超过 40 个字符"); + } + if summary.chars().count() > GAME_DISTRIBUTION_THEME_SUMMARY_MAX_CHARS { + return Some("主题简介不能超过 200 个字符"); + } + if badge.chars().count() > GAME_DISTRIBUTION_THEME_BADGE_MAX_CHARS { + return Some("主题角标不能超过 16 个字符"); + } + None +} + /// 主题是否应出现在公开投影(公开列表 / 公开详情 / 作品详情的 `themes`)里。 /// /// 等价于 `status == "published"`:`draft` 与 `archived` 一律不可见(后台仍可见全量)。与 @@ -347,6 +412,88 @@ mod tests { assert!(!game_distribution_theme_root_acceptable(true)); } + /// 后台过滤白名单 = `all` + 三个真实状态;`all` 自己**不是**能落进主题行的状态。 + #[test] + fn admin_status_filter_accepts_all_plus_real_statuses_only() { + assert!(game_distribution_theme_admin_status_filter_valid( + GAME_DISTRIBUTION_THEME_ADMIN_STATUS_ALL + )); + for status in GAME_DISTRIBUTION_THEME_STATUSES { + assert!( + game_distribution_theme_admin_status_filter_valid(status), + "{status}" + ); + } + for status in ["", "ALL", "deleted", "published ", " archived"] { + assert!( + !game_distribution_theme_admin_status_filter_valid(status), + "{status}" + ); + } + // 两条白名单刻意分开:`all` 只服务过滤视图,混进状态白名单会让它被当成可落库的值。 + assert!(!game_distribution_theme_status_valid( + GAME_DISTRIBUTION_THEME_ADMIN_STATUS_ALL + )); + } + + /// 后台主题文本校验:空名 / 超长各有明确原因;边界取「刚好合法」,且按**字符**而不是字节计数。 + #[test] + fn theme_text_validation_pins_limits_at_the_boundary() { + assert_eq!( + game_distribution_theme_text_violation("", "", ""), + Some("主题名不能为空") + ); + assert_eq!( + game_distribution_theme_text_violation(" ", "", ""), + Some("主题名不能为空"), + "调用方传 trim 后的值:一串空格与空名同罪" + ); + + // 40 个中文字符 = 120 字节:按字节计数的实现会在这里红。 + let max_name = "主".repeat(GAME_DISTRIBUTION_THEME_NAME_MAX_CHARS); + assert_eq!( + game_distribution_theme_text_violation(&max_name, "", ""), + None + ); + let too_long_name = "主".repeat(GAME_DISTRIBUTION_THEME_NAME_MAX_CHARS + 1); + assert_eq!( + game_distribution_theme_text_violation(&too_long_name, "", ""), + Some("主题名不能超过 40 个字符") + ); + + let max_summary = "简".repeat(GAME_DISTRIBUTION_THEME_SUMMARY_MAX_CHARS); + assert_eq!( + game_distribution_theme_text_violation("主题", &max_summary, ""), + None + ); + assert_eq!( + game_distribution_theme_text_violation("主题", &format!("{max_summary}简"), ""), + Some("主题简介不能超过 200 个字符") + ); + + let max_badge = "标".repeat(GAME_DISTRIBUTION_THEME_BADGE_MAX_CHARS); + assert_eq!( + game_distribution_theme_text_violation("主题", "", &max_badge), + None + ); + assert_eq!( + game_distribution_theme_text_violation("主题", "", &format!("{max_badge}标")), + Some("主题角标不能超过 16 个字符") + ); + + // 三个上限各自独立:简介/角标超长不会因为名字合法而被放过。 + assert_eq!( + game_distribution_theme_text_violation("主题", &max_summary, &format!("{max_badge}标")), + Some("主题角标不能超过 16 个字符") + ); + + // 长度按 trim 后的字符数算:40 个字 + 首尾空格仍然合法(空格不占额度)。 + assert_eq!( + game_distribution_theme_text_violation(&format!(" {max_name} "), "", ""), + None + ); + } + /// `limit` 语义:`0` 取默认 20(不是「返回 0 条」),超界截断到 50(不是报错)。 #[test] fn page_limit_defaults_zero_and_truncates_overflow() { diff --git a/server-rs/crates/spacetime-client/src/active.rs b/server-rs/crates/spacetime-client/src/active.rs index 6350262ec..188e582ed 100644 --- a/server-rs/crates/spacetime-client/src/active.rs +++ b/server-rs/crates/spacetime-client/src/active.rs @@ -23,10 +23,11 @@ mod error_reports; pub mod external_api_key; pub mod game_distribution; pub use game_distribution::{ - GameDistributionAdminGameListRecordInput, GameDistributionAdminUserReviewListRecordInput, - GameDistributionApproveRecordInput, GameDistributionCancelVersionRecordInput, - GameDistributionCollectGameRecordInput, GameDistributionConfirmPackageRecordInput, - GameDistributionConfirmProjectBundleRecordInput, GameDistributionCreateGameRecordInput, + GameDistributionAdminGameListRecordInput, GameDistributionAdminThemeListRecordInput, + GameDistributionAdminUserReviewListRecordInput, GameDistributionApproveRecordInput, + GameDistributionCancelVersionRecordInput, GameDistributionCollectGameRecordInput, + GameDistributionConfirmPackageRecordInput, GameDistributionConfirmProjectBundleRecordInput, + GameDistributionCreateGameRecordInput, GameDistributionCreateThemeRecordInput, GameDistributionCreateVersionRecordInput, GameDistributionDeleteGameRecordInput, GameDistributionFailUploadRecordInput, GameDistributionGetGameRecordInput, GameDistributionOwnerGameListRecordInput, GameDistributionPlayCountIncrementRecordInput, @@ -34,8 +35,9 @@ pub use game_distribution::{ GameDistributionRestoreRecordInput, GameDistributionReviewGameListRecordInput, GameDistributionReviewModerationRecordInput, GameDistributionSetForkAuthorizationRecordInput, GameDistributionSubmitReviewRecordInput, GameDistributionSuspendRecordInput, + GameDistributionThemeMemberRemoveRecordInput, GameDistributionThemeMemberUpsertRecordInput, GameDistributionUncollectGameRecordInput, GameDistributionUnpublishRecordInput, - GameDistributionUpdateMetadataRecordInput, + GameDistributionUpdateMetadataRecordInput, GameDistributionUpdateThemeRecordInput, }; #[path = "active/external_generation.rs"] pub mod external_generation; diff --git a/server-rs/crates/spacetime-client/src/active/mapper.rs b/server-rs/crates/spacetime-client/src/active/mapper.rs index 00d25537e..9f8ce922f 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper.rs @@ -96,11 +96,13 @@ pub use self::external_generation::{ ExternalGenerationQueueStatsRecord, }; pub use self::game_distribution::{ - GameDistributionAdminGameRecord, GameDistributionAdminUserReviewDetailRecord, - GameDistributionAdminUserReviewListRecord, GameDistributionAdminUserReviewRecord, - GameDistributionAdminVersionRecord, GameDistributionCollectionMutationRecord, - GameDistributionDerivedGamesRecord, GameDistributionForkSourceRecord, - GameDistributionGameRecord, GameDistributionLineageNodeRecord, GameDistributionLineageRecord, + GameDistributionAdminGameRecord, GameDistributionAdminThemeMemberRecord, + GameDistributionAdminThemeMutationRecord, GameDistributionAdminThemeRecord, + GameDistributionAdminUserReviewDetailRecord, GameDistributionAdminUserReviewListRecord, + GameDistributionAdminUserReviewRecord, GameDistributionAdminVersionRecord, + GameDistributionCollectionMutationRecord, GameDistributionDerivedGamesRecord, + GameDistributionForkSourceRecord, GameDistributionGameRecord, + GameDistributionLineageNodeRecord, GameDistributionLineageRecord, GameDistributionLineageTreeRecord, GameDistributionOwnerGameRecord, GameDistributionPublicGameRecord, GameDistributionRatingSummaryRecord, GameDistributionReviewGameListRecord, GameDistributionReviewGameRecord, @@ -173,8 +175,11 @@ pub(crate) use self::external_generation::{ }; pub(crate) use self::game_distribution::{ ensure_admin_user_review_result, ensure_user_review_result, map_admin_user_review, - map_game_distribution_admin_game_list_result, map_game_distribution_collection_list_result, - map_game_distribution_collection_mutation_result, + map_game_distribution_admin_game_list_result, map_game_distribution_admin_theme_list_result, + map_game_distribution_admin_theme_member_remove_result, + map_game_distribution_admin_theme_member_upsert_result, + map_game_distribution_admin_theme_mutation_result, + map_game_distribution_collection_list_result, map_game_distribution_collection_mutation_result, map_game_distribution_collection_state_result, map_game_distribution_derived_games_result, map_game_distribution_fork_source_result, map_game_distribution_game_result, map_game_distribution_lineage_result, map_game_distribution_owner_game_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 62ef700ac..c60fd76dc 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 @@ -385,6 +385,40 @@ pub struct GameDistributionThemeDetailRecord { pub roots: Vec, } +/// 后台主题行记录:公开投影看不到的列(运营排序、状态、时间戳)与**成员行总数**都在这里。 +/// +/// `member_count` 与公开记录的 `member_count` 语义**刻意不同**:这里是成员行总数(含当前对外 +/// 不可见的成员),公开侧是当前可见成员数。后台没有「不该被探测」的顾虑,运营恰恰需要知道 +/// 自己挂了几行;公开侧报总数则会泄露「存在草稿 / 被下架成员」。 +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct GameDistributionAdminThemeRecord { + pub theme_id: String, + pub name: String, + pub summary: String, + pub badge: String, + pub sort_order: i64, + pub status: String, + pub member_count: u64, + pub created_at: String, + pub updated_at: String, +} + +/// 后台创建 / 更新主题后的权威投影:`replayed` 为 true 表示命中同键幂等收据(本次没有写库)。 +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct GameDistributionAdminThemeMutationRecord { + pub theme: GameDistributionAdminThemeRecord, + pub replayed: bool, +} + +/// 后台主题成员行记录(PUT 落成的那一行)。 +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct GameDistributionAdminThemeMemberRecord { + pub theme_id: String, + pub root_game_id: String, + pub sort_order: i64, + pub created_at: String, +} + fn map_game( value: crate::module_bindings::GameDistributionGameSnapshot, ) -> GameDistributionGameRecord { @@ -697,6 +731,82 @@ pub(crate) fn map_game_distribution_theme_refs_result( Ok(result.themes.into_iter().map(map_theme_reference).collect()) } +/// 后台主题行(列表条目与写接口返回值共用同一个形状)。 +fn map_admin_theme( + value: crate::module_bindings::GameDistributionAdminThemeSnapshot, +) -> GameDistributionAdminThemeRecord { + GameDistributionAdminThemeRecord { + theme_id: value.theme_id, + name: value.name, + summary: value.summary, + badge: value.badge, + sort_order: value.sort_order, + status: value.status, + member_count: value.member_count, + created_at: shared_kernel::format_timestamp_micros(value.created_at_micros), + updated_at: shared_kernel::format_timestamp_micros(value.updated_at_micros), + } +} + +/// 后台创建 / 更新主题:`ok == false` 走既有错误映射(400 / 404 / 409)。 +/// +/// `ok == true` 却缺主题行属于服务端契约违背:**不能**降级成「成功但没有内容」——那会让 handler +/// 把一个空壳当正常主题渲染;这里直接折成错误,让问题在第一次调用就暴露。 +pub(crate) fn map_game_distribution_admin_theme_mutation_result( + result: crate::module_bindings::GameDistributionAdminThemeMutationResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + let theme = result.theme.ok_or_else(|| { + SpacetimeClientError::procedure_failed(Some("主题写接口未回传主题行".to_string())) + })?; + Ok(GameDistributionAdminThemeMutationRecord { + theme: map_admin_theme(theme), + replayed: result.replayed, + }) +} + +/// 后台增 / 改成员:`ok == false` 走既有错误映射;成功时必须有成员行(PUT 的语义就是「落成一行」)。 +pub(crate) fn map_game_distribution_admin_theme_member_upsert_result( + result: crate::module_bindings::GameDistributionAdminThemeMemberMutationResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + let member = result.member.ok_or_else(|| { + SpacetimeClientError::procedure_failed(Some("主题成员写接口未回传成员行".to_string())) + })?; + Ok(GameDistributionAdminThemeMemberRecord { + theme_id: member.theme_id, + root_game_id: member.root_game_id, + sort_order: member.sort_order, + created_at: shared_kernel::format_timestamp_micros(member.created_at_micros), + }) +} + +/// 后台移除成员:成功时**没有**成员行(行已经不在库里),因此这里只关心 `ok`。 +/// +/// 刻意不回传「之前存不存在」:成员不存在也算成功,重复 DELETE 的响应因此逐字节相同。 +pub(crate) fn map_game_distribution_admin_theme_member_remove_result( + result: crate::module_bindings::GameDistributionAdminThemeMemberMutationResult, +) -> Result<(), SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + Ok(()) +} + +/// 后台主题列表:含 `draft` / `archived`(可见性过滤只发生在公开路径上)。 +pub(crate) fn map_game_distribution_admin_theme_list_result( + result: crate::module_bindings::GameDistributionAdminThemeListResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + Ok(result.themes.into_iter().map(map_admin_theme).collect()) +} + pub(crate) fn map_game_distribution_admin_game_list_result( result: crate::module_bindings::GameDistributionAdminGameListResult, ) -> Result<(Vec, Option), SpacetimeClientError> { @@ -915,3 +1025,143 @@ mod theme_result_tests { assert_eq!(list_error.to_string(), "主题列表游标格式无效"); } } + +/// 后台主题写接口结果的映射。 +/// +/// 三条纪律:① `ok = false` 必须折成 `Procedure` 错误(api-server 才能按码映射 400 / 404 / 409); +/// ② `ok = true` 却没有载荷是**契约违背**,直接失败,不能降级成「成功但没内容」; +/// ③ 时间戳在这里由微秒折成 `seconds.microsZ` 文本(与后台作品行同一口径)。 +#[cfg(test)] +mod admin_theme_result_tests { + use super::*; + + const CREATED_MICROS: i64 = 1_700_000_000_123_456; + const UPDATED_MICROS: i64 = 1_700_000_100_654_321; + + fn admin_theme_snapshot() -> crate::module_bindings::GameDistributionAdminThemeSnapshot { + crate::module_bindings::GameDistributionAdminThemeSnapshot { + theme_id: "theme_a".to_string(), + name: "共创主题".to_string(), + summary: "运营简介".to_string(), + badge: "精选".to_string(), + sort_order: 7, + status: "draft".to_string(), + member_count: 3, + created_at_micros: CREATED_MICROS, + updated_at_micros: UPDATED_MICROS, + } + } + + fn mutation_result( + ok: bool, + theme: Option, + ) -> crate::module_bindings::GameDistributionAdminThemeMutationResult { + crate::module_bindings::GameDistributionAdminThemeMutationResult { + ok, + replayed: false, + theme, + error_message: (!ok).then(|| "THEME_NOT_FOUND: 主题不存在".to_string()), + } + } + + fn member_result( + ok: bool, + member: Option, + ) -> crate::module_bindings::GameDistributionAdminThemeMemberMutationResult { + crate::module_bindings::GameDistributionAdminThemeMemberMutationResult { + ok, + member, + error_message: (!ok).then(|| "THEME_MEMBER_NOT_ROOT: 不是根作品".to_string()), + } + } + + #[test] + fn admin_theme_mutation_keeps_row_status_and_iso_timestamps() { + let mut result = mutation_result(true, Some(admin_theme_snapshot())); + result.replayed = true; + let record = map_game_distribution_admin_theme_mutation_result(result).expect("成功写结果"); + assert!(record.replayed, "重放必须原样透传"); + assert_eq!(record.theme.theme_id, "theme_a"); + // 后台行**必须**保留 draft / archived(公开投影那条路径才过滤可见性)。 + assert_eq!(record.theme.status, "draft"); + assert_eq!(record.theme.sort_order, 7); + assert_eq!(record.theme.member_count, 3); + assert_eq!(record.theme.created_at, "1700000000.123456Z"); + assert_eq!(record.theme.updated_at, "1700000100.654321Z"); + + let error = map_game_distribution_admin_theme_mutation_result(mutation_result(false, None)) + .expect_err("失败必须是错误"); + assert_eq!(error.to_string(), "THEME_NOT_FOUND: 主题不存在"); + + // `ok = true` 却没有行是契约违背:不能降级成「成功但没有内容」。 + let missing = + map_game_distribution_admin_theme_mutation_result(mutation_result(true, None)) + .expect_err("缺载荷必须失败关闭"); + assert!(missing.to_string().contains("未回传主题行")); + } + + #[test] + fn admin_theme_member_mutation_separates_upsert_from_idempotent_remove() { + let member = crate::module_bindings::GameDistributionAdminThemeMemberSnapshot { + theme_id: "theme_a".to_string(), + root_game_id: "game_root".to_string(), + sort_order: 2, + created_at_micros: CREATED_MICROS, + }; + let record = map_game_distribution_admin_theme_member_upsert_result(member_result( + true, + Some(member), + )) + .expect("PUT 成功"); + assert_eq!(record.root_game_id, "game_root"); + assert_eq!(record.sort_order, 2); + assert_eq!(record.created_at, "1700000000.123456Z"); + + let upsert_error = + map_game_distribution_admin_theme_member_upsert_result(member_result(false, None)) + .expect_err("非根作品必须是错误"); + assert_eq!( + upsert_error.to_string(), + "THEME_MEMBER_NOT_ROOT: 不是根作品" + ); + let upsert_missing = + map_game_distribution_admin_theme_member_upsert_result(member_result(true, None)) + .expect_err("PUT 成功却没有成员行是契约违背"); + assert!(upsert_missing.to_string().contains("未回传成员行")); + + // DELETE 成功时**本来就没有**成员行(行已经不在库里):这不是契约违背。 + map_game_distribution_admin_theme_member_remove_result(member_result(true, None)) + .expect("成员不存在也算成功(幂等删除)"); + let remove_error = + map_game_distribution_admin_theme_member_remove_result(member_result(false, None)) + .expect_err("主题不存在仍必须是错误"); + assert_eq!( + remove_error.to_string(), + "THEME_MEMBER_NOT_ROOT: 不是根作品" + ); + } + + #[test] + fn admin_theme_list_maps_drafts_and_fails_closed() { + let themes = map_game_distribution_admin_theme_list_result( + crate::module_bindings::GameDistributionAdminThemeListResult { + ok: true, + themes: vec![admin_theme_snapshot()], + error_message: None, + }, + ) + .expect("列表结果"); + assert_eq!(themes.len(), 1); + assert_eq!(themes[0].status, "draft"); + + let error = map_game_distribution_admin_theme_list_result( + crate::module_bindings::GameDistributionAdminThemeListResult { + ok: false, + themes: Vec::new(), + error_message: Some("THEME_BAD_REQUEST: 未知的主题状态过滤值 hidden".to_string()), + }, + ) + .expect_err("非法过滤值必须是错误(api-server 才能映射 400)"); + assert!(error.to_string().contains("THEME_BAD_REQUEST")); + } +} diff --git a/server-rs/crates/spacetime-client/src/game_distribution.rs b/server-rs/crates/spacetime-client/src/game_distribution.rs index e8546c1a8..c85ba9c5c 100644 --- a/server-rs/crates/spacetime-client/src/game_distribution.rs +++ b/server-rs/crates/spacetime-client/src/game_distribution.rs @@ -293,6 +293,60 @@ pub struct GameDistributionUncollectGameRecordInput { pub user_id: String, } +/// 后台创建共创主题。`theme_id` 由 api-server 生成(`theme-` + 随机后缀):请求体里没有这个字段, +/// 因此外部调用方无法指定主题 ID,而幂等收据仍能记住「首次创建的是哪一行」。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GameDistributionCreateThemeRecordInput { + pub theme_id: String, + pub name: String, + pub summary: String, + pub badge: String, + pub sort_order: i64, + pub status: String, + pub admin_user_id: String, + pub idempotency_key: String, + pub request_digest: String, + pub now_micros: i64, +} + +/// 后台整体覆盖主题字段(改名 / 简介 / 角标 / 排序 / 状态)。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GameDistributionUpdateThemeRecordInput { + pub theme_id: String, + pub name: String, + pub summary: String, + pub badge: String, + pub sort_order: i64, + pub status: String, + pub admin_user_id: String, + pub idempotency_key: String, + pub request_digest: String, + pub now_micros: i64, +} + +/// 后台增 / 改主题成员;成员身份由 `(theme_id, root_game_id)` 决定,因此不需要幂等键。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GameDistributionThemeMemberUpsertRecordInput { + pub theme_id: String, + pub root_game_id: String, + pub sort_order: i64, + pub now_micros: i64, +} + +/// 后台移除主题成员;按确定性主键删除,成员不存在也算成功,因此不需要幂等键。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GameDistributionThemeMemberRemoveRecordInput { + pub theme_id: String, + pub root_game_id: String, +} + +/// 后台主题列表:`status` 为 `all` / `draft` / `published` / `archived`(缺省 = 全量),无游标。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GameDistributionAdminThemeListRecordInput { + pub status: Option, + pub limit: u32, +} + impl SpacetimeClient { pub async fn list_admin_game_distribution_user_reviews( &self, @@ -921,6 +975,163 @@ impl SpacetimeClient { .await } + /// 后台创建共创主题(`POST /admin/api/game-distribution/themes`)。 + /// + /// `theme_id` 由 api-server 生成后传入(请求体里没有这个字段);命中同键幂等收据时 + /// `replayed = true`,且回的是**库里首次创建的那一行**。 + pub async fn create_game_distribution_theme( + &self, + input: GameDistributionCreateThemeRecordInput, + ) -> Result { + let input = crate::module_bindings::GameDistributionCreateThemeInput { + theme_id: input.theme_id, + name: input.name, + summary: input.summary, + badge: input.badge, + sort_order: input.sort_order, + status: input.status, + admin_user_id: input.admin_user_id, + idempotency_key: input.idempotency_key, + request_digest: input.request_digest, + now_micros: input.now_micros, + }; + self.call_after_connect( + "create_game_distribution_theme", + move |connection, sender| { + connection + .procedures() + .create_game_distribution_theme_and_return_then(input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_game_distribution_admin_theme_mutation_result); + send_once(&sender, mapped); + }); + }, + ) + .await + } + + /// 后台整体覆盖主题字段(`PUT /admin/api/game-distribution/themes/{theme_id}`)。 + /// + /// `theme_id` 不存在由事务回带码错误(api-server 映射 404);命中幂等收据的重放不写库。 + pub async fn update_game_distribution_theme( + &self, + input: GameDistributionUpdateThemeRecordInput, + ) -> Result { + let input = crate::module_bindings::GameDistributionUpdateThemeInput { + theme_id: input.theme_id, + name: input.name, + summary: input.summary, + badge: input.badge, + sort_order: input.sort_order, + status: input.status, + admin_user_id: input.admin_user_id, + idempotency_key: input.idempotency_key, + request_digest: input.request_digest, + now_micros: input.now_micros, + }; + self.call_after_connect( + "update_game_distribution_theme", + move |connection, sender| { + connection + .procedures() + .update_game_distribution_theme_and_return_then(input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_game_distribution_admin_theme_mutation_result); + send_once(&sender, mapped); + }); + }, + ) + .await + } + + /// 后台增 / 改主题成员(`PUT …/members/{root_game_id}`);幂等由确定性主键保证。 + pub async fn upsert_game_distribution_theme_member( + &self, + input: GameDistributionThemeMemberUpsertRecordInput, + ) -> Result { + let input = crate::module_bindings::GameDistributionThemeMemberUpsertInput { + theme_id: input.theme_id, + root_game_id: input.root_game_id, + sort_order: input.sort_order, + now_micros: input.now_micros, + }; + self.call_after_connect( + "upsert_game_distribution_theme_member", + move |connection, sender| { + connection + .procedures() + .upsert_game_distribution_theme_member_and_return_then( + input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_game_distribution_admin_theme_member_upsert_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + /// 后台移除主题成员(`DELETE …/members/{root_game_id}`);成员不存在也算成功(幂等)。 + pub async fn remove_game_distribution_theme_member( + &self, + input: GameDistributionThemeMemberRemoveRecordInput, + ) -> Result<(), SpacetimeClientError> { + let input = crate::module_bindings::GameDistributionThemeMemberRemoveInput { + theme_id: input.theme_id, + root_game_id: input.root_game_id, + }; + self.call_after_connect( + "remove_game_distribution_theme_member", + move |connection, sender| { + connection + .procedures() + .remove_game_distribution_theme_member_and_return_then( + input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_game_distribution_admin_theme_member_remove_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + /// 后台主题列表:含 `draft` / `archived`,无游标(一次取全,超界截断到上限)。 + pub async fn list_admin_game_distribution_themes( + &self, + input: GameDistributionAdminThemeListRecordInput, + ) -> Result, SpacetimeClientError> { + let input = crate::module_bindings::GameDistributionAdminThemeListInput { + status: input.status, + limit: input.limit, + }; + self.call_after_connect( + "list_admin_game_distribution_themes", + move |connection, sender| { + connection + .procedures() + .list_admin_game_distribution_themes_and_return_then( + input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_game_distribution_admin_theme_list_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 6f696dd7f..05f69adc2 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -172,6 +172,7 @@ pub mod create_editor_project_resource_and_return_procedure; pub mod create_error_report_and_return_procedure; pub mod create_external_api_key_and_return_procedure; pub mod create_game_distribution_game_and_return_procedure; +pub mod create_game_distribution_theme_and_return_procedure; pub mod create_game_distribution_version_and_return_procedure; pub mod create_payment_api_key_and_return_procedure; pub mod create_payment_app_and_return_procedure; @@ -423,6 +424,12 @@ pub mod follow_creator_and_return_procedure; pub mod game_distribution_admin_game_list_input_type; pub mod game_distribution_admin_game_list_result_type; pub mod game_distribution_admin_game_snapshot_type; +pub mod game_distribution_admin_theme_list_input_type; +pub mod game_distribution_admin_theme_list_result_type; +pub mod game_distribution_admin_theme_member_mutation_result_type; +pub mod game_distribution_admin_theme_member_snapshot_type; +pub mod game_distribution_admin_theme_mutation_result_type; +pub mod game_distribution_admin_theme_snapshot_type; pub mod game_distribution_admin_user_review_input_type; pub mod game_distribution_admin_user_review_list_input_type; pub mod game_distribution_admin_user_review_result_type; @@ -441,6 +448,7 @@ pub mod game_distribution_collection_type; pub mod game_distribution_confirm_package_input_type; pub mod game_distribution_confirm_project_bundle_input_type; pub mod game_distribution_create_game_input_type; +pub mod game_distribution_create_theme_input_type; pub mod game_distribution_create_version_input_type; pub mod game_distribution_delete_game_input_type; pub mod game_distribution_derived_games_input_type; @@ -491,8 +499,10 @@ 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_remove_input_type; pub mod game_distribution_theme_member_table; pub mod game_distribution_theme_member_type; +pub mod game_distribution_theme_member_upsert_input_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; @@ -502,6 +512,7 @@ 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; +pub mod game_distribution_update_theme_input_type; pub mod game_distribution_user_review_input_type; pub mod game_distribution_user_review_list_input_type; pub mod game_distribution_user_review_result_type; @@ -562,6 +573,7 @@ pub mod initialize_editor_generation_pricing_config_if_missing_and_return_proced pub mod is_game_distribution_collected_and_return_procedure; pub mod list_admin_accounts_and_return_procedure; pub mod list_admin_game_distribution_games_and_return_procedure; +pub mod list_admin_game_distribution_themes_and_return_procedure; pub mod list_admin_game_distribution_user_reviews_and_return_procedure; pub mod list_agc_tracking_events_procedure; pub mod list_asset_history_and_return_procedure; @@ -727,6 +739,7 @@ pub mod refund_profile_wallet_points_and_return_procedure; pub mod reject_game_distribution_version_and_return_procedure; pub mod release_profile_recharge_refund_hold_and_return_procedure; pub mod remove_creator_follower_and_return_procedure; +pub mod remove_game_distribution_theme_member_and_return_procedure; pub mod rename_editor_project_and_return_procedure; pub mod renew_external_generation_job_lease_and_return_procedure; pub mod repair_editor_asset_media_and_return_procedure; @@ -916,11 +929,13 @@ pub mod update_editor_showcase_asset_display_and_return_procedure; pub mod update_error_report_and_return_procedure; pub mod update_external_generation_job_phase_and_return_procedure; pub mod update_game_distribution_game_metadata_and_return_procedure; +pub mod update_game_distribution_theme_and_return_procedure; pub mod update_payment_app_and_return_procedure; pub mod upload_agc_analytics_batch_procedure; pub mod upsert_editor_generation_pricing_config_and_return_procedure; pub mod upsert_editor_showcase_campaign_config_and_return_procedure; pub mod upsert_feature_gate_config_procedure; +pub mod upsert_game_distribution_theme_member_and_return_procedure; pub mod upsert_llm_router_account_and_return_procedure; pub mod upsert_runtime_setting_and_return_procedure; pub mod user_account_table; @@ -1097,6 +1112,7 @@ pub use create_editor_project_resource_and_return_procedure::create_editor_proje pub use create_error_report_and_return_procedure::create_error_report_and_return; pub use create_external_api_key_and_return_procedure::create_external_api_key_and_return; pub use create_game_distribution_game_and_return_procedure::create_game_distribution_game_and_return; +pub use create_game_distribution_theme_and_return_procedure::create_game_distribution_theme_and_return; pub use create_game_distribution_version_and_return_procedure::create_game_distribution_version_and_return; pub use create_payment_api_key_and_return_procedure::create_payment_api_key_and_return; pub use create_payment_app_and_return_procedure::create_payment_app_and_return; @@ -1348,6 +1364,12 @@ pub use follow_creator_and_return_procedure::follow_creator_and_return; pub use game_distribution_admin_game_list_input_type::GameDistributionAdminGameListInput; pub use game_distribution_admin_game_list_result_type::GameDistributionAdminGameListResult; pub use game_distribution_admin_game_snapshot_type::GameDistributionAdminGameSnapshot; +pub use game_distribution_admin_theme_list_input_type::GameDistributionAdminThemeListInput; +pub use game_distribution_admin_theme_list_result_type::GameDistributionAdminThemeListResult; +pub use game_distribution_admin_theme_member_mutation_result_type::GameDistributionAdminThemeMemberMutationResult; +pub use game_distribution_admin_theme_member_snapshot_type::GameDistributionAdminThemeMemberSnapshot; +pub use game_distribution_admin_theme_mutation_result_type::GameDistributionAdminThemeMutationResult; +pub use game_distribution_admin_theme_snapshot_type::GameDistributionAdminThemeSnapshot; pub use game_distribution_admin_user_review_input_type::GameDistributionAdminUserReviewInput; pub use game_distribution_admin_user_review_list_input_type::GameDistributionAdminUserReviewListInput; pub use game_distribution_admin_user_review_result_type::GameDistributionAdminUserReviewResult; @@ -1366,6 +1388,7 @@ pub use game_distribution_collection_type::GameDistributionCollection; pub use game_distribution_confirm_package_input_type::GameDistributionConfirmPackageInput; pub use game_distribution_confirm_project_bundle_input_type::GameDistributionConfirmProjectBundleInput; pub use game_distribution_create_game_input_type::GameDistributionCreateGameInput; +pub use game_distribution_create_theme_input_type::GameDistributionCreateThemeInput; pub use game_distribution_create_version_input_type::GameDistributionCreateVersionInput; pub use game_distribution_delete_game_input_type::GameDistributionDeleteGameInput; pub use game_distribution_derived_games_input_type::GameDistributionDerivedGamesInput; @@ -1416,8 +1439,10 @@ pub use game_distribution_theme_detail_input_type::GameDistributionThemeDetailIn 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_remove_input_type::GameDistributionThemeMemberRemoveInput; pub use game_distribution_theme_member_table::*; pub use game_distribution_theme_member_type::GameDistributionThemeMember; +pub use game_distribution_theme_member_upsert_input_type::GameDistributionThemeMemberUpsertInput; 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; @@ -1427,6 +1452,7 @@ 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; +pub use game_distribution_update_theme_input_type::GameDistributionUpdateThemeInput; pub use game_distribution_user_review_input_type::GameDistributionUserReviewInput; pub use game_distribution_user_review_list_input_type::GameDistributionUserReviewListInput; pub use game_distribution_user_review_result_type::GameDistributionUserReviewResult; @@ -1487,6 +1513,7 @@ pub use initialize_editor_generation_pricing_config_if_missing_and_return_proced pub use is_game_distribution_collected_and_return_procedure::is_game_distribution_collected_and_return; pub use list_admin_accounts_and_return_procedure::list_admin_accounts_and_return; pub use list_admin_game_distribution_games_and_return_procedure::list_admin_game_distribution_games_and_return; +pub use list_admin_game_distribution_themes_and_return_procedure::list_admin_game_distribution_themes_and_return; pub use list_admin_game_distribution_user_reviews_and_return_procedure::list_admin_game_distribution_user_reviews_and_return; pub use list_agc_tracking_events_procedure::list_agc_tracking_events; pub use list_asset_history_and_return_procedure::list_asset_history_and_return; @@ -1652,6 +1679,7 @@ pub use refund_profile_wallet_points_and_return_procedure::refund_profile_wallet pub use reject_game_distribution_version_and_return_procedure::reject_game_distribution_version_and_return; pub use release_profile_recharge_refund_hold_and_return_procedure::release_profile_recharge_refund_hold_and_return; pub use remove_creator_follower_and_return_procedure::remove_creator_follower_and_return; +pub use remove_game_distribution_theme_member_and_return_procedure::remove_game_distribution_theme_member_and_return; pub use rename_editor_project_and_return_procedure::rename_editor_project_and_return; pub use renew_external_generation_job_lease_and_return_procedure::renew_external_generation_job_lease_and_return; pub use repair_editor_asset_media_and_return_procedure::repair_editor_asset_media_and_return; @@ -1841,11 +1869,13 @@ pub use update_editor_showcase_asset_display_and_return_procedure::update_editor pub use update_error_report_and_return_procedure::update_error_report_and_return; pub use update_external_generation_job_phase_and_return_procedure::update_external_generation_job_phase_and_return; pub use update_game_distribution_game_metadata_and_return_procedure::update_game_distribution_game_metadata_and_return; +pub use update_game_distribution_theme_and_return_procedure::update_game_distribution_theme_and_return; pub use update_payment_app_and_return_procedure::update_payment_app_and_return; pub use upload_agc_analytics_batch_procedure::upload_agc_analytics_batch; pub use upsert_editor_generation_pricing_config_and_return_procedure::upsert_editor_generation_pricing_config_and_return; pub use upsert_editor_showcase_campaign_config_and_return_procedure::upsert_editor_showcase_campaign_config_and_return; pub use upsert_feature_gate_config_procedure::upsert_feature_gate_config; +pub use upsert_game_distribution_theme_member_and_return_procedure::upsert_game_distribution_theme_member_and_return; pub use upsert_llm_router_account_and_return_procedure::upsert_llm_router_account_and_return; pub use upsert_runtime_setting_and_return_procedure::upsert_runtime_setting_and_return; pub use user_account_table::*; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/create_game_distribution_theme_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/create_game_distribution_theme_and_return_procedure.rs new file mode 100644 index 000000000..2b8753a3e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/create_game_distribution_theme_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_admin_theme_mutation_result_type::GameDistributionAdminThemeMutationResult; +use super::game_distribution_create_theme_input_type::GameDistributionCreateThemeInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct CreateGameDistributionThemeAndReturnArgs { + pub input: GameDistributionCreateThemeInput, +} + +impl __sdk::InModule for CreateGameDistributionThemeAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `create_game_distribution_theme_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait create_game_distribution_theme_and_return { + fn create_game_distribution_theme_and_return(&self, input: GameDistributionCreateThemeInput) { + self.create_game_distribution_theme_and_return_then(input, |_, _| {}); + } + + fn create_game_distribution_theme_and_return_then( + &self, + input: GameDistributionCreateThemeInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl create_game_distribution_theme_and_return for super::RemoteProcedures { + fn create_game_distribution_theme_and_return_then( + &self, + input: GameDistributionCreateThemeInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, GameDistributionAdminThemeMutationResult>( + "create_game_distribution_theme_and_return", + CreateGameDistributionThemeAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_theme_list_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_theme_list_input_type.rs new file mode 100644 index 000000000..993ef137f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_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 GameDistributionAdminThemeListInput { + pub status: Option, + pub limit: u32, +} + +impl __sdk::InModule for GameDistributionAdminThemeListInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_theme_list_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_theme_list_result_type.rs new file mode 100644 index 000000000..9f1b2073d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_theme_list_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_admin_theme_snapshot_type::GameDistributionAdminThemeSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct GameDistributionAdminThemeListResult { + pub ok: bool, + pub themes: Vec, + pub error_message: Option, +} + +impl __sdk::InModule for GameDistributionAdminThemeListResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_theme_member_mutation_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_theme_member_mutation_result_type.rs new file mode 100644 index 000000000..c0027afb8 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_theme_member_mutation_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_admin_theme_member_snapshot_type::GameDistributionAdminThemeMemberSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct GameDistributionAdminThemeMemberMutationResult { + pub ok: bool, + pub member: Option, + pub error_message: Option, +} + +impl __sdk::InModule for GameDistributionAdminThemeMemberMutationResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_theme_member_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_theme_member_snapshot_type.rs new file mode 100644 index 000000000..7b4f7e5b0 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_theme_member_snapshot_type.rs @@ -0,0 +1,18 @@ +// 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 GameDistributionAdminThemeMemberSnapshot { + pub theme_id: String, + pub root_game_id: String, + pub sort_order: i64, + pub created_at_micros: i64, +} + +impl __sdk::InModule for GameDistributionAdminThemeMemberSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_theme_mutation_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_theme_mutation_result_type.rs new file mode 100644 index 000000000..a16ff0899 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_theme_mutation_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_admin_theme_snapshot_type::GameDistributionAdminThemeSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct GameDistributionAdminThemeMutationResult { + pub ok: bool, + pub replayed: bool, + pub theme: Option, + pub error_message: Option, +} + +impl __sdk::InModule for GameDistributionAdminThemeMutationResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_theme_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_theme_snapshot_type.rs new file mode 100644 index 000000000..45f40d33d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_admin_theme_snapshot_type.rs @@ -0,0 +1,23 @@ +// 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 GameDistributionAdminThemeSnapshot { + pub theme_id: String, + pub name: String, + pub summary: String, + pub badge: String, + pub sort_order: i64, + pub status: String, + pub member_count: u64, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for GameDistributionAdminThemeSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_create_theme_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_create_theme_input_type.rs new file mode 100644 index 000000000..311276a95 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_create_theme_input_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}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct GameDistributionCreateThemeInput { + pub theme_id: String, + pub name: String, + pub summary: String, + pub badge: String, + pub sort_order: i64, + pub status: String, + pub admin_user_id: String, + pub idempotency_key: String, + pub request_digest: String, + pub now_micros: i64, +} + +impl __sdk::InModule for GameDistributionCreateThemeInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_member_remove_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_member_remove_input_type.rs new file mode 100644 index 000000000..7b73fda80 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_member_remove_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 GameDistributionThemeMemberRemoveInput { + pub theme_id: String, + pub root_game_id: String, +} + +impl __sdk::InModule for GameDistributionThemeMemberRemoveInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_member_upsert_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_member_upsert_input_type.rs new file mode 100644 index 000000000..1a01182fa --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_theme_member_upsert_input_type.rs @@ -0,0 +1,18 @@ +// 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 GameDistributionThemeMemberUpsertInput { + pub theme_id: String, + pub root_game_id: String, + pub sort_order: i64, + pub now_micros: i64, +} + +impl __sdk::InModule for GameDistributionThemeMemberUpsertInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_update_theme_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_update_theme_input_type.rs new file mode 100644 index 000000000..47534fb45 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_update_theme_input_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}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct GameDistributionUpdateThemeInput { + pub theme_id: String, + pub name: String, + pub summary: String, + pub badge: String, + pub sort_order: i64, + pub status: String, + pub admin_user_id: String, + pub idempotency_key: String, + pub request_digest: String, + pub now_micros: i64, +} + +impl __sdk::InModule for GameDistributionUpdateThemeInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_admin_game_distribution_themes_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_admin_game_distribution_themes_and_return_procedure.rs new file mode 100644 index 000000000..783a5c8e8 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_admin_game_distribution_themes_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_admin_theme_list_input_type::GameDistributionAdminThemeListInput; +use super::game_distribution_admin_theme_list_result_type::GameDistributionAdminThemeListResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ListAdminGameDistributionThemesAndReturnArgs { + pub input: GameDistributionAdminThemeListInput, +} + +impl __sdk::InModule for ListAdminGameDistributionThemesAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `list_admin_game_distribution_themes_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait list_admin_game_distribution_themes_and_return { + fn list_admin_game_distribution_themes_and_return( + &self, + input: GameDistributionAdminThemeListInput, + ) { + self.list_admin_game_distribution_themes_and_return_then(input, |_, _| {}); + } + + fn list_admin_game_distribution_themes_and_return_then( + &self, + input: GameDistributionAdminThemeListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl list_admin_game_distribution_themes_and_return for super::RemoteProcedures { + fn list_admin_game_distribution_themes_and_return_then( + &self, + input: GameDistributionAdminThemeListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, GameDistributionAdminThemeListResult>( + "list_admin_game_distribution_themes_and_return", + ListAdminGameDistributionThemesAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/remove_game_distribution_theme_member_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/remove_game_distribution_theme_member_and_return_procedure.rs new file mode 100644 index 000000000..3d3d4f7f6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/remove_game_distribution_theme_member_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_admin_theme_member_mutation_result_type::GameDistributionAdminThemeMemberMutationResult; +use super::game_distribution_theme_member_remove_input_type::GameDistributionThemeMemberRemoveInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct RemoveGameDistributionThemeMemberAndReturnArgs { + pub input: GameDistributionThemeMemberRemoveInput, +} + +impl __sdk::InModule for RemoveGameDistributionThemeMemberAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `remove_game_distribution_theme_member_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait remove_game_distribution_theme_member_and_return { + fn remove_game_distribution_theme_member_and_return( + &self, + input: GameDistributionThemeMemberRemoveInput, + ) { + self.remove_game_distribution_theme_member_and_return_then(input, |_, _| {}); + } + + fn remove_game_distribution_theme_member_and_return_then( + &self, + input: GameDistributionThemeMemberRemoveInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl remove_game_distribution_theme_member_and_return for super::RemoteProcedures { + fn remove_game_distribution_theme_member_and_return_then( + &self, + input: GameDistributionThemeMemberRemoveInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, GameDistributionAdminThemeMemberMutationResult>( + "remove_game_distribution_theme_member_and_return", + RemoveGameDistributionThemeMemberAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/update_game_distribution_theme_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/update_game_distribution_theme_and_return_procedure.rs new file mode 100644 index 000000000..f26782f6f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/update_game_distribution_theme_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_admin_theme_mutation_result_type::GameDistributionAdminThemeMutationResult; +use super::game_distribution_update_theme_input_type::GameDistributionUpdateThemeInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct UpdateGameDistributionThemeAndReturnArgs { + pub input: GameDistributionUpdateThemeInput, +} + +impl __sdk::InModule for UpdateGameDistributionThemeAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `update_game_distribution_theme_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait update_game_distribution_theme_and_return { + fn update_game_distribution_theme_and_return(&self, input: GameDistributionUpdateThemeInput) { + self.update_game_distribution_theme_and_return_then(input, |_, _| {}); + } + + fn update_game_distribution_theme_and_return_then( + &self, + input: GameDistributionUpdateThemeInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl update_game_distribution_theme_and_return for super::RemoteProcedures { + fn update_game_distribution_theme_and_return_then( + &self, + input: GameDistributionUpdateThemeInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, GameDistributionAdminThemeMutationResult>( + "update_game_distribution_theme_and_return", + UpdateGameDistributionThemeAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/upsert_game_distribution_theme_member_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/upsert_game_distribution_theme_member_and_return_procedure.rs new file mode 100644 index 000000000..8c5f65edf --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/upsert_game_distribution_theme_member_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_admin_theme_member_mutation_result_type::GameDistributionAdminThemeMemberMutationResult; +use super::game_distribution_theme_member_upsert_input_type::GameDistributionThemeMemberUpsertInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct UpsertGameDistributionThemeMemberAndReturnArgs { + pub input: GameDistributionThemeMemberUpsertInput, +} + +impl __sdk::InModule for UpsertGameDistributionThemeMemberAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `upsert_game_distribution_theme_member_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait upsert_game_distribution_theme_member_and_return { + fn upsert_game_distribution_theme_member_and_return( + &self, + input: GameDistributionThemeMemberUpsertInput, + ) { + self.upsert_game_distribution_theme_member_and_return_then(input, |_, _| {}); + } + + fn upsert_game_distribution_theme_member_and_return_then( + &self, + input: GameDistributionThemeMemberUpsertInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl upsert_game_distribution_theme_member_and_return for super::RemoteProcedures { + fn upsert_game_distribution_theme_member_and_return_then( + &self, + input: GameDistributionThemeMemberUpsertInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, GameDistributionAdminThemeMemberMutationResult>( + "upsert_game_distribution_theme_member_and_return", + UpsertGameDistributionThemeMemberAndReturnArgs { 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 86c33fb9d..ce7d44ef1 100644 --- a/server-rs/crates/spacetime-module/src/game_distribution.rs +++ b/server-rs/crates/spacetime-module/src/game_distribution.rs @@ -1049,6 +1049,14 @@ const GAME_DISTRIBUTION_ACTION_SET_FORK_AUTHORIZATION: &str = "set_fork_authoriz const GAME_DISTRIBUTION_ACTION_UPLOAD_PROJECT_BUNDLE: &str = "upload_project_bundle"; /// 登录用户收藏(收录)公开作品;幂等键只挂在「收藏」上(取消按确定性主键删除,天然幂等)。 const GAME_DISTRIBUTION_ACTION_COLLECT_GAME: &str = "collect_game"; +/// 后台创建共创主题;幂等键作用域 = `(admin 主体, 动作, 键)`,与作者侧收据互不干扰。 +const GAME_DISTRIBUTION_ACTION_CREATE_THEME: &str = "create_theme"; +/// 后台整体覆盖共创主题字段。收据只用于「同键重放回同一行 + 同键不同摘要 409」。 +const GAME_DISTRIBUTION_ACTION_UPDATE_THEME: &str = "update_theme"; +/// 主题相关的幂等收据把主题 ID 存在 `outcome_json` 里(收据没有 theme 专用列,也不该为它加一列)。 +const GAME_DISTRIBUTION_THEME_RECEIPT_OUTCOME_KIND: &str = "theme"; +/// 后台主题列表一次最多回传多少条;与后台作品列表同口径(运营表格一次扫读的量级)。 +const GAME_DISTRIBUTION_ADMIN_THEME_LIST_LIMIT: u32 = 200; /// 后台游戏管理页每个游戏最多回传多少个版本历史;版本数仍按全量统计。 const GAME_DISTRIBUTION_ADMIN_VERSION_HISTORY_LIMIT: usize = 20; /// 后台游戏管理页一次最多回传多少个游戏。 @@ -1404,6 +1412,79 @@ pub struct GameDistributionThemeRefsInput { pub game_id: String, } +/// 后台创建主题输入(`POST /admin/api/game-distribution/themes`)。 +/// +/// `theme_id` 由 api-server 生成(`theme-` + 随机后缀,同作品的 `game_id` 口径)后再传进来: +/// **请求体里没有这个字段**,因此外部调用方无法指定主题 ID,而事务这边又能像 `create_game` 那样 +/// 在幂等收据里记住「首次创建的是哪一行」。 +/// +/// `admin_user_id` 落到 `created_by_user_id`(审计用,不参与鉴权判定);`idempotency_key` / +/// `request_digest` 与既有写接口同族:同键同摘要重放,同键不同摘要 409。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionCreateThemeInput { + pub theme_id: String, + pub name: String, + pub summary: String, + pub badge: String, + pub sort_order: i64, + /// `draft` / `published` / `archived`(api-server 的 DTO 枚举已挡掉未知取值)。 + pub status: String, + pub admin_user_id: String, + pub idempotency_key: String, + pub request_digest: String, + pub now_micros: i64, +} + +/// 后台整体覆盖主题字段的输入(`PUT /admin/api/game-distribution/themes/{theme_id}`)。 +/// +/// 与创建分开类型而不是复用:更新必须带 `theme_id`,创建**必须不带**(由服务端生成)。把两者合成 +/// 一个带 `Option` 的输入,会让「漏传就是新建」这种事故变成一次静默的成功。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionUpdateThemeInput { + pub theme_id: String, + pub name: String, + pub summary: String, + pub badge: String, + pub sort_order: i64, + pub status: String, + pub admin_user_id: String, + pub idempotency_key: String, + pub request_digest: String, + pub now_micros: i64, +} + +/// 后台增 / 改主题成员输入(`PUT …/themes/{theme_id}/members/{root_game_id}`)。 +/// +/// **没有幂等键**:成员身份完全由 `(theme_id, root_game_id)` 这对路径参数决定,确定性主键 +/// (`game_distribution_theme_member_id`)让重复调用落在同一行上,没有「重放 vs 新意图」需要区分。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionThemeMemberUpsertInput { + pub theme_id: String, + pub root_game_id: String, + pub sort_order: i64, + pub now_micros: i64, +} + +/// 后台移除主题成员输入(`DELETE …/themes/{theme_id}/members/{root_game_id}`)。 +/// +/// 同样没有幂等键:按确定性主键删除,成员不存在也算成功。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionThemeMemberRemoveInput { + pub theme_id: String, + pub root_game_id: String, +} + +/// 后台主题列表输入(`GET /admin/api/game-distribution/themes`)。 +/// +/// `status` 为 `all` / `draft` / `published` / `archived`(缺省 = 全量);非法值在事务里失败关闭, +/// 绝不「悄悄按全量返回」。`limit` 为 0 时取后台默认上限,超界**截断**(与后台作品列表同口径)。 +/// **不带游标**:主题是运营维护的小集合,一次取全比游标翻页更符合后台表格的用法。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionAdminThemeListInput { + pub status: Option, + pub limit: u32, +} + /// 单次游玩计数增量;api-server 已按 `game_id` 聚合,同一输入内不重复。 #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct GameDistributionPlayCountIncrement { @@ -1829,6 +1910,102 @@ impl GameDistributionThemeRefsResult { } } +/// 后台主题行快照:公开投影(`GameDistributionThemeSnapshot`)**看不到**的那几列都在这里。 +/// +/// 与公开投影的两处差异都是刻意的: +/// - 它包含 `draft` / `archived`(后台要管全量,公开侧一律不出现); +/// - `member_count` 是**成员行总数**(含当前对外不可见的成员),而公开投影的 `member_count` 是 +/// **当前可见成员数**。公开侧只报可见数是为了不泄露「存在草稿 / 被下架成员」;后台没有这层顾虑, +/// 运营恰恰需要知道「挂了几行、其中几行现在不可见」(本轮只给总数,标记留给后台 UI)。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionAdminThemeSnapshot { + pub theme_id: String, + pub name: String, + pub summary: String, + pub badge: String, + pub sort_order: i64, + pub status: String, + pub member_count: u64, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +/// 后台创建 / 更新主题的结果。 +/// +/// `theme` 为 `None` 时 `ok` 必为 false(失败路径显式给空,不靠缺省值)。`replayed` 只在带幂等键 +/// 的两条路径(创建 / 更新)上有意义:命中同键收据且摘要一致 → `true`,且回的是**库里那一行** +/// (不是本次请求带来的新值)。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionAdminThemeMutationResult { + pub ok: bool, + pub replayed: bool, + pub theme: Option, + pub error_message: Option, +} + +impl GameDistributionAdminThemeMutationResult { + fn failed(error: String) -> Self { + Self { + ok: false, + replayed: false, + theme: None, + error_message: Some(error), + } + } +} + +/// 后台主题成员行快照(回传「这次 PUT 落成了哪一行」)。 +/// +/// `created_at_micros` 是**首次写入**的时间:重复 PUT 只改 `sort_order`,不会刷新它——否则 +/// 「这个成员是什么时候挂上来的」会被一次无意义的重复调用抹掉。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionAdminThemeMemberSnapshot { + pub theme_id: String, + pub root_game_id: String, + pub sort_order: i64, + pub created_at_micros: i64, +} + +/// 后台增 / 改 / 移除成员的结果。 +/// +/// 移除成功时 `member` 为 `None` 而 `ok` 为 true(行已经不在库里,事务不回填一个假行); +/// 不设 `removed` 之类的「之前存不存在」布尔,是为了让**重复 DELETE 的响应逐字节相同**—— +/// 成员不存在也算成功,客户端不该也无法据此区分「删掉了」与「本来就没有」。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionAdminThemeMemberMutationResult { + pub ok: bool, + pub member: Option, + pub error_message: Option, +} + +impl GameDistributionAdminThemeMemberMutationResult { + fn failed(error: String) -> Self { + Self { + ok: false, + member: None, + error_message: Some(error), + } + } +} + +/// 后台主题列表结果;含 `draft` / `archived`,按 `created_at` 倒序 + `theme_id` 升序。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct GameDistributionAdminThemeListResult { + pub ok: bool, + pub themes: Vec, + pub error_message: Option, +} + +impl GameDistributionAdminThemeListResult { + 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 { @@ -2662,6 +2839,124 @@ pub fn list_game_distribution_theme_refs_for_root_and_return( } } +/// 后台创建共创主题(`POST /admin/api/game-distribution/themes`)。 +/// +/// 鉴权分两层:这里要求**受信服务身份**(只有 api-server 能调模块),HTTP 层的 admin 会话由 +/// api-server 的 `require_admin_auth` 负责——两条都在,缺一条都不算通过。 +/// 幂等:同键同摘要 → `replayed = true` 且回库里那一行;同键不同摘要 → 409。 +#[spacetimedb::procedure] +pub fn create_game_distribution_theme_and_return( + ctx: &mut ProcedureContext, + input: GameDistributionCreateThemeInput, +) -> GameDistributionAdminThemeMutationResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + require_editor_generation_runtime_service_identity(tx, caller)?; + create_game_distribution_theme_tx(tx, input.clone()) + }) { + Ok((theme, replayed)) => GameDistributionAdminThemeMutationResult { + ok: true, + replayed, + theme: Some(theme), + error_message: None, + }, + Err(error) => GameDistributionAdminThemeMutationResult::failed(error), + } +} + +/// 后台整体覆盖主题字段(`PUT /admin/api/game-distribution/themes/{theme_id}`)。 +/// +/// `theme_id` 不存在 → `THEME_NOT_FOUND`(404);字段非法 → `THEME_BAD_REQUEST`(400)。 +/// 每次**生效**的更新刷新 `updated_at`;命中幂等收据的重放**不写库**(因此也不刷新)。 +#[spacetimedb::procedure] +pub fn update_game_distribution_theme_and_return( + ctx: &mut ProcedureContext, + input: GameDistributionUpdateThemeInput, +) -> GameDistributionAdminThemeMutationResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + require_editor_generation_runtime_service_identity(tx, caller)?; + update_game_distribution_theme_tx(tx, input.clone()) + }) { + Ok((theme, replayed)) => GameDistributionAdminThemeMutationResult { + ok: true, + replayed, + theme: Some(theme), + error_message: None, + }, + Err(error) => GameDistributionAdminThemeMutationResult::failed(error), + } +} + +/// 后台增 / 改主题成员(`PUT …/themes/{theme_id}/members/{root_game_id}`)。 +/// +/// 幂等由确定性主键保证(同主题同根只有一行);只允许**根作品**(有血缘行 → 409)。 +/// 不做「成员必须公开」的前置校验:运营可以先挂草稿根,作品公开后成员自动进入公开投影。 +#[spacetimedb::procedure] +pub fn upsert_game_distribution_theme_member_and_return( + ctx: &mut ProcedureContext, + input: GameDistributionThemeMemberUpsertInput, +) -> GameDistributionAdminThemeMemberMutationResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + require_editor_generation_runtime_service_identity(tx, caller)?; + upsert_game_distribution_theme_member_tx(tx, input.clone()) + }) { + Ok(member) => GameDistributionAdminThemeMemberMutationResult { + ok: true, + member: Some(member), + error_message: None, + }, + Err(error) => GameDistributionAdminThemeMemberMutationResult::failed(error), + } +} + +/// 后台移除主题成员(`DELETE …/themes/{theme_id}/members/{root_game_id}`)。 +/// +/// **不要求幂等键**:按确定性主键删除,成员不存在也算成功(`ok = true`、`member = None`), +/// 重复调用的响应因此逐字节相同。主题不存在仍是 404 —— 那是路径错了,不是「已经删过了」。 +#[spacetimedb::procedure] +pub fn remove_game_distribution_theme_member_and_return( + ctx: &mut ProcedureContext, + input: GameDistributionThemeMemberRemoveInput, +) -> GameDistributionAdminThemeMemberMutationResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + require_editor_generation_runtime_service_identity(tx, caller)?; + remove_game_distribution_theme_member_tx(tx, input.clone()) + }) { + Ok(()) => GameDistributionAdminThemeMemberMutationResult { + ok: true, + member: None, + error_message: None, + }, + Err(error) => GameDistributionAdminThemeMemberMutationResult::failed(error), + } +} + +/// 后台主题列表(`GET /admin/api/game-distribution/themes`):含 `draft` / `archived`。 +/// +/// 需要服务身份(后台只读同样是后台数据);`status` 过滤与 `limit` 归一化都在事务内完成, +/// 非法过滤值失败关闭而不是退化成全量。 +#[spacetimedb::procedure] +pub fn list_admin_game_distribution_themes_and_return( + ctx: &mut ProcedureContext, + input: GameDistributionAdminThemeListInput, +) -> GameDistributionAdminThemeListResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + require_editor_generation_runtime_service_identity(tx, caller)?; + list_admin_game_distribution_themes_tx(tx, input.clone()) + }) { + Ok(themes) => GameDistributionAdminThemeListResult { + ok: true, + themes, + error_message: None, + }, + Err(error) => GameDistributionAdminThemeListResult::failed(error), + } +} + #[spacetimedb::procedure] pub fn get_game_distribution_version_and_return( ctx: &mut ProcedureContext, @@ -5354,6 +5649,420 @@ fn list_game_distribution_theme_refs_for_root_tx( .collect()) } +/// 后台主题写接口的参数非法(`THEME_BAD_REQUEST`):错误码前缀由领域错误统一给出, +/// 这里只做「拼一句带码的文案」这一件事,避免每个分支各写一遍前缀字符串。 +fn theme_bad_request(reason: impl Into) -> String { + module_game_distribution::GameDistributionError::ThemeBadRequest { + reason: reason.into(), + } + .to_string() +} + +/// 后台主题写接口的幂等摘要校验。 +/// +/// 复用同一个收据判定(`ensure_game_distribution_receipt_digest`),只把「摘要不一致」重新命名成 +/// 主题自己的码:客户端因此能按 `THEME_IDEMPOTENCY_CONFLICT` 分支,而不是从 409 的文案里猜这是 +/// 哪一类冲突。判据本身**不在**这里重写一遍——那样迟早会出现两套「什么算同一次请求」的实现。 +fn ensure_game_distribution_theme_receipt_digest( + receipt: &GameDistributionIdempotencyReceipt, + request_digest: &str, +) -> Result<(), String> { + ensure_game_distribution_receipt_digest(receipt, request_digest).map_err(|_| { + module_game_distribution::GameDistributionError::ThemeIdempotencyConflict.to_string() + }) +} + +/// 后台主题写接口的文本与状态校验:空名 / 超长 / 未知状态一律失败关闭。 +/// +/// 文本规则来自纯函数 `game_distribution_theme_text_violation`(api-server 侧同一份),状态白名单 +/// 来自 `game_distribution_theme_status_valid`——两处都不在这里重写,否则「HTTP 挡住的」与 +/// 「落库挡住的」会各长一套判据。 +fn ensure_game_distribution_theme_write_fields( + name: &str, + summary: &str, + badge: &str, + status: &str, +) -> Result<(), String> { + if let Some(reason) = + module_game_distribution::game_distribution_theme_text_violation(name, summary, badge) + { + return Err(theme_bad_request(reason)); + } + if !module_game_distribution::game_distribution_theme_status_valid(status) { + return Err(theme_bad_request(format!("未知的主题状态 {status}"))); + } + Ok(()) +} + +/// 主题的成员**行**总数(含当前对外不可见的成员),只供后台视图使用。 +/// +/// 公开投影用的是 `game_distribution_theme_member_count`(只算可见成员)。两个数字刻意不同: +/// 公开侧报总数会泄露「存在草稿 / 被下架成员」,后台侧藏起来则让运营看不见自己挂过什么。 +fn game_distribution_theme_member_row_count(ctx: &ReducerContext, theme_id: &str) -> u64 { + ctx.db + .game_distribution_theme_member() + .by_game_distribution_theme_member_theme_id() + .filter(theme_id) + .count() as u64 +} + +/// 主题行 → 后台快照(含 `draft` / `archived`、运营排序、时间戳与成员行总数)。 +fn game_distribution_admin_theme_snapshot( + ctx: &ReducerContext, + theme: &GameDistributionTheme, +) -> GameDistributionAdminThemeSnapshot { + GameDistributionAdminThemeSnapshot { + theme_id: theme.theme_id.clone(), + name: theme.name.clone(), + summary: theme.summary.clone(), + badge: theme.badge.clone(), + sort_order: theme.sort_order, + status: theme.status.clone(), + member_count: game_distribution_theme_member_row_count(ctx, theme.theme_id.as_str()), + created_at_micros: theme.created_at.to_micros_since_unix_epoch(), + updated_at_micros: theme.updated_at.to_micros_since_unix_epoch(), + } +} + +/// 后台创建主题。 +/// +/// 幂等:命中同键收据且摘要一致时回**首次创建的那一行**(`replayed = true`),而不是拿本次请求 +/// 带来的新 `theme_id` 再插一次——重试的客户端不该得到两个主题。摘要不一致 → 409。 +/// `created_by_user_id` 取调用方给的会话主体(只作审计),`created_at = updated_at = now`。 +fn create_game_distribution_theme_tx( + ctx: &ReducerContext, + input: GameDistributionCreateThemeInput, +) -> Result<(GameDistributionAdminThemeSnapshot, bool), String> { + let theme_id = required_game_distribution_text(input.theme_id, "theme_id")?; + let admin_user_id = required_game_distribution_text(input.admin_user_id, "admin_user_id")?; + let idempotency_key = + required_game_distribution_text(input.idempotency_key, "idempotency_key")?; + let request_digest = required_game_distribution_text(input.request_digest, "request_digest")?; + let name = input.name.trim().to_string(); + let summary = input.summary.trim().to_string(); + let badge = input.badge.trim().to_string(); + let status = input.status.trim().to_lowercase(); + ensure_game_distribution_theme_write_fields( + name.as_str(), + summary.as_str(), + badge.as_str(), + status.as_str(), + )?; + let receipt_id = game_distribution_receipt_id( + admin_user_id.as_str(), + GAME_DISTRIBUTION_ACTION_CREATE_THEME, + idempotency_key.as_str(), + ); + if let Some(receipt) = find_game_distribution_receipt(ctx, receipt_id.as_str()) { + ensure_game_distribution_theme_receipt_digest(&receipt, request_digest.as_str())?; + let created_theme_id = receipt + .outcome_json + .ok_or_else(|| "幂等收据缺少主题 ID".to_string())?; + let theme = ctx + .db + .game_distribution_theme() + .theme_id() + .find(&created_theme_id) + .ok_or_else(|| { + module_game_distribution::GameDistributionError::ThemeNotFound.to_string() + })?; + return Ok((game_distribution_admin_theme_snapshot(ctx, &theme), true)); + } + if ctx + .db + .game_distribution_theme() + .theme_id() + .find(&theme_id) + .is_some() + { + // 同一个 ID 只会由 api-server 生成一次(随机后缀),走到这里说明调用方自己拼了 ID: + // 直接拒绝,而不是把别人的主题覆盖成我的内容。 + return Err(theme_bad_request("主题 ID 已存在")); + } + let now = Timestamp::from_micros_since_unix_epoch(input.now_micros); + let theme = GameDistributionTheme { + theme_id: theme_id.clone(), + name, + summary, + badge, + sort_order: input.sort_order, + status, + created_by_user_id: admin_user_id.clone(), + created_at: now, + updated_at: now, + }; + ctx.db.game_distribution_theme().insert(theme.clone()); + insert_game_distribution_receipt( + ctx, + GameDistributionReceiptInput { + receipt_id, + owner_user_id: admin_user_id, + action: GAME_DISTRIBUTION_ACTION_CREATE_THEME.to_string(), + idempotency_key, + request_digest, + game_id: None, + version_id: None, + outcome_kind: GAME_DISTRIBUTION_THEME_RECEIPT_OUTCOME_KIND.to_string(), + outcome_game_id: None, + outcome_version_id: None, + // 主题没有专用结果列:把主题 ID 放进 `outcome_json`(重放时据此取回那一行)。 + outcome_json: Some(theme_id), + now_micros: input.now_micros, + }, + )?; + Ok((game_distribution_admin_theme_snapshot(ctx, &theme), false)) +} + +/// 后台整体覆盖主题字段(改名 / 简介 / 角标 / 排序 / 状态)。 +/// +/// 顺序:先校字段(400)→ 再找主题(404)→ 再判收据(409 / 重放)。把「主题不存在」放在收据判定 +/// 之前,是为了让「ID 打错了」永远是 404,而不是在同键不同摘要时被 409 抢走。 +/// 生效的更新刷新 `updated_at`;重放不写库,因此也不会刷新。 +fn update_game_distribution_theme_tx( + ctx: &ReducerContext, + input: GameDistributionUpdateThemeInput, +) -> Result<(GameDistributionAdminThemeSnapshot, bool), String> { + let theme_id = required_game_distribution_text(input.theme_id, "theme_id")?; + let admin_user_id = required_game_distribution_text(input.admin_user_id, "admin_user_id")?; + let idempotency_key = + required_game_distribution_text(input.idempotency_key, "idempotency_key")?; + let request_digest = required_game_distribution_text(input.request_digest, "request_digest")?; + let name = input.name.trim().to_string(); + let summary = input.summary.trim().to_string(); + let badge = input.badge.trim().to_string(); + let status = input.status.trim().to_lowercase(); + ensure_game_distribution_theme_write_fields( + name.as_str(), + summary.as_str(), + badge.as_str(), + status.as_str(), + )?; + let Some(mut theme) = ctx.db.game_distribution_theme().theme_id().find(&theme_id) else { + return Err(module_game_distribution::GameDistributionError::ThemeNotFound.to_string()); + }; + let receipt_id = game_distribution_receipt_id( + admin_user_id.as_str(), + GAME_DISTRIBUTION_ACTION_UPDATE_THEME, + idempotency_key.as_str(), + ); + if let Some(receipt) = find_game_distribution_receipt(ctx, receipt_id.as_str()) { + ensure_game_distribution_theme_receipt_digest(&receipt, request_digest.as_str())?; + // 重放回**当前**行(与 `create_game` 的重放同一取舍:收据只保证「同一意图只生效一次」, + // 不冻结响应内容)。因此这里不写库、不刷新 `updated_at`。 + return Ok((game_distribution_admin_theme_snapshot(ctx, &theme), true)); + } + theme.name = name; + theme.summary = summary; + theme.badge = badge; + theme.sort_order = input.sort_order; + theme.status = status; + theme.updated_at = Timestamp::from_micros_since_unix_epoch(input.now_micros); + ctx.db + .game_distribution_theme() + .theme_id() + .update(theme.clone()); + insert_game_distribution_receipt( + ctx, + GameDistributionReceiptInput { + receipt_id, + owner_user_id: admin_user_id, + action: GAME_DISTRIBUTION_ACTION_UPDATE_THEME.to_string(), + idempotency_key, + request_digest, + game_id: None, + version_id: None, + outcome_kind: GAME_DISTRIBUTION_THEME_RECEIPT_OUTCOME_KIND.to_string(), + outcome_game_id: None, + outcome_version_id: None, + outcome_json: Some(theme_id), + now_micros: input.now_micros, + }, + )?; + Ok((game_distribution_admin_theme_snapshot(ctx, &theme), false)) +} + +/// 后台增 / 改主题成员(幂等 upsert,只允许根作品)。 +/// +/// 判定顺序是有意的:**主题存在 → 作品存在 → 是否根**。反过来的话,「挂一个不存在的作品」 +/// 与「挂一个非根作品」会共用同一个状态码,运营分不清是 ID 打错了还是应该改挂它的根。 +/// 不做「成员必须公开」的前置校验:草稿 / 已下架 / 软删除的作品都能先挂上(公开投影按可见性 +/// 跳过),作品公开后成员自动出现——这正是「投影跳过但不删行」的正面用法。 +fn upsert_game_distribution_theme_member_tx( + ctx: &ReducerContext, + input: GameDistributionThemeMemberUpsertInput, +) -> Result { + let theme_id = required_game_distribution_text(input.theme_id, "theme_id")?; + let root_game_id = required_game_distribution_text(input.root_game_id, "root_game_id")?; + if ctx + .db + .game_distribution_theme() + .theme_id() + .find(&theme_id) + .is_none() + { + return Err(module_game_distribution::GameDistributionError::ThemeNotFound.to_string()); + } + if ctx + .db + .game_distribution_game() + .game_id() + .find(&root_game_id) + .is_none() + { + return Err( + module_game_distribution::GameDistributionError::ThemeMemberGameNotFound { + game_id: root_game_id, + } + .to_string(), + ); + } + // 「是不是根」的事实只能由血缘行给出:有行 ⇒ 这个作品自己是别人的衍生,不能作为成员。 + // 与 `game_distribution_theme_root_of` 用同一张表 / 同一个键,但方向相反——那里是「顺着行找根」, + // 这里是「有行就不是根」,两处都不是「先查后写」的流程假设。 + let has_lineage_row = ctx + .db + .game_distribution_lineage() + .game_id() + .find(&root_game_id) + .is_some(); + if !module_game_distribution::game_distribution_theme_root_acceptable(has_lineage_row) { + return Err( + module_game_distribution::GameDistributionError::ThemeMemberNotRoot { + game_id: root_game_id, + } + .to_string(), + ); + } + let member_id = module_game_distribution::game_distribution_theme_member_id( + theme_id.as_str(), + root_game_id.as_str(), + ); + let created_at = match ctx + .db + .game_distribution_theme_member() + .member_id() + .find(&member_id) + { + Some(existing) => { + // 幂等 upsert:主键已经保证「同主题同根一行」,这里只更新运营权重,**不动 `created_at`** + // ——「这个成员是什么时候挂上来的」不该被一次重复 PUT 改写。 + let mut updated = existing; + updated.sort_order = input.sort_order; + ctx.db + .game_distribution_theme_member() + .member_id() + .update(updated.clone()); + updated.created_at + } + None => { + let created_at = Timestamp::from_micros_since_unix_epoch(input.now_micros); + ctx.db + .game_distribution_theme_member() + .insert(GameDistributionThemeMember { + member_id, + theme_id: theme_id.clone(), + root_game_id: root_game_id.clone(), + sort_order: input.sort_order, + created_at, + }); + created_at + } + }; + Ok(GameDistributionAdminThemeMemberSnapshot { + theme_id, + root_game_id, + sort_order: input.sort_order, + created_at_micros: created_at.to_micros_since_unix_epoch(), + }) +} + +/// 后台移除主题成员:按确定性主键删除,**成员不存在也算成功**(幂等)。 +/// +/// 主题不存在仍是 `THEME_NOT_FOUND`:那是路径里的主题 ID 错了,与「成员早就删掉了」是两回事。 +/// 归档主题**不清理**成员行(`status = archived` 只是不再公开),因此这里也不做任何级联。 +fn remove_game_distribution_theme_member_tx( + ctx: &ReducerContext, + input: GameDistributionThemeMemberRemoveInput, +) -> Result<(), String> { + let theme_id = required_game_distribution_text(input.theme_id, "theme_id")?; + let root_game_id = required_game_distribution_text(input.root_game_id, "root_game_id")?; + if ctx + .db + .game_distribution_theme() + .theme_id() + .find(&theme_id) + .is_none() + { + return Err(module_game_distribution::GameDistributionError::ThemeNotFound.to_string()); + } + let member_id = module_game_distribution::game_distribution_theme_member_id( + theme_id.as_str(), + root_game_id.as_str(), + ); + if ctx + .db + .game_distribution_theme_member() + .member_id() + .find(&member_id) + .is_some() + { + ctx.db + .game_distribution_theme_member() + .member_id() + .delete(&member_id); + } + Ok(()) +} + +/// 后台主题列表:含 `draft` / `archived`,`status` 过滤 + `limit` 截断,按 `created_at` 倒序 + +/// `theme_id` 升序。**不带游标**(主题是运营维护的小集合)。 +/// +/// 排序刻意复用公开列表那份全序比较器 `sort_public_themes`:后台与公开对「主题之间谁在前」不该有 +/// 两套语义,复用同一份实现就不会漂移。`limit` 的 0 / 超界语义与后台作品列表同口径(取默认 / +/// 截断到 200),不是「返回 0 条」也不是报错。 +fn list_admin_game_distribution_themes_tx( + ctx: &ReducerContext, + input: GameDistributionAdminThemeListInput, +) -> Result, String> { + let limit = if input.limit == 0 { + GAME_DISTRIBUTION_ADMIN_THEME_LIST_LIMIT + } else { + input.limit.min(GAME_DISTRIBUTION_ADMIN_THEME_LIST_LIMIT) + } as usize; + let status = input + .status + .and_then(normalize_game_distribution_optional) + .map(|value| value.to_lowercase()); + if let Some(status) = status.as_deref() { + // api-server 已经挡过一次;这里再挡一次是为了让非 HTTP 调用方也失败关闭,而不是拿到一份 + // 「悄悄按全量返回」的列表——那会让运营以为自己筛了、其实没筛。 + if !module_game_distribution::game_distribution_theme_admin_status_filter_valid(status) { + return Err(theme_bad_request(format!("未知的主题状态过滤值 {status}"))); + } + } + let themes = ctx + .db + .game_distribution_theme() + .iter() + .filter(|theme| match status.as_deref() { + None | Some(module_game_distribution::GAME_DISTRIBUTION_THEME_ADMIN_STATUS_ALL) => true, + Some(value) => theme.status == value, + }) + .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::>(); + Ok(module_game_distribution::sort_public_themes(themes) + .into_iter() + .take(limit) + .map(|item| game_distribution_admin_theme_snapshot(ctx, &item.payload)) + .collect()) +} + /// /// 这里只负责取行(根游戏行、按根索引命中的血缘行、以及每行引用的游戏行), /// 「家族归属校验、根自身入集合、游戏行缺失跳过、重复行去重」全部交给 @@ -6321,4 +7030,311 @@ mod tests { "失败路径必须显式给出 next_cursor(不能靠缺省值)" ); } + + /// 后台创建主题:先判幂等收据再写库,重放回**收据里记下的那一行**(不是本次请求带来的新 ID), + /// `created_by_user_id` 取调用方给的会话主体,`created_at` 与 `updated_at` 同为 now。 + #[test] + fn admin_theme_create_is_idempotent_and_audits_the_admin_subject() { + let source = include_str!("game_distribution.rs"); + let body = function_body(source, "fn create_game_distribution_theme_tx("); + for required in [ + "GAME_DISTRIBUTION_ACTION_CREATE_THEME", + "find_game_distribution_receipt", + "ensure_game_distribution_theme_receipt_digest", + "outcome_json", + "created_by_user_id: admin_user_id.clone()", + "created_at: now", + "updated_at: now", + ] { + assert!(body.contains(required), "创建主题事务缺少:{required}"); + } + // 重放必须按收据里那个主题 ID 取回原行(把 ID 丢掉就等于把重放变成二次创建)。 + assert!( + body.contains("find(&created_theme_id)"), + "重放必须按收据记下的主题 ID 取回原行" + ); + let receipt_at = body + .find("find_game_distribution_receipt") + .expect("必须判幂等收据"); + let insert_at = body.find(".insert(theme.clone())").expect("必须写主题行"); + assert!(receipt_at < insert_at, "必须先判收据再写库"); + // 一次调用最多插一行:重放走的是 return,不是第二次 insert。 + assert_eq!(body.matches(".insert(").count(), 1, "主题行只能插一次"); + // api-server 生成的 ID 撞车(调用方自己拼 ID)必须失败关闭,而不是覆盖别人的主题。 + assert!(body.contains("主题 ID 已存在")); + assert!(body.contains("theme_bad_request")); + } + + /// 后台更新主题:404 先于收据判定(ID 打错永远 404),重放**在刷新 `updated_at` 之前返回** + /// (不写库、不刷新时间),只有真正生效的更新才整组覆盖字段并写收据。 + #[test] + fn admin_theme_update_refreshes_updated_at_only_on_effective_write() { + let source = include_str!("game_distribution.rs"); + let body = function_body(source, "fn update_game_distribution_theme_tx("); + for required in [ + "GAME_DISTRIBUTION_ACTION_UPDATE_THEME", + "ensure_game_distribution_theme_receipt_digest", + "ensure_game_distribution_theme_write_fields", + "theme.name = name", + "theme.summary = summary", + "theme.badge = badge", + "theme.sort_order = input.sort_order", + "theme.status = status", + "theme.updated_at = Timestamp::from_micros_since_unix_epoch(input.now_micros)", + ] { + assert!(body.contains(required), "更新主题事务缺少:{required}"); + } + let not_found_at = body + .find("GameDistributionError::ThemeNotFound") + .expect("必须按主题 ID 判 404"); + let receipt_at = body + .find("find_game_distribution_receipt") + .expect("必须判幂等收据"); + assert!(not_found_at < receipt_at, "主题不存在必须先于摘要冲突判定"); + let replay_return_at = body + .find("return Ok((game_distribution_admin_theme_snapshot(ctx, &theme), true));") + .expect("重放分支必须显式返回"); + let refresh_at = body + .find("theme.updated_at =") + .expect("必须刷新 updated_at"); + assert!( + replay_return_at < refresh_at, + "重放必须先于刷新返回:重放不写库、不刷新 updated_at" + ); + // 覆盖式更新只 `update` 既有行,不插新行,也不删任何行。 + assert_eq!(body.matches(".update(").count(), 1); + assert!(!body.contains(".insert(")); + assert!(!body.contains(".delete(")); + } + + /// 后台增 / 改成员:确定性主键决定「同主题同根一行」,重复 PUT 只改 `sort_order` 且 + /// **不动 `created_at`**;判定顺序是 主题 → 作品 → 是否根;不做任何「必须公开」的前置校验。 + #[test] + fn admin_theme_member_upsert_is_idempotent_and_root_only() { + let source = include_str!("game_distribution.rs"); + let body = function_body(source, "fn upsert_game_distribution_theme_member_tx("); + for required in [ + "module_game_distribution::game_distribution_theme_member_id", + ".member_id()", + "find(&member_id)", + "module_game_distribution::game_distribution_theme_root_acceptable", + "GameDistributionError::ThemeMemberNotRoot", + "GameDistributionError::ThemeMemberGameNotFound", + "GameDistributionError::ThemeNotFound", + "updated.sort_order = input.sort_order", + ] { + assert!(body.contains(required), "增改成员事务缺少:{required}"); + } + // 重复 PUT 不刷新 `created_at`:它是「什么时候挂上来的」,不该被一次重复调用改写。 + assert!( + !body.contains("updated.created_at ="), + "重复 PUT 不得改写 created_at" + ); + // 一行只可能被插一次、被 update 一次;成员行在这条路径上永不被删。 + assert_eq!(body.matches(".insert(").count(), 1); + assert_eq!(body.matches(".update(").count(), 1); + assert!(!body.contains(".delete(")); + // 判定顺序:主题存在 → 作品存在 → 是否根(反了就分不清「ID 打错」与「该挂根」)。 + let theme_at = body + .find("GameDistributionError::ThemeNotFound") + .expect("必须判主题存在"); + let game_at = body + .find("GameDistributionError::ThemeMemberGameNotFound") + .expect("必须判作品存在"); + let root_at = body + .find("GameDistributionError::ThemeMemberNotRoot") + .expect("必须判是否根"); + assert!(theme_at < game_at, "主题存在必须先于作品存在"); + assert!(game_at < root_at, "作品存在必须先于是否根"); + // 「是不是根」只能由血缘行给出。 + assert!(body.contains(".game_distribution_lineage()")); + assert!(body.contains(".game_id()")); + // **不做**公开性前置校验:草稿 / 下架 / 软删除的作品也要能先挂上(公开后自动出现)。 + assert!( + !body.contains("visibility") && !body.contains("deleted_at"), + "不得把「作品当前是否公开」当成入成员的前置条件" + ); + assert!(!body.contains("game_distribution_theme_member_visible")); + } + + /// 后台移除成员:按确定性主键删一行,成员不存在也算成功(主题不存在才是 404); + /// 整份模块里**只有这里**会删成员行——归档 / 下架都不清理。 + #[test] + fn admin_theme_member_remove_is_idempotent_and_never_cascades() { + let source = include_str!("game_distribution.rs"); + let body = function_body(source, "fn remove_game_distribution_theme_member_tx("); + assert!(body.contains("module_game_distribution::game_distribution_theme_member_id")); + assert!(body.contains("find(&member_id)")); + // 断言用的针在源码里拼出来,避免 `source.matches` 把本测试自己的字面量也算进去。 + let deletion = [".delete(&", "member_id)"].concat(); + assert!(body.contains(deletion.as_str())); + assert!( + body.contains("GameDistributionError::ThemeNotFound"), + "主题不存在仍是 404:路径里的主题 ID 错了,与「成员早删掉了」是两回事" + ); + // 只有主题不存在会 `return Err`:成员不存在不是错误,直接走成功路径。 + assert_eq!(body.matches("return Err(").count(), 1); + // 不做批量清理(不按主题索引扫成员),也不因为主题归档而级联。 + assert!(!body.contains("by_game_distribution_theme_member_theme_id")); + assert_eq!( + source.matches(deletion.as_str()).count(), + 1, + "整份模块里只有后台显式移除会删成员行" + ); + // 改状态(含归档)的事务完全不碰成员表。 + let update = function_body(source, "fn update_game_distribution_theme_tx("); + assert!( + !update.contains("theme_member") && !update.contains("ThemeMember"), + "归档只是「不再公开」,不得清理成员行" + ); + } + + /// 后台主题列表:含 `draft` / `archived`(**不**套公开可见性过滤),排序复用公开列表同一份 + /// 全序比较器,超界截断,非法过滤值失败关闭。 + #[test] + fn admin_theme_list_keeps_drafts_and_shares_the_public_comparator() { + let source = include_str!("game_distribution.rs"); + let body = function_body(source, "fn list_admin_game_distribution_themes_tx("); + for required in [ + "GAME_DISTRIBUTION_ADMIN_THEME_LIST_LIMIT", + "GAME_DISTRIBUTION_THEME_ADMIN_STATUS_ALL", + "module_game_distribution::game_distribution_theme_admin_status_filter_valid", + "module_game_distribution::sort_public_themes", + ".take(limit)", + ] { + assert!(body.contains(required), "后台主题列表缺少:{required}"); + } + assert!( + !body.contains("game_distribution_theme_public_visible"), + "后台列表必须含 draft / archived,不能套公开可见性过滤" + ); + assert!( + !body.contains("game_distribution_theme_member_visible"), + "成员可见性不参与后台列表" + ); + assert!( + body.contains("return Err("), + "非法 status 过滤值必须失败关闭,而不是悄悄按全量返回" + ); + } + + /// 后台快照的 `member_count` 是**成员行总数**,公开投影的 `member_count` 仍是可见成员数: + /// 两个函数刻意不同,合并任意一个方向都会出事(后台看不见自己的草稿成员 / 公开侧泄露它)。 + #[test] + fn admin_theme_snapshot_counts_member_rows_not_visible_members() { + let source = include_str!("game_distribution.rs"); + let snapshot = function_body(source, "fn game_distribution_admin_theme_snapshot("); + assert!(snapshot.contains("game_distribution_theme_member_row_count")); + assert!( + !snapshot.contains("game_distribution_theme_visible_members"), + "后台计数不按公开可见性过滤" + ); + assert!(snapshot.contains("sort_order: theme.sort_order")); + assert!(snapshot.contains("status: theme.status.clone()")); + let row_count = function_body(source, "fn game_distribution_theme_member_row_count("); + assert!( + row_count.contains("by_game_distribution_theme_member_theme_id()"), + "按主题索引计数,不扫全表" + ); + assert!(row_count.contains(".count()")); + let public_count = function_body(source, "fn game_distribution_theme_member_count("); + assert!( + public_count.contains("game_distribution_theme_visible_members"), + "公开计数仍然只算可见成员" + ); + } + + /// 五条后台主题路由对应的 procedure:都必须要求受信服务身份(HTTP 层的 admin 会话是另一条), + /// 且失败路径显式给出空结果。 + #[test] + fn admin_theme_procedures_require_service_identity_and_fail_closed() { + let source = include_str!("game_distribution.rs"); + for marker in [ + "pub fn create_game_distribution_theme_and_return(", + "pub fn update_game_distribution_theme_and_return(", + "pub fn upsert_game_distribution_theme_member_and_return(", + "pub fn remove_game_distribution_theme_member_and_return(", + "pub fn list_admin_game_distribution_themes_and_return(", + ] { + let body = function_body(source, marker); + assert!( + body.contains("require_editor_generation_runtime_service_identity(tx, caller)?"), + "{marker} 必须要求受信服务身份" + ); + assert!( + body.contains("ctx.try_with_tx("), + "{marker} 必须在单一事务里完成" + ); + } + // 失败路径显式给空:不靠 `Default`、也不回一个「看起来成功」的空间。 + let failed_result = |marker: &str, field: &str| { + let start = source + .find(marker) + .unwrap_or_else(|| panic!("{marker} 必须存在")); + let tail = &source[start..]; + let end = tail.find("\nimpl ").unwrap_or(tail.len()); + assert!( + tail[..end].contains(field), + "{marker} 的失败路径必须显式给出 {field}" + ); + }; + failed_result( + "impl GameDistributionAdminThemeMutationResult {", + "theme: None", + ); + failed_result( + "impl GameDistributionAdminThemeMemberMutationResult {", + "member: None", + ); + failed_result( + "impl GameDistributionAdminThemeListResult {", + "themes: Vec::new()", + ); + // 创建 / 更新的成功路径必须把 `replayed` 透传给 handler(否则重放会被说成新建)。 + let create_procedure = + function_body(source, "pub fn create_game_distribution_theme_and_return("); + assert!(create_procedure.contains("Ok((theme, replayed))")); + assert!(create_procedure.contains("replayed,")); + } + + /// 后台主题写接口的错误码一律来自领域错误类型(`THEME_BAD_REQUEST` / `THEME_NOT_FOUND` / …), + /// 不在事务里手写前缀字符串——手写就会与 api-server 的映射表各走一套。 + #[test] + fn admin_theme_write_errors_come_from_the_domain_error_type() { + let source = include_str!("game_distribution.rs"); + for marker in [ + "fn create_game_distribution_theme_tx(", + "fn update_game_distribution_theme_tx(", + "fn upsert_game_distribution_theme_member_tx(", + "fn remove_game_distribution_theme_member_tx(", + "fn list_admin_game_distribution_themes_tx(", + ] { + let body = function_body(source, marker); + assert!( + !body.contains("\"THEME_"), + "{marker} 里的错误码必须来自领域错误类型,不是手写字符串" + ); + } + // 文本校验只走共享纯函数:事务里不出现第二套长度判据。 + let fields = function_body(source, "fn ensure_game_distribution_theme_write_fields("); + assert!(fields.contains("game_distribution_theme_text_violation")); + assert!(fields.contains("game_distribution_theme_status_valid")); + assert!(fields.contains("theme_bad_request")); + // 收据判定的三处幂等入口:冲突必须换成主题自己的码(`THEME_IDEMPOTENCY_CONFLICT`), + // 但**判据**仍然只有一份(复用共享的收据摘要比较)。 + let digest_guard = + function_body(source, "fn ensure_game_distribution_theme_receipt_digest("); + assert!( + digest_guard.contains("ensure_game_distribution_receipt_digest("), + "冲突判据必须复用共享收据判定,不能长出第二套「什么算同一次请求」" + ); + assert!( + digest_guard.contains("GameDistributionError::ThemeIdempotencyConflict"), + "摘要不一致必须换成 THEME_IDEMPOTENCY_CONFLICT" + ); + let update = function_body(source, "fn update_game_distribution_theme_tx("); + assert!(update.contains("ensure_game_distribution_theme_receipt_digest")); + let create = function_body(source, "fn create_game_distribution_theme_tx("); + assert!(create.contains("ensure_game_distribution_theme_receipt_digest")); + } }