From 576c28354b37389ec9f64bb424e353f7aad35e49 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 7 Oct 2026 14:47:06 +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=AC=E5=BC=80=E7=9B=AE=E5=BD=95=E6=94=AF=E6=8C=81=20forkab?= =?UTF-8?q?le=20=E8=BF=87=E6=BB=A4=EF=BC=88=E5=8F=AA=E5=88=97=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=85=B1=E5=88=9B=E7=9A=84=E4=BD=9C=E5=93=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `forkable=1/true`(大小写不敏感、先 trim)→ 只返回 `fork_authorization != forbidden` 的公开作品; 省略 / 空 / `0` / `false` / 非法取值一律按「未过滤」(宽容降级 = 现状:公开只读列表里的可选展示过滤 不该让整个游戏广场 400,降级只会少列不会多列)。 - 过滤落在**事务内、切页之前**(api-server 只解析参数、不对返回结果做过滤)⇒ forbidden 不占页位、 翻页不重不漏、末页仍为 `null`;分页与游标语义、响应形状均不变(**无新字段**,`forkAuthorization` 本来就在 公开投影里)。 - 判据只有一份实现:`ForkAuthorization::stored_allows_fork`(未知档位按「禁止」解释,与 `/fork-source` 403 同源)。 - 测试 +9:module 6(含「9 条里夹 4 条 forbidden、页大小 2 → 3 页且游标序列 / 末页 None / forbidden 不占位」、 真值集合精确、未知档位失败关闭、不可见行不被 resurrect、未设置时逐条相等)+ api-server 2 + 结构断言 1。 - 文档 §3.4 目录行补 `&forkable=`、语义与宽容降级理由(该文档改动已由 peer 提交进 `67f691784`)。 门禁:`cargo check --all-targets` 0;`cargo test -p module-game-distribution` **149 passed**; `-p spacetime-module` **308 passed / 1 ignored**;`-p api-server game_distribution` **114 passed**; DTO parity 66 组 / 19 构建器 / 15 手拼类型;`check:spacetime-schema` 0(98 表);`check:encoding` 0(5641 files); `cargo fmt --all -- --check` 0;`git diff --check` 0。 --- .../src/modules/game_distribution.rs | 195 ++++++++++++++- .../module-game-distribution/src/catalog.rs | 222 ++++++++++++++++++ .../module-game-distribution/src/domain.rs | 36 +++ .../module-game-distribution/src/lib.rs | 6 +- .../spacetime-client/src/game_distribution.rs | 7 + ...istribution_public_game_list_input_type.rs | 1 + .../spacetime-module/src/game_distribution.rs | 83 ++++++- 7 files changed, 541 insertions(+), 9 deletions(-) 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 cf6ebbb52..ba5b62421 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -210,7 +210,7 @@ impl ReleasePackageCache { } } -/// 公开游戏目录的查询串:关键词 / 分类 / 作者过滤 + `limit` + `cursor`。 +/// 公开游戏目录的查询串:关键词 / 分类 / 作者 / `forkable` 过滤 + `limit` + `cursor`。 /// /// 分页口径独立于 `/my-collections`(20 / 50)与主题列表(20 / 50):目录是广场首屏,缺省保持 /// 既有的 **48**、上限 **100**(都是模块侧常量,这里不抄第二份数字)。`cursor` 只透传字符串—— @@ -224,6 +224,13 @@ struct GameListQuery { author_id: Option, limit: Option, cursor: Option, + /// `forkable` = 只返回可被共创的作品(AGC「共创」页用)。 + /// + /// 这里刻意收成 `Option` 而不是 `Option`:serde 的 `bool` 解析会把 + /// `forkable=abc` 变成 axum 的 400 纯文本(连平台信封都没有),而本参数的既定口径是 + /// **宽容降级**(非法取值按「不过滤」处理,见 `parse_forkable_flag`)。用字符串接收、 + /// 再用一个可单测的纯函数解释,才能让「拼错一个可选参数」回到现状而不是让整个公开目录 400。 + forkable: Option, } #[derive(Debug, Deserialize)] @@ -1472,11 +1479,13 @@ fn release_asset_not_modified_response(etag: &str, cache_control: &'static str) response } -/// 公开游戏目录:`GET /api/game-distribution/games?search=&category=&authorId=&limit=&cursor=`。 +/// 公开游戏目录:`GET /api/game-distribution/games?search=&category=&authorId=&limit=&cursor=&forkable=`。 /// /// 匿名可读;只含公开可玩的作品。**真游标分页**:`limit` 缺省 48(保持既有首屏语义)、上限 100、 /// 超界**截断**(客户端拿到一个完整页,而不是一个需要重试的错误);游标格式非法由模块侧报错, /// 这里透传成 400 + 稳定码 `CATALOG_INVALID_CURSOR`(不吞掉、也不自己造一种 200 的空页)。 +/// `forkable` 为真时只返回可被共创的作品;过滤在事务里、**切页之前**完成,本层只解析参数 +/// (口径见 `parse_forkable_flag`)。 /// /// 修正前这里写死 `limit = 48` 且把 `nextCursor` 恒置 `null`:库里第 49 条起的作品(含最老的 /// 母版与主干)在 HTTP 面永久不可见。`nextCursor` 现在由事务给出的真实游标决定,末页为 `null`。 @@ -1496,6 +1505,10 @@ async fn list_games( // 与模块侧同一套归一化(同一函数),因此日志里的 `limit` 就是事务真正生效的页大小。 let limit = public_games_page_limit(query.limit); let cursor = normalize_optional(query.cursor); + // `forkable` 的过滤**不在这一层做**:逐页过滤返回结果会让被滤掉的行凭空占掉页名额,下一页 + // 游标又指回过滤前的序列,于是每翻一页都漏掉自己的若干条。这里只把「要不要过滤」的结论 + // 交给事务,过滤与切页在同一处、同一序(见 `list_public_game_distribution_games_tx`)。 + let forkable = parse_forkable_flag(query.forkable.as_deref()); let (games, next_cursor) = state .spacetime_client() .list_game_distribution_games(GameDistributionPublicGameListRecordInput { @@ -1504,6 +1517,7 @@ async fn list_games( limit, author_id, cursor: cursor.clone(), + forkable, }) .await .map_err(map_spacetime_error)?; @@ -1515,6 +1529,7 @@ async fn list_games( max_limit = GAME_DISTRIBUTION_CATALOG_PAGE_LIMIT_MAX, has_cursor = cursor.is_some(), has_more = next_cursor.is_some(), + forkable, elapsed_ms = ctx.elapsed(), "读取公开游戏目录" ); @@ -1549,6 +1564,24 @@ fn public_games_page_limit(limit: Option) -> u32 { ) as u32 } +/// 查询串 `forkable` → 「只返回可被共创的作品」开关:真值(`1` / `true`,大小写不敏感、先 trim) +/// 为 `true`;**其余一切取值一律 `false` = 不过滤**。 +/// +/// 「其余」包括省略、空串、`0` / `false`、以及拼错的取值(`abc`、`yes`、`2`)。 +/// +/// 为什么非法取值**宽容降级**而不是像 `status` 白名单那样 400:目录是**公开只读列表**,而 +/// `forkable` 是**可选**的展示过滤,不是一个能改变权限或可见性的开关。客户端(尤其是旧版 AGC +/// 外壳)把参数拼错一次就让整个游戏广场 400,代价远大于收益;而「不过滤」正好等于这个参数 +/// 出现之前的既有行为,因此降级既不会多吐出任何东西(相对现状只会少列、不会多列),也不会改变 +/// 任何既有调用方的语义——它只是把这个可选参数忽略掉。相比之下失败关闭在这里表达不出任何安全 +/// 意图,只会把「参数拼错」升级成「目录不可用」。 +fn parse_forkable_flag(value: Option<&str>) -> bool { + matches!( + value.map(str::trim).map(str::to_ascii_lowercase).as_deref(), + Some("1") | Some("true") + ) +} + /// 解析公开投影的查看者:可选鉴权读取当前用户,决定 `purchased` 与付费入口可见性。 /// /// 无令牌、坏令牌、管理令牌与读取购买记录失败都不阻断公开详情:坏令牌按匿名、读失败按未购买 @@ -9680,6 +9713,164 @@ mod tests { assert_eq!(GAME_DISTRIBUTION_CATALOG_PAGE_LIMIT_MAX, 100); } + /// 查询串 `forkable` 的口径:真值 = `1` / `true`(大小写不敏感、先 trim);**其余一律 false** + /// (省略 / 空 / `0` / `false` / 拼错),因此非法取值不会让公开目录 400,而是回到现状。 + #[test] + fn public_games_forkable_flag_is_lenient_about_every_other_value() { + for truthy in ["1", "true", "TRUE", "True", " true ", "1 ", "\ttrue"] { + assert!( + parse_forkable_flag(Some(truthy)), + "真值必须开过滤:{truthy:?}" + ); + } + for falsy in [ + None, + Some(""), + Some(" "), + Some("0"), + Some("false"), + Some("FALSE"), + Some("abc"), + Some("yes"), + Some("2"), + Some("-1"), + Some("true,false"), + ] { + assert!( + !parse_forkable_flag(falsy), + "非真值一律按「不过滤」(= 现状行为):{falsy:?}" + ); + } + } + + /// `forkable=1` 的目录**先过滤、再切页**:禁止共创与不可见的作品都不占页名额,翻页不重不漏、 + /// 末页 `nextCursor` 为 `null`;非法取值 `abc` 的返回集合与「不带该参数」**完全相同**。 + /// + /// api-server 的单测不起真库,因此这里与既有「51 条翻到底」那条用同一手法:用 handler 自己的 + /// `public_games_page_limit` 与共享纯函数复现事务里的顺序(可见性过滤 → forkable 过滤 → + /// 排序切页),逐页过一遍 `public_games_payload` 断言响应形状。 + #[test] + fn public_games_forkable_filters_before_paging_and_degrades_on_bad_values() { + // (game_id, created_at, 授权, 公开可见) + let rows = vec![ + ("game_forbidden_newest", 9_800, "forbidden", true), + ("game_full", 9_700, "full", true), + ("game_draft_full", 9_650, "full", false), // 未公开 + ("game_non_commercial", 9_600, "nonCommercial", true), + ("game_deleted_full", 9_550, "full", false), // 已软删除 + ("game_forbidden_middle", 9_500, "forbidden", true), + ("game_no_version_full", 9_450, "full", false), // 没有当前公开版本 + ("game_full_oldest", 9_400, "full", true), + ]; + let visible = rows + .into_iter() + .filter(|(_, _, _, visible)| *visible) + .map(|(game_id, created_at_micros, authorization, _)| { + module_game_distribution::GameDistributionCatalogPageItem { + game_id: game_id.to_string(), + created_at_micros, + payload: (game_id.to_string(), authorization.to_string()), + } + }) + .collect::>(); + assert_eq!(visible.len(), 5, "8 条里 5 条公开可见"); + + let page_size = public_games_page_limit(Some(2)); + assert_eq!(page_size, 2); + + // 按参数的**解析结论**驱动同一段翻页逻辑:过滤在切页之前。 + let page_all = |forkable: bool| { + let items = + module_game_distribution::filter_public_game_distribution_catalog_by_forkable( + visible.clone(), + forkable, + |row: &(String, String)| row.1.as_str(), + ); + let mut seen: Vec = Vec::new(); + let mut cursors: Vec> = Vec::new(); + let mut cursor = None; + loop { + let (page, next_cursor) = + module_game_distribution::page_public_game_distribution_catalog( + items.clone(), + cursor, + page_size, + ); + let body = public_games_payload(Vec::new(), next_cursor.clone()); + assert!( + body.get("games").is_some() && body.get("nextCursor").is_some(), + "每页都必须带 games 与 nextCursor:{body}" + ); + if next_cursor.is_none() { + assert_eq!( + body["nextCursor"], + Value::Null, + "末页 nextCursor 必须是 null" + ); + } + seen.extend(page.into_iter().map(|row| row.0)); + cursors.push(next_cursor.clone()); + match next_cursor { + Some(value) => { + cursor = Some( + module_game_distribution::parse_game_distribution_catalog_cursor( + &value, + ) + .expect("服务端产出的游标必须能被自己解析"), + ) + } + None => break, + } + } + (seen, cursors) + }; + + let (forkable_seen, forkable_cursors) = page_all(parse_forkable_flag(Some("1"))); + assert_eq!( + forkable_seen, + vec!["game_full", "game_non_commercial", "game_full_oldest",], + "只含允许共创的作品,且顺序仍是创建时间倒序、不重不漏" + ); + assert_eq!( + forkable_cursors, + vec![Some("9600:game_non_commercial".to_string()), None], + "游标只指向这一页真正回传的最后一条;forbidden 与不可见行都不参与定位" + ); + for dropped in [ + "game_forbidden_newest", + "game_forbidden_middle", + "game_draft_full", + "game_deleted_full", + "game_no_version_full", + ] { + assert!( + !forkable_seen.iter().any(|game_id| game_id == dropped), + "禁止共创 / 不可见的作品不得出现在任何一页:{dropped}" + ); + } + + // ① 不带 `forkable`(与 `0` / `false` / 非法取值同一支)在**可见集**上与改动前逐条相同。 + let (unfiltered_seen, _) = page_all(parse_forkable_flag(None)); + assert_eq!( + unfiltered_seen, + vec![ + "game_forbidden_newest", + "game_full", + "game_non_commercial", + "game_forbidden_middle", + "game_full_oldest", + ], + "不传 `forkable` 必须等于既有目录行为(forbidden 照旧在列)" + ); + for falsy in [Some("0"), Some("false"), Some("abc"), Some("")] { + assert_eq!( + page_all(parse_forkable_flag(falsy)).0, + unfiltered_seen, + "非真值必须按未过滤处理:{falsy:?}" + ); + } + } + /// 公开目录响应形状:`games` 与 `nextCursor` 两个键一定发出;游标是真实值,最后一页为 `null`。 #[test] fn public_games_payload_carries_real_next_cursor() { diff --git a/server-rs/crates/module-game-distribution/src/catalog.rs b/server-rs/crates/module-game-distribution/src/catalog.rs index c077b83bc..762a624b6 100644 --- a/server-rs/crates/module-game-distribution/src/catalog.rs +++ b/server-rs/crates/module-game-distribution/src/catalog.rs @@ -8,6 +8,8 @@ //! 数据一直在、详情与 `/lineage` 都读得到,缺的只是「翻到下一页」的通道。这里补上游标分页, //! 并沿用仓库既有的 `"{i64}:{id}"` 游标惯例(与主题列表 / 收藏列表同一套),不另立第二套语义。 +use crate::domain::ForkAuthorization; + /// 公开目录一页的默认条数。 /// /// 取 48 是**保持既有首屏语义**:现网 AGC 共创页首屏就是 48 条(客户端按这个数显示「已检查 @@ -157,6 +159,33 @@ pub fn page_public_game_distribution_catalog( ) } +/// 公开目录的 `forkable` 过滤:`forkable` 为真时**只留下允许共创的作品**,否则原样返回。 +/// +/// 这是 `/api/game-distribution/games?forkable=1`(AGC「共创」页)的判据。它与可见性过滤同一条 +/// 纪律,**必须在切页之前**调用:先把序列按 `forkable` 收窄、再交给 +/// `page_public_game_distribution_catalog`,被滤掉的 `forbidden` 作品才不会凭空占掉名额、也不会 +/// 让下一页游标指回过滤前的序列(那种顺序会让每翻一页就漏掉自己的若干条)。 +/// +/// `authorization_of` 由调用方给出「这一条作品的原始档位字符串怎么取」——分页项对负载是泛型的, +/// 本 crate 不依赖 SpacetimeDB 行类型;判据本身复用 [`ForkAuthorization::stored_allows_fork`], +/// 因此「未知档位按禁止解释」这一条与 fork 门禁同源、只有一份实现。 +/// +/// `forkable` 为假时**直接返回原序列**(不做一次空过滤):`false` / 省略参数 / 非法取值在 +/// api-server 侧都归一成假,因此「不传 `forkable`」的行为就是本函数出现之前的既有目录行为。 +pub fn filter_public_game_distribution_catalog_by_forkable( + items: Vec>, + forkable: bool, + authorization_of: impl Fn(&T) -> &str, +) -> Vec> { + if !forkable { + return items; + } + items + .into_iter() + .filter(|item| ForkAuthorization::stored_allows_fork(authorization_of(&item.payload))) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -408,4 +437,197 @@ mod tests { assert_eq!(rest, vec!["game_c"]); assert_eq!(last, None); } + + /// `forkable` 过滤的负载:`(game_id, 作品行里的原始授权档位字符串)`。 + fn forkable_item( + game_id: &str, + created_at_micros: i64, + authorization: &str, + ) -> GameDistributionCatalogPageItem<(String, String)> { + GameDistributionCatalogPageItem { + game_id: game_id.to_string(), + created_at_micros, + payload: (game_id.to_string(), authorization.to_string()), + } + } + + fn filter_forkable( + items: Vec>, + forkable: bool, + ) -> Vec> { + filter_public_game_distribution_catalog_by_forkable(items, forkable, |row| row.1.as_str()) + } + + fn game_ids(items: &[GameDistributionCatalogPageItem<(String, String)>]) -> Vec { + items.iter().map(|item| item.payload.0.clone()).collect() + } + + /// `forkable = false`(省略参数 / `0` / `false` / 非法取值在 api-server 侧都归一成假) + /// **就是本函数出现之前的既有行为**:一条不少、一条不多,连 `forbidden` 也照旧在列。 + #[test] + fn forkable_filter_is_an_exact_no_op_when_unset() { + let items = vec![ + forkable_item("game_a", 300, "forbidden"), + forkable_item("game_b", 200, "nonCommercial"), + forkable_item("game_c", 100, "full"), + ]; + let kept = filter_forkable(items.clone(), false); + assert_eq!( + kept, items, + "不传 forkable 必须与改动前逐条相同(含 forbidden)" + ); + assert_eq!(game_ids(&kept), vec!["game_a", "game_b", "game_c"]); + } + + /// `forkable = true` 的集合**精确**等于「档位允许共创」的那些条目:`forbidden` 一条都不留, + /// 允许共创的档位一条都不少(不是「至少少了 forbidden」这种宽松断言)。 + #[test] + fn forkable_filter_keeps_exactly_the_fork_enabled_rows() { + let items = vec![ + forkable_item("game_forbidden", 500, "forbidden"), + forkable_item("game_non_commercial", 400, "nonCommercial"), + forkable_item("game_full", 300, "full"), + forkable_item("game_forbidden_oldest", 200, "forbidden"), + ]; + let kept = filter_forkable(items, true); + assert_eq!( + game_ids(&kept), + vec!["game_non_commercial", "game_full"], + "过滤不排序:只收窄集合,顺序仍由 page_public_game_distribution_catalog 决定" + ); + let kept_ids = kept + .iter() + .map(|item| item.payload.0.clone()) + .collect::>(); + assert_eq!( + kept_ids, + ["game_non_commercial".to_string(), "game_full".to_string()] + .into_iter() + .collect::>(), + "留下的集合必须与被允许共创的条目一一对应" + ); + } + + /// **未知档位失败关闭**:解析不出的档位按「禁止共创」解释,与 fork 门禁 + /// (`FORK_NOT_AUTHORIZED`)同一判据——否则共创页会列出一个点进去必然 403 的作品。 + #[test] + fn forkable_filter_fails_closed_on_unknown_authorization() { + for unknown in ["", " ", "allowed", "FULL", "non_commercial", "forbidden "] { + let items = vec![ + forkable_item("game_known", 100, "full"), + forkable_item("game_unknown", 200, unknown), + ]; + let kept = filter_forkable(items, true); + assert_eq!( + game_ids(&kept), + vec!["game_known"], + "未知档位必须按禁止处理:authorization={unknown:?}" + ); + } + // 前后空白是 `ForkAuthorization::parse` 允许的写法,不算未知。 + let kept = filter_forkable(vec![forkable_item("game_padded", 100, " full ")], true); + assert_eq!(game_ids(&kept), vec!["game_padded"]); + } + + /// **先过滤、再切页**:`forbidden` 混在中间时不占页位,翻页不重不漏,末页游标为 `None`。 + /// + /// 若顺序反过来(先切页再过滤),第一页会因 `forbidden` 占位而少回传,下一页的游标又指回 + /// 过滤前的序列,于是每一页都漏掉自己的若干条。这里用 9 条(4 条 `forbidden` 夹在中间) + /// 逐页翻到底,断言总条数 = 5、顺序严格是「创建时间倒序」、且游标只指向真正回传的最后一条。 + #[test] + fn forkable_filter_happens_before_paging_so_forbidden_rows_never_take_a_slot() { + let raw = vec![ + forkable_item("game_00", 900, "forbidden"), + forkable_item("game_01", 800, "full"), + forkable_item("game_02", 700, "forbidden"), + forkable_item("game_03", 600, "nonCommercial"), + forkable_item("game_04", 500, "forbidden"), + forkable_item("game_05", 400, "full"), + forkable_item("game_06", 300, "forbidden"), + forkable_item("game_07", 200, "nonCommercial"), + forkable_item("game_08", 100, "full"), + ]; + let visible = filter_forkable(raw, true); + assert_eq!(visible.len(), 5, "9 条里只有 5 条允许共创"); + + let mut seen: Vec = Vec::new(); + let mut nexts: Vec> = Vec::new(); + let mut cursor = None; + loop { + let (page, next) = page_public_game_distribution_catalog(visible.clone(), cursor, 2); + let ids = page.iter().map(|row| row.0.clone()).collect::>(); + match next { + Some(_) => assert_eq!(ids.len(), 2, "过滤后仍有余量时必须填满 limit 条"), + None => assert_eq!(ids.len(), 1, "最后 1 条"), + } + seen.extend(ids); + nexts.push(next.clone()); + match next { + Some(value) => { + cursor = Some(parse_game_distribution_catalog_cursor(&value).unwrap()) + } + None => break, + } + } + assert_eq!(nexts.len(), 3, "5 条按页大小 2 分页应为 3 页"); + assert_eq!( + nexts, + vec![ + Some("600:game_03".to_string()), + Some("200:game_07".to_string()), + None, + ], + "游标只指向这一页真正回传的最后一条,forbidden 不参与定位" + ); + assert!( + nexts.last().expect("至少一页").is_none(), + "末页 nextCursor 必须是 null" + ); + assert_eq!( + seen, + vec!["game_01", "game_03", "game_05", "game_07", "game_08"], + "翻页必须不重不漏" + ); + let unique = seen.iter().collect::>(); + assert_eq!(unique.len(), seen.len(), "同一作品不得跨页重复"); + for forbidden in ["game_00", "game_02", "game_04", "game_06"] { + assert!( + !seen.iter().any(|game_id| game_id == forbidden), + "禁止共创的作品不得出现在任何一页:{forbidden}" + ); + } + } + + /// `forkable` 不改变**可见性**口径:调用方先按可见性(已发布 / 未软删 / 有当前公开版本) + /// 过滤,再叠加 `forkable`,未公开 / 已软删 / 无公开版本的作品即使授权允许共创也不得出现。 + #[test] + fn forkable_filter_does_not_resurrect_invisible_rows() { + let raw = vec![ + // (game_id, created_at, 授权, 可见) + ("game_visible_full", 900, "full", true), + ("game_draft_full", 800, "full", false), // 未公开 + ("game_deleted_full", 700, "full", false), // 已软删除 + ("game_no_version_full", 600, "full", false), // 没有当前公开版本 + ("game_visible_forbidden", 500, "forbidden", true), + ]; + let visible = raw + .into_iter() + .filter(|(_, _, _, visible)| *visible) + .map(|(game_id, created_at_micros, authorization, _)| { + forkable_item(game_id, created_at_micros, authorization) + }) + .collect::>(); + let kept = filter_forkable(visible, true); + assert_eq!(game_ids(&kept), vec!["game_visible_full"]); + for invisible in [ + "game_draft_full", + "game_deleted_full", + "game_no_version_full", + ] { + assert!( + !game_ids(&kept).iter().any(|game_id| game_id == invisible), + "可见性口径不得被 forkable 放宽:{invisible}" + ); + } + } } diff --git a/server-rs/crates/module-game-distribution/src/domain.rs b/server-rs/crates/module-game-distribution/src/domain.rs index 5ba156065..a2551fd1e 100644 --- a/server-rs/crates/module-game-distribution/src/domain.rs +++ b/server-rs/crates/module-game-distribution/src/domain.rs @@ -257,6 +257,17 @@ impl ForkAuthorization { !matches!(self, Self::Forbidden) } + /// 作品行里的**原始存储值**是否允许他人共创(公开目录 `forkable` 过滤的判据)。 + /// + /// 与 fork 门禁(`resolve_game_distribution_fork_declaration_tx` 的 `parse(...).unwrap_or( + /// Forbidden).allows_fork()`)是同一判据:**解析不出的取值按禁止解释**。若这里改用 + /// 「原始字符串 `!= "forbidden"`」,一个未知档位的作品就会被列进共创页,点进去却必然回 + /// 403 `FORK_NOT_AUTHORIZED`——目录不该给出一个点了必失败的入口。写库侧本来就失败关闭 + /// (创建与提升都拒绝未知档位),所以这条收敛不会与任何既有行的行为产生差异。 + pub fn stored_allows_fork(value: &str) -> bool { + Self::parse(value).is_some_and(Self::allows_fork) + } + /// 目标档位是否合法:只能提升,同级视为无变化,降级一律拒绝。 pub fn can_promote_to(self, target: Self) -> bool { target.rank() > self.rank() @@ -634,6 +645,31 @@ mod fork_authorization_tests { assert!(ForkAuthorization::Full.allows_fork()); } + /// 存储值判据(公开目录 `forkable` 过滤用):三个合法档位按开放度作答,**未知取值按禁止**。 + /// + /// 第二条断言是这条判据的全部价值:写库侧本来就失败关闭,因此库里的未知取值只可能来自更早的 + /// 历史或未来新增档位;此时目录必须与 fork 门禁做出同一个判断,不把「点了必然 403」的作品 + /// 列进共创页。 + #[test] + fn stored_values_allow_fork_only_for_the_known_open_tiers() { + assert!(!ForkAuthorization::stored_allows_fork( + FORK_AUTHORIZATION_FORBIDDEN + )); + assert!(ForkAuthorization::stored_allows_fork( + FORK_AUTHORIZATION_NON_COMMERCIAL + )); + assert!(ForkAuthorization::stored_allows_fork( + FORK_AUTHORIZATION_FULL + )); + assert!(ForkAuthorization::stored_allows_fork(" full ")); + for unknown in ["", " ", "allowed", "FULL", "non_commercial", "forbidden "] { + assert!( + !ForkAuthorization::stored_allows_fork(unknown), + "未知档位必须按禁止解释:{unknown:?}" + ); + } + } + #[test] fn generation_starts_at_one_for_direct_derivatives() { assert_eq!(next_generation(0), 1); diff --git a/server-rs/crates/module-game-distribution/src/lib.rs b/server-rs/crates/module-game-distribution/src/lib.rs index b269c028c..deab8afd4 100644 --- a/server-rs/crates/module-game-distribution/src/lib.rs +++ b/server-rs/crates/module-game-distribution/src/lib.rs @@ -27,9 +27,9 @@ pub use application::{ pub use catalog::{ GAME_DISTRIBUTION_CATALOG_INVALID_CURSOR_CODE, GAME_DISTRIBUTION_CATALOG_PAGE_LIMIT_DEFAULT, GAME_DISTRIBUTION_CATALOG_PAGE_LIMIT_MAX, GameDistributionCatalogPageItem, - encode_game_distribution_catalog_cursor, game_distribution_catalog_page_limit, - page_public_game_distribution_catalog, parse_game_distribution_catalog_cursor, - sort_public_game_distribution_catalog, + encode_game_distribution_catalog_cursor, filter_public_game_distribution_catalog_by_forkable, + game_distribution_catalog_page_limit, page_public_game_distribution_catalog, + parse_game_distribution_catalog_cursor, sort_public_game_distribution_catalog, }; pub use collection::{ GAME_DISTRIBUTION_COLLECTION_PAGE_LIMIT_DEFAULT, GAME_DISTRIBUTION_COLLECTION_PAGE_LIMIT_MAX, diff --git a/server-rs/crates/spacetime-client/src/game_distribution.rs b/server-rs/crates/spacetime-client/src/game_distribution.rs index 79c5d4a4f..a7a49ceb8 100644 --- a/server-rs/crates/spacetime-client/src/game_distribution.rs +++ b/server-rs/crates/spacetime-client/src/game_distribution.rs @@ -38,6 +38,12 @@ pub struct GameDistributionPublicGameListRecordInput { /// 解析在模块侧事务里,非法格式由服务端回稳定码 `CATALOG_INVALID_CURSOR`(api-server 映射 /// 400);facade 不预校验,避免「客户端挡住的」与「服务端挡住的」变成两套判据。 pub cursor: Option, + /// 只返回可被共创的作品(AGC「共创」页的过滤);`false` = 不过滤 = 既有目录行为。 + /// + /// 过滤在事务内、切页之前完成,因此翻开第二页时也仍然只含可共创作品,且末页游标与实际 + /// 条数一致。取值口径(真值 `1` / `true` 大小写不敏感、非法取值宽容降级成「不过滤」) + /// 由 HTTP 面定;facade 只透传已有结论,不在这里重新解释字符串。 + pub forkable: bool, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -676,6 +682,7 @@ impl SpacetimeClient { limit: input.limit, author_id: input.author_id, cursor: input.cursor, + forkable: input.forkable, }; self.call_after_connect("list_game_distribution_games", move |connection, sender| { connection diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_public_game_list_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_public_game_list_input_type.rs index 007452359..567db490b 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_public_game_list_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_public_game_list_input_type.rs @@ -12,6 +12,7 @@ pub struct GameDistributionPublicGameListInput { pub limit: u32, pub author_id: Option, pub cursor: Option, + pub forkable: bool, } impl __sdk::InModule for GameDistributionPublicGameListInput { diff --git a/server-rs/crates/spacetime-module/src/game_distribution.rs b/server-rs/crates/spacetime-module/src/game_distribution.rs index ccc5319ff..068a04412 100644 --- a/server-rs/crates/spacetime-module/src/game_distribution.rs +++ b/server-rs/crates/spacetime-module/src/game_distribution.rs @@ -1400,6 +1400,13 @@ pub struct GameDistributionPublicGameListInput { /// 解析在事务里(模块侧纯函数),非法格式回稳定码 `CATALOG_INVALID_CURSOR` → 400, /// 而不是静默当成第一页(静默会把「游标坏了」渲染成「又回到最新一页」)。 pub cursor: Option, + /// 只返回**可被共创**(`fork_authorization` 允许他人改编)的作品;AGC「共创」页用。 + /// + /// `false` = 不过滤 = 本字段出现之前的既有目录行为,因此 `forkable` 省略 / `0` / `false` / + /// 非法取值(api-server 宽容降级成假)都落在这里,既有调用方一条行为都不会变。过滤在事务内、 + /// **切页之前**完成(见 `list_public_game_distribution_games_tx`),禁止共创的行不占页名额, + /// 翻页与末页语义与不带该参数时逐字相同。 + pub forkable: bool, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -5556,10 +5563,10 @@ fn list_owner_game_distribution_games_tx( /// 公开游戏目录(游标分页)。 /// -/// 顺序纪律是「**先过滤、再排序切页**」:作者 / 可见性 / 软删除 / 当前公开版本 / 分类 / 关键词 -/// 全部在切页之前完成,游标位置因此定义在已过滤序列上。反过来先把未过滤序列切页、再逐页过滤, -/// 被滤掉的行会凭空占掉名额,下一页的游标又指回过滤前的序列,于是每翻一页都漏掉自己的若干条 -/// (与 `/my-collections`、公开主题列表同一条纪律)。排序 / 切页走 +/// 顺序纪律是「**先过滤、再排序切页**」:作者 / 可见性 / 软删除 / 当前公开版本 / 分类 / 关键词 / +/// `forkable` 全部在切页之前完成,游标位置因此定义在已过滤序列上。反过来先把未过滤序列切页、 +/// 再逐页过滤,被滤掉的行会凭空占掉名额,下一页的游标又指回过滤前的序列,于是每翻一页都漏掉 +/// 自己的若干条(与 `/my-collections`、公开主题列表同一条纪律)。排序 / 切页走 /// `page_public_game_distribution_catalog`(`created_at` 倒序 + `game_id` 升序兜底的全序), /// 翻页不重不漏;页大小由同一个纯函数归一(缺省 48 / 上限 100 / 超界截断),事务与 handler 的 /// 日志口径因此同源。 @@ -5567,6 +5574,7 @@ fn list_public_game_distribution_games_tx( ctx: &ReducerContext, input: GameDistributionPublicGameListInput, ) -> Result<(Vec, Option), String> { + let forkable = input.forkable; let author_id = input .author_id .as_deref() @@ -5619,6 +5627,15 @@ fn list_public_game_distribution_games_tx( }, ) .collect::>(); + // `forkable` 是**同一条过滤链上的最后一道**,仍然发生在切页之前:禁止共创的行既不出现在 + // 任何一页,也不占页名额(占位会让第一页少回传、下一页游标错位,每翻一页就漏若干条)。 + // 判据复用模块纯函数(未知档位按禁止解释,与 fork 门禁同源),`forkable == false` 时原样 + // 返回,因此「不传该参数」与改动前的既有目录行为逐条相同。 + let visible = module_game_distribution::filter_public_game_distribution_catalog_by_forkable( + visible, + forkable, + |game| game.fork_authorization.as_str(), + ); let (page, next_cursor) = module_game_distribution::page_public_game_distribution_catalog( visible, cursor, @@ -8413,6 +8430,64 @@ mod tests { ); } + /// 公开目录 `forkable` 过滤:入参真的落到事务里、判据唯一、且发生在**切页之前**。 + /// + /// 「切页之前」是这条功能唯一的正确性要点:禁止共创的行若先占掉页名额再被滤掉,第一页就会 + /// 少回传、下一页游标又指回过滤前的序列,每翻一页漏掉自己的若干条。断言用**顺序**而不是 + /// 「函数里出现了这个调用」,因此把过滤挪到 `page_public_game_distribution_catalog` 之后 + /// (例如在 api-server 里滤返回结果)会立刻变红。 + #[test] + fn public_game_catalog_forkable_filters_before_paging() { + let source = include_str!("game_distribution.rs"); + assert!( + source.contains("pub forkable: bool"), + "公开目录入参必须带 `forkable`(procedure 入参变更需同步重生成客户端绑定)" + ); + let body = function_body(source, "fn list_public_game_distribution_games_tx("); + assert!( + body.contains("input.forkable"), + "事务必须真的读入参,不能只在类型里挂一个字段" + ); + assert!( + body.contains( + "module_game_distribution::filter_public_game_distribution_catalog_by_forkable(" + ), + "过滤判据必须走模块纯函数(唯一实现:未知档位按禁止解释,与 fork 门禁同源)" + ); + assert!( + body.contains("|game| game.fork_authorization.as_str()"), + "过滤必须看作品行里真实的授权列,而不是别的字段或客户端自称的档位" + ); + for inline_compare in ["fork_authorization == ", "fork_authorization != "] { + assert!( + !body.contains(inline_compare), + "不得在事务里另写一套字符串比较(`{inline_compare}`):判据只能有一份实现,\ + 否则未知档位会在这里被当成「非 forbidden」而列进共创页" + ); + } + let forkable_at = body + .find("filter_public_game_distribution_catalog_by_forkable(") + .expect("forkable 过滤必须在事务内"); + let page_at = body + .find("module_game_distribution::page_public_game_distribution_catalog(") + .expect("切页必须在事务内"); + assert!( + forkable_at < page_at, + "必须先按 forkable 过滤、再排序切页:反过来会让 forbidden 行占掉页名额并让游标错位" + ); + // 该过滤只属于公开目录:作者 / 收藏 / 主题列表各自有自己的可见性口径,不得顺手复用。 + for other in [ + "fn list_owner_game_distribution_games_tx(", + "fn list_game_distribution_collections_tx(", + ] { + let other_body = function_body(source, other); + assert!( + !other_body.contains("forkable"), + "{other} 不得被 forkable 过滤污染" + ); + } + } + /// 主题详情:不存在 / 未发布用同一句 404 文案;已发布空成员是正常结果;roots 复用公开投影。 #[test] fn public_theme_detail_maps_missing_to_not_found_and_empty_members_to_success() {