feat(游戏共创): 公开目录支持 forkable 过滤(只列支持共创的作品)
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

- `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。
This commit is contained in:
2026-10-07 14:47:06 +08:00
parent 67f6917846
commit 576c28354b
7 changed files with 541 additions and 9 deletions
@@ -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<String>,
limit: Option<u32>,
cursor: Option<String>,
/// `forkable` = 只返回可被共创的作品(AGC「共创」页用)。
///
/// 这里刻意收成 `Option<String>` 而不是 `Option<bool>`:serde 的 `bool` 解析会把
/// `forkable=abc` 变成 axum 的 400 纯文本(连平台信封都没有),而本参数的既定口径是
/// **宽容降级**(非法取值按「不过滤」处理,见 `parse_forkable_flag`)。用字符串接收、
/// 再用一个可单测的纯函数解释,才能让「拼错一个可选参数」回到现状而不是让整个公开目录 400。
forkable: Option<String>,
}
#[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>) -> 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::<Vec<_>>();
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<String> = Vec::new();
let mut cursors: Vec<Option<String>> = 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() {
@@ -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<T>(
)
}
/// 公开目录的 `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<T>(
items: Vec<GameDistributionCatalogPageItem<T>>,
forkable: bool,
authorization_of: impl Fn(&T) -> &str,
) -> Vec<GameDistributionCatalogPageItem<T>> {
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<GameDistributionCatalogPageItem<(String, String)>>,
forkable: bool,
) -> Vec<GameDistributionCatalogPageItem<(String, String)>> {
filter_public_game_distribution_catalog_by_forkable(items, forkable, |row| row.1.as_str())
}
fn game_ids(items: &[GameDistributionCatalogPageItem<(String, String)>]) -> Vec<String> {
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::<std::collections::BTreeSet<_>>();
assert_eq!(
kept_ids,
["game_non_commercial".to_string(), "game_full".to_string()]
.into_iter()
.collect::<std::collections::BTreeSet<_>>(),
"留下的集合必须与被允许共创的条目一一对应"
);
}
/// **未知档位失败关闭**:解析不出的档位按「禁止共创」解释,与 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<String> = Vec::new();
let mut nexts: Vec<Option<String>> = 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::<Vec<_>>();
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::<std::collections::BTreeSet<_>>();
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::<Vec<_>>();
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}"
);
}
}
}
@@ -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);
@@ -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,
@@ -38,6 +38,12 @@ pub struct GameDistributionPublicGameListRecordInput {
/// 解析在模块侧事务里,非法格式由服务端回稳定码 `CATALOG_INVALID_CURSOR`(api-server 映射
/// 400);facade 不预校验,避免「客户端挡住的」与「服务端挡住的」变成两套判据。
pub cursor: Option<String>,
/// 只返回可被共创的作品(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
@@ -12,6 +12,7 @@ pub struct GameDistributionPublicGameListInput {
pub limit: u32,
pub author_id: Option<String>,
pub cursor: Option<String>,
pub forkable: bool,
}
impl __sdk::InModule for GameDistributionPublicGameListInput {
@@ -1400,6 +1400,13 @@ pub struct GameDistributionPublicGameListInput {
/// 解析在事务里(模块侧纯函数),非法格式回稳定码 `CATALOG_INVALID_CURSOR` → 400,
/// 而不是静默当成第一页(静默会把「游标坏了」渲染成「又回到最新一页」)。
pub cursor: Option<String>,
/// 只返回**可被共创**(`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<GameDistributionPublicGameSnapshot>, Option<String>), 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::<Vec<_>>();
// `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() {