feat(游戏共创): 收录(收藏)列表补真分页(cursor + limit)
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

- 纯函数(`module-game-distribution/src/collection.rs`,与收藏另两条规则同文件):`encode/parse_game_distribution_collection_cursor`(`"{created_at_micros}:{collection_id}"`,复用仓库既有 `"{micros}:{id}"` 惯例;非法 → Err「收藏列表游标格式无效」→ 400)、`game_distribution_collection_page_limit`(0/缺省 → 20,超界 → 50)、`game_distribution_collection_page`(稳定全序排序 → 跳过游标之前 → 取 limit → 多取一条判 has_more,只对页内最后一条编码下一游标)
- 排序与顺序语义:`created_at` 倒序 + `collection_id` 升序兜底(主键、同用户内唯一 ⇒ 全序,翻页不重不漏);**先按可见性过滤、再排序切页**——游标位置必须落在已过滤序列上,否则被过滤的行会让下一页漏项(推理写进 tx 与纯函数注释);snapshot 只对最终页成员计算
- 模块:list 的 input 增 `limit` / `cursor`,result 增 `next_cursor`;api-server `my-collections` 加 `Query<MyCollectionsQuery>`,默认 20 / 上限 50(网格一屏 vs 单响应体积;与后台列表的 200 口径不必相等,理由写进注释),超界**截断**不报错,非法游标 400 透传不吞,日志带 `has_more`
- 契约:Rust DTO 增 `next_cursor`、TS 镜像同步、parity 登记(50 组一致)
- 文档:§3.4 的 `my-collections` 行补分页语义(默认/上限/游标格式/非法 400/末页 null/排序口径)
- 测试 13 条:纯函数 8(不足一页 → None;恰好整页不产生空游标;5 条 limit=2 连翻 3 页不重不漏;同 `created_at` 用 `collection_id` 兜底;limit=0 与超界;游标往返 + 7 种非法输入)、模块事务结构 2、api-server 3(默认与截断、`nextCursor` 透传、非法游标 → 400)
- 门禁:wasm build 0;`cargo check --all-targets` 0;`cargo test -p module-game-distribution` **81 passed**;`cargo test -p api-server game_distribution` **73 passed**;`cargo test -p spacetime-module` 274 passed / 1 ignored;DTO parity **50 组**;`check:spacetime-schema` 0(94 tables);`check:encoding` 0(5382 files);`cargo fmt --all -- --check` 0;`git diff --check` 0
This commit is contained in:
2026-10-06 01:31:09 +08:00
parent eb7349ef3d
commit f09607d903
12 changed files with 560 additions and 40 deletions
@@ -338,7 +338,7 @@ pub(crate) project_bundle_sha256: Option<String>,
| --- | --- |
| `PUT /games/{gameId}/collection` | 收藏。要求 `Idempotency-Key`;请求摘要绑定 `(userId, gameId)`。**幂等**:同键重放返回 `replayed: true`(本次没有新事实),同键不同请求(换作品 / 换用户)→ **409**。**防重靠结构**:主键是确定性构造的 `{userId}:{gameId}`,同一 (用户, 作品) 不可能出现第二行(重复收藏只留一行、仍算成功),不靠「事务里先查后写」,也不额外建唯一索引。错误码:作品不存在 → **404**;未公开 / 已软删除 / 没有当前公开版本 → **409**(失败关闭,文案 `作品状态不允许收藏(未公开或已软删除)`,不含「不存在」/「已被删除」,不用错误码泄露未公开作品的存在性)。响应 `{ collected: true, replayed: boolean }` |
| `DELETE /games/{gameId}/collection` | 取消收藏。**不要求 `Idempotency-Key`**:按确定性主键删除,重复调用结果完全相同(不存在也算成功),没有「重放 vs 新意图」需要区分。**也不要求作品仍公开 / 未被删除**:下架后拒绝取消只会给用户留下清理不掉的脏行。响应 `{ collected: false }`(**不带** `replayed`) |
| `GET /my-collections` | 「我的收藏(收录)」列表:逐条用公开目录同一份 `public_game_payload` 投影,形状与公开目录一致 `{ games, nextCursor }`(`nextCursor` 恒为 `null`)。**只含当前公开可读的作品**;未公开 / 已软删除的行在投影时被跳过但**不删除**,作品重新公开后自动回到列表 |
| `GET /my-collections?limit=&cursor=`(2026-10-06 补真分页) | 「我的收藏(收录)」列表:逐条用公开目录同一份 `public_game_payload` 投影,形状与公开目录一致 `{ games, nextCursor }`。**真游标分页**:`limit` 缺省 **20**(网格一屏)、上限 **50**(约束单响应体积;与后台列表的 200 口径**不必相等**),超界**截断**而非报错;`cursor` 形如 `"{createdAtMicros}:{collectionId}"`(与后台列表同一套 `"{micros}:{id}"` 惯例,`collectionId` = `{userId}:{gameId}` 自带冒号,解析只切第一个冒号);**格式非法 → 400**;`nextCursor` 为真实值,**最后一页为 `null`**。排序:**收藏时间倒序,同值用 `collectionId` 升序兜底**(`collectionId` 唯一 ⇒ 全序,翻页不重不漏)。分页顺序定义为「**先按可见性过滤、再排序切页**」——游标位置落在已过滤序列上,否则每翻一页都会漏掉自己的若干条收藏。**只含当前公开可读的作品**;未公开 / 已软删除的行在投影时被跳过但**不删除**,作品重新公开后自动回到列表 |
#### 后台(admin)
@@ -305,6 +305,19 @@ export type GameDistributionListResponse = {
nextCursor?: string | null;
};
/**
* 「我的收藏(收录)」列表接口(`GET /api/game-distribution/my-collections`)的响应体。
*
* 形状与公开目录 `GameDistributionListResponse` 一致(同一份公开投影),但分页语义不同:
* 默认一页 20 条、上限 50 条,排序按「收藏时间倒序 + collectionId 升序兜底」。
* `nextCursor` 是真实游标(`"{createdAtMicros}:{collectionId}"`),最后一页为 `null`;
* 游标格式非法时服务端返回 400(不是 200 的空页)。
*/
export type GameDistributionMyCollectionsResponse = {
games: GameDistributionGame[];
nextCursor?: string | null;
};
export type GameDistributionCreateGameRequest = {
/** 发布方本地项目标识;同一作者重复发布会复用既有 gameId。 */
localProjectId?: string | null;
@@ -80,8 +80,13 @@ const PAIRS = [
'GameDistributionSetForkAuthorizationRequest',
'GameDistributionSetForkAuthorizationRequest',
],
// 收藏(收录):PUT / DELETE 共用同一个权威投影值形状(`replayed` 只有 PUT 会发)。
// 收藏(收录):PUT / DELETE 共用同一个权威投影值形状(`replayed` 只有 PUT 会发);
// 列表响应用独立类型登记,避免与公开目录的分页口径混成一个契约。
['GameDistributionCollectionState', 'GameDistributionCollectionState'],
[
'GameDistributionMyCollectionsResponse',
'GameDistributionMyCollectionsResponse',
],
['AdminGameReviewGamesQuery', 'AdminGameReviewGamesQuery'],
['AdminGameReviewGame', 'AdminGameReviewGame'],
['AdminGameReviewGamesResponse', 'AdminGameReviewGamesResponse'],
@@ -142,9 +147,10 @@ const RESPONSE_BUILDERS = [
{ fn: 'private_version_payload', ts: 'GameDistributionPrivateVersion' },
{ fn: 'version_summary_payload', ts: 'GameDistributionVersionSummary' },
{
// 「我的收藏」复用公开目录的分页字段与条目形状;游标恒为 null(收藏量受用户自身规模约束)。
// 「我的收藏」复用公开目录的条目形状,但有自己的分页契约(默认 20 / 上限 50 / 真实游标):
// `nextCursor` 是这条路径**必须**发出的键(最后一页发 `null`,不是省略)。
fn: 'my_collections_payload',
ts: 'GameDistributionListResponse',
ts: 'GameDistributionMyCollectionsResponse',
mustEmit: ['games', 'nextCursor'],
},
];
@@ -19,11 +19,12 @@ use axum::{
};
use flate2::{Compression as GzipCompression, write::GzEncoder};
use module_game_distribution::{
GAME_DISTRIBUTION_COLLECTION_PAGE_LIMIT_DEFAULT, GAME_DISTRIBUTION_COLLECTION_PAGE_LIMIT_MAX,
MAX_PACKAGE_BYTES, MAX_PROJECT_BUNDLE_BYTES, ProjectBundleError, ProjectBundleManifest,
ReleaseAssetError, ReleasePackageError, ReleasePackageManifest, compute_request_digest,
extract_release_asset, normalize_review_comment, normalize_review_moderation_reason,
release_asset_content_type, validate_project_bundle_zip, validate_release_zip,
validate_review_list_status,
extract_release_asset, game_distribution_collection_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};
@@ -261,6 +262,17 @@ struct AdminGameListQuery {
cursor: Option<String>,
}
/// 「我的收藏(收录)」列表的查询串:`limit` + `cursor`。
///
/// 分页口径与后台列表**同构但数值不同**:默认 20(网格一屏)、上限 50(约束单响应体积)。
/// 两处口径不必相等——后台是运营表格视图,需要一次扫读更多行;用户态网格按屏取数。
/// 数值本身只在 `module_game_distribution` 里定义一次,这里引用常量而不是再抄一遍。
#[derive(Debug, Deserialize)]
struct MyCollectionsQuery {
limit: Option<u32>,
cursor: Option<String>,
}
/// 作者软删除游戏:CAS 修订号走查询串,删除本身没有请求体。
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -1462,34 +1474,70 @@ async fn uncollect_game(
))
}
/// 「我的收藏(收录)」:`GET /my-collections`。
/// 「我的收藏(收录)」:`GET /my-collections?limit=&cursor=`。
///
/// 逐条用公开目录同一份 `public_game_payload` 组装,形状与公开目录一致(`games` + `nextCursor`)。
/// 只返回当前公开可读的作品;已下架 / 软删除的收藏**只是不在响应里**,行不删除——作品重新
/// 公开后会自动回来。
///
/// 分页:默认 20、上限 50,超界**截断**(与后台列表一致:客户端拿到一个完整页,而不是重试错误);
/// 游标格式非法由模块侧报错并在这里透传成 400(不吞掉、也不自己造一种 200 的空页)。
async fn list_my_collections(
State(state): State<AppState>,
Extension(ctx): Extension<RequestContext>,
Extension(auth): Extension<AuthenticatedAccessToken>,
Query(query): Query<MyCollectionsQuery>,
) -> Result<Json<Value>, AppError> {
let user_id = auth.claims().user_id().to_string();
let games = state
// 与模块侧同一套归一化(同一函数),因此日志里的 `limit` 就是真正生效的页大小。
let limit = my_collections_page_limit(query.limit);
let cursor = normalize_optional(query.cursor);
let (games, next_cursor) = state
.spacetime_client()
.list_game_distribution_collections(user_id)
.list_game_distribution_collections(user_id, limit, cursor.clone())
.await
.map_err(map_spacetime_error)?;
Ok(json_success_body(Some(&ctx), my_collections_payload(games)))
info!(
request_id = ctx.request_id(),
operation = "game_collections_listed",
games = games.len(),
limit,
max_limit = GAME_DISTRIBUTION_COLLECTION_PAGE_LIMIT_MAX,
has_cursor = cursor.is_some(),
has_more = next_cursor.is_some(),
elapsed_ms = ctx.elapsed(),
"读取我的收藏列表"
);
Ok(json_success_body(
Some(&ctx),
my_collections_payload(games, next_cursor),
))
}
/// 「我的收藏」响应负载:分页字段与公开目录逐字一致(收藏量受用户自身规模约束,游标恒为 null)。
/// 「我的收藏」响应负载:分页字段与公开目录逐字一致(`games` + `nextCursor`)。
///
/// `nextCursor` 是**真实**游标:还有下一页时给出,最后一页为 `null`,客户端据此决定是否继续拉。
///
/// 同步纯函数:既是 handler 的组装点,也是 DTO parity 脚本登记的响应构建器。
fn my_collections_payload(games: Vec<GameDistributionPublicGameRecord>) -> Value {
fn my_collections_payload(
games: Vec<GameDistributionPublicGameRecord>,
next_cursor: Option<String>,
) -> Value {
let games = games
.into_iter()
.map(public_game_payload)
.collect::<Vec<_>>();
json!({ "games": games, "nextCursor": Value::Null })
json!({ "games": games, "nextCursor": next_cursor })
}
/// 查询串的 `limit` → 生效页大小:缺省取默认 20,超界截断到上限 50(`0` 也取默认)。
///
/// 归一化本身委托给 `module_game_distribution::game_distribution_collection_page_limit`,
/// 与事务里真正切页用的是**同一个函数**,避免「日志写 50、实际发了 20」这类漂移。
fn my_collections_page_limit(limit: Option<u32>) -> u32 {
game_distribution_collection_page_limit(
limit.unwrap_or(GAME_DISTRIBUTION_COLLECTION_PAGE_LIMIT_DEFAULT),
) as u32
}
/// 读接口的「不可读即 404」:族谱与衍生列表的锚点必须公开可读,否则按「不存在」处理。
@@ -7873,6 +7921,71 @@ mod tests {
assert_eq!(delete, json!({ "collected": false }));
}
/// `limit` 口径:缺省 20、超界截断到 50、`0` 取默认;都不是报错。
///
/// 这条测试同时钉住「api-server 与模块侧同源」:归一化函数就是模块里的那一个,
/// 因此 handler 记的 `limit` 与事务真正用的页大小不可能对不上。
#[test]
fn my_collections_limit_defaults_and_truncates() {
assert_eq!(my_collections_page_limit(None), 20);
assert_eq!(my_collections_page_limit(Some(0)), 20);
assert_eq!(my_collections_page_limit(Some(5)), 5);
assert_eq!(my_collections_page_limit(Some(50)), 50);
assert_eq!(
my_collections_page_limit(Some(51)),
50,
"超界截断而不是报错"
);
assert_eq!(my_collections_page_limit(Some(u32::MAX)), 50);
// 与后台列表口径**不必相等**,但两个数都必须是「正数且有界」。
assert!(my_collections_page_limit(None) > 0);
assert!(GAME_DISTRIBUTION_COLLECTION_PAGE_LIMIT_MAX <= 200);
}
/// 响应形状:`games` 与 `nextCursor` 两个键一定发出;游标是真实值,最后一页为 `null`。
#[test]
fn my_collections_payload_carries_real_next_cursor() {
let with_more = my_collections_payload(Vec::new(), Some("100:usr_1:game_a".to_string()));
assert_eq!(
with_more,
json!({ "games": [], "nextCursor": "100:usr_1:game_a" })
);
let last_page = my_collections_payload(Vec::new(), None);
assert_eq!(last_page, json!({ "games": [], "nextCursor": Value::Null }));
// 有内容时 `games` 走公开目录同一份投影,而不是另造一种条目形状。
let page = my_collections_payload(vec![public_game_record_fixture()], None);
assert_eq!(page["games"].as_array().map(Vec::len), Some(1));
assert_eq!(page["games"][0]["id"], "game_1");
}
/// 非法游标必须落 400:模块侧文案经 `map_spacetime_error` 兜底分支,不得被 404 / 409 子串抢先命中。
#[test]
fn my_collections_invalid_cursor_maps_to_bad_request() {
let message =
module_game_distribution::parse_game_distribution_collection_cursor("不是游标")
.expect_err("非法游标必须报错");
assert!(message.contains("格式无效"), "{message}");
for forbidden in [
"不存在",
"已被删除",
"状态",
"不匹配",
"幂等",
"已存在",
"FORK_",
] {
assert!(
!message.contains(forbidden),
"游标错误文案不得含「{forbidden}」:{message}"
);
}
assert_eq!(
map_spacetime_error(SpacetimeClientError::Procedure(message.clone())).status_code(),
StatusCode::BAD_REQUEST,
"{message}"
);
}
fn public_game_record_fixture() -> GameDistributionPublicGameRecord {
GameDistributionPublicGameRecord {
game: GameDistributionGameRecord {
@@ -1,7 +1,22 @@
//! 收藏(收录)的纯函数规则。
//!
//! 表与事务在 `spacetime-module::game_distribution`;这里只放能被不起 SpacetimeDB 的单测钉住的
//! 两条规则:确定性主键的构造,以及「收藏行是否出现在列表投影里」的可见性判定。
//! 规则:确定性主键的构造、「收藏行是否出现在列表投影里」的可见性判定,以及
//! 「我的收藏」列表的游标编解码与排序切页。
/// 「我的收藏」一页的默认条数。
///
/// 取值 20 的理由:前端「我的收藏」是网格布局,一屏放得下 20 张卡片,首屏少一次往返。
/// 与后台列表默认口径(`GAME_DISTRIBUTION_ADMIN_GAME_LIST_LIMIT` = 200)**不必相等**:
/// 后台是运营用的表格视图、单行信息量小且需要批量扫读,用户态网格则按屏取数。
pub const GAME_DISTRIBUTION_COLLECTION_PAGE_LIMIT_DEFAULT: u32 = 20;
/// 「我的收藏」单次响应回传的条数上限。
///
/// 取值 50 的理由:约束单个响应的体积(每条约等于一份公开游戏投影,含当前版本与评分摘要),
/// 同时给「一屏 20 条」留出一次翻页拉满 2 页的余量。超界一律**截断**而不是报错——与后台列表
/// 同一处理方式(客户端拿到的是一个完整页,而不是一个需要重试的错误)。
pub const GAME_DISTRIBUTION_COLLECTION_PAGE_LIMIT_MAX: u32 = 50;
/// 收藏主键:`{user_id}:{game_id}`。
///
@@ -34,6 +49,112 @@ pub fn game_distribution_collection_visible(
!is_deleted && is_published && has_public_version
}
/// 分页序列里的一条收藏:游标/排序所需的两个键 + 调用方自己的负载。
///
/// 泛型 `payload` 让「排序键」与「这一页要回传的东西」绑在同一个值上,纯函数切页时整条值一起移动,
/// 因此不可能出现「按 A 排序、按 B 切页」的错位;模块侧把负载填成游戏行,测试里填成任意轻量值。
#[derive(Clone, Debug, PartialEq)]
pub struct GameDistributionCollectionPageItem<T> {
/// 收藏主键 `{user_id}:{game_id}`;同一用户范围内唯一,因此可作全序兜底键。
pub collection_id: String,
/// 收藏时间(Unix 微秒),主排序键。
pub created_at_micros: i64,
pub payload: T,
}
/// 收藏列表游标编码:`{created_at_micros}:{collection_id}`。
///
/// 与后台列表(`parse_admin_game_distribution_cursor`)同一套 `"{micros}:{id}"` 惯例。
/// 注意 `collection_id` 本身由 `game_distribution_collection_id` 拼成、**含冒号**
/// (`{user_id}:{game_id}`):解析只切**第一个**冒号,`micros` 之后的整段(含更多冒号)都是
/// collection_id,因此编解码仍然是双射(`encode` → `parse` 原样返回)。
pub fn encode_game_distribution_collection_cursor(
created_at_micros: i64,
collection_id: &str,
) -> String {
format!("{created_at_micros}:{collection_id}")
}
/// 收藏列表游标解析;格式非法时返回 `Err`(api-server 会把它透传成 400)。
///
/// 严格的错误文案是刻意的:它不得含「不存在」「已被删除」「状态」「不匹配」「幂等」等子串,
/// 否则会被 api-server 的既有 `map_spacetime_error` 分支抢先映射成 404 / 409;「格式无效」
/// 只会落到兜底的 400,这正是「客户端传了坏游标」的语义。
pub fn parse_game_distribution_collection_cursor(value: &str) -> Result<(i64, String), String> {
let (micros, collection_id) = value
.split_once(':')
.ok_or_else(|| "收藏列表游标格式无效".to_string())?;
let micros = micros
.parse::<i64>()
.map_err(|_| "收藏列表游标格式无效".to_string())?;
if collection_id.is_empty() {
return Err("收藏列表游标格式无效".to_string());
}
Ok((micros, collection_id.to_string()))
}
/// 请求里的 `limit` → 实际页大小:`0` 取默认,超界截断到上限。
///
/// 抽成独立函数是为了让 api-server 记日志的「生效条数」与模块真正使用的页大小**同源**:
/// 两处各自写一遍 `if == 0 ... min(...)` 迟早会漂移。
pub fn game_distribution_collection_page_limit(limit: u32) -> usize {
let resolved = if limit == 0 {
GAME_DISTRIBUTION_COLLECTION_PAGE_LIMIT_DEFAULT
} else {
limit.min(GAME_DISTRIBUTION_COLLECTION_PAGE_LIMIT_MAX)
};
resolved as usize
}
/// 按「收藏时间倒序 + `collection_id` 升序兜底」排序、跳过游标之前的位置、取 `limit` 条,
/// 并返回下一页游标(没有下一页时为 `None`)。
///
/// 为什么这个比较器是**全序**:`created_at` 相同的两条必须还能分出先后,否则同一页边界上
/// 的项会在翻页时重复或漏掉。`collection_id` 是主键、在同一用户范围内唯一,因此
/// `(created_at_micros, collection_id)` 唯一决定顺序 —— 相同 `created_at` 不会被吞掉。
///
/// 调用方必须**先**按可见性过滤、**再**把结果交给本函数:游标位置定义在已过滤的序列上。
/// 反过来先把未过滤序列切页、再逐页过滤,会让被滤掉的行凭空占掉名额,并使下一页的游标指回
/// 过滤前的序列——每翻一页都漏掉自己的若干条收藏。
pub fn game_distribution_collection_page<T>(
mut items: Vec<GameDistributionCollectionPageItem<T>>,
cursor: Option<(i64, String)>,
limit: u32,
) -> (Vec<T>, Option<String>) {
let limit = game_distribution_collection_page_limit(limit);
items.sort_by(|left, right| {
right
.created_at_micros
.cmp(&left.created_at_micros)
.then_with(|| left.collection_id.cmp(&right.collection_id))
});
if let Some((cursor_micros, cursor_id)) = cursor.as_ref() {
// 严格「在游标之后」:时间更早,或时间相同且 collection_id 更大(升序方向)。
items.retain(|item| {
item.created_at_micros < *cursor_micros
|| (item.created_at_micros == *cursor_micros
&& item.collection_id.as_str() > cursor_id.as_str())
});
}
// 多取一条判断是否还有下一页;游标只指向这一页真正回传的最后一条。
let has_more = items.len() > limit;
items.truncate(limit);
let next_cursor = if has_more {
items.last().map(|item| {
encode_game_distribution_collection_cursor(
item.created_at_micros,
item.collection_id.as_str(),
)
})
} else {
None
};
(
items.into_iter().map(|item| item.payload).collect(),
next_cursor,
)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -79,4 +200,174 @@ mod tests {
// 重新公开:同一行再次可见。
assert!(game_distribution_collection_visible(false, true, true));
}
fn item(
collection_id: &str,
created_at_micros: i64,
) -> GameDistributionCollectionPageItem<String> {
GameDistributionCollectionPageItem {
collection_id: collection_id.to_string(),
created_at_micros,
payload: collection_id.to_string(),
}
}
/// 不足一页:全部回传,且**没有**下一页游标(不能给一个指向空页的游标,否则客户端会多翻一次死页)。
#[test]
fn page_returns_everything_without_cursor_when_under_limit() {
let (page, next) = game_distribution_collection_page(
vec![item("usr_1:game_a", 300), item("usr_1:game_b", 200)],
None,
20,
);
assert_eq!(page, vec!["usr_1:game_a", "usr_1:game_b"]);
assert_eq!(next, None);
}
/// 恰好一页:`has_more` 依赖「多取一条」,因此 `len == limit` 时不能再给游标。
#[test]
fn page_at_exact_limit_has_no_cursor() {
let (page, next) = game_distribution_collection_page(
vec![item("usr_1:game_a", 300), item("usr_1:game_b", 200)],
None,
2,
);
assert_eq!(page.len(), 2);
assert_eq!(next, None);
}
/// 连续翻页直到消耗完:每页**不重复**上一页的项,也**不跳过**任何项;最后一页游标为 `None`。
#[test]
fn paging_walks_the_whole_sequence_without_gaps_or_duplicates() {
let entries = vec![
item("usr_1:game_a", 500),
item("usr_1:game_b", 400),
item("usr_1:game_c", 300),
item("usr_1:game_d", 200),
item("usr_1:game_e", 100),
];
let mut seen: Vec<String> = Vec::new();
let mut cursor = None;
let mut pages = 0;
loop {
let (page, next) = game_distribution_collection_page(entries.clone(), cursor, 2);
assert!(!page.is_empty(), "非末页不得为空");
if next.is_some() {
assert_eq!(page.len(), 2, "还有下一页时必须填满 limit 条");
}
seen.extend(page);
pages += 1;
match next {
Some(value) => {
cursor = Some(parse_game_distribution_collection_cursor(&value).unwrap())
}
None => break,
}
}
assert_eq!(pages, 3);
assert_eq!(
seen,
vec![
"usr_1:game_a",
"usr_1:game_b",
"usr_1:game_c",
"usr_1:game_d",
"usr_1:game_e",
],
"翻页顺序必须严格是「收藏时间倒序」且不重不漏"
);
}
/// 同一 `created_at` 多条:`collection_id` 升序兜底保证全序,翻页边界不重不漏。
///
/// 这是唯一能钉住「排序是全序」的场景:若比较器在 `created_at` 相同时不稳定,三条同刻收藏
/// 里的某一条就会被下一页重复返回或被整段跳过。
#[test]
fn page_tiebreaks_equal_created_at_by_collection_id_ascending() {
let entries = vec![
item("usr_1:game_c", 100),
item("usr_1:game_a", 100),
item("usr_1:game_d", 99),
item("usr_1:game_b", 100),
];
let (first, next) = game_distribution_collection_page(entries.clone(), None, 2);
assert_eq!(first, vec!["usr_1:game_a", "usr_1:game_b"]);
let cursor = next.expect("还有同刻与更早的项");
assert_eq!(cursor, "100:usr_1:game_b");
let (second, last) = game_distribution_collection_page(
entries,
Some(parse_game_distribution_collection_cursor(&cursor).unwrap()),
2,
);
assert_eq!(second, vec!["usr_1:game_c", "usr_1:game_d"]);
assert_eq!(last, None, "第二页就是最后一页");
}
/// `limit` 语义:`0` 取默认 20(不是「返回 0 条」),超界截断到 50(不是报错)。
#[test]
fn page_limit_defaults_zero_and_truncates_overflow() {
assert_eq!(game_distribution_collection_page_limit(0), 20);
assert_eq!(game_distribution_collection_page_limit(1), 1);
assert_eq!(game_distribution_collection_page_limit(50), 50);
assert_eq!(
game_distribution_collection_page_limit(51),
50,
"超界截断而不是报错"
);
assert_eq!(game_distribution_collection_page_limit(u32::MAX), 50);
let oversized = (0..25)
.map(|index| item(&format!("usr_1:game_{index:02}"), 1_000 - index))
.collect::<Vec<_>>();
let (page, next) = game_distribution_collection_page(oversized, None, 0);
assert_eq!(page.len(), 20, "limit=0 必须取默认 20 条");
assert!(next.is_some(), "25 条 > 20 条,必须有下一页游标");
}
/// 游标编解码:`collection_id` 自带冒号也只切第一个,因此是双射;格式非法一律 `Err`。
#[test]
fn cursor_round_trips_and_rejects_malformed_values() {
let cursor =
encode_game_distribution_collection_cursor(1_700_000_000_000_000, "usr_1:game_a");
assert_eq!(cursor, "1700000000000000:usr_1:game_a");
assert_eq!(
parse_game_distribution_collection_cursor(&cursor).unwrap(),
(1_700_000_000_000_000, "usr_1:game_a".to_string())
);
// 负数微秒(仅编码假设,仍必须可逆)。
assert_eq!(
parse_game_distribution_collection_cursor("-5:usr_1:game_a").unwrap(),
(-5, "usr_1:game_a".to_string())
);
for malformed in [
"",
"abc",
"1700000000000000",
"1700000000000000:",
":usr_1:game_a",
"not-a-number:usr_1:game_a",
] {
assert!(
parse_game_distribution_collection_cursor(malformed).is_err(),
"非法游标必须报错:{malformed}"
);
}
// 文案不得命中 api-server 的 404 / 409 子串分支,只能落到兜底 400。
let message = parse_game_distribution_collection_cursor("bad").unwrap_err();
for forbidden in [
"不存在",
"已被删除",
"状态",
"不匹配",
"幂等",
"已存在",
"FORK_",
] {
assert!(
!message.contains(forbidden),
"游标错误文案不得含「{forbidden}」:{message}"
);
}
}
}
@@ -21,7 +21,13 @@ pub use application::{
GameDistributionService, GameOperationResult, InMemoryGameDistributionStore,
PublicationOperationResult,
};
pub use collection::{game_distribution_collection_id, game_distribution_collection_visible};
pub use collection::{
GAME_DISTRIBUTION_COLLECTION_PAGE_LIMIT_DEFAULT, GAME_DISTRIBUTION_COLLECTION_PAGE_LIMIT_MAX,
GameDistributionCollectionPageItem, encode_game_distribution_collection_cursor,
game_distribution_collection_id, game_distribution_collection_page,
game_distribution_collection_page_limit, game_distribution_collection_visible,
parse_game_distribution_collection_cursor,
};
pub use commands::{CreateGameInput, CreateVersionInput, IdempotencyRequest, ReviewDecision};
pub use domain::{
FORK_AUTHORIZATION_FORBIDDEN, FORK_AUTHORIZATION_FULL, FORK_AUTHORIZATION_NON_COMMERCIAL,
@@ -379,6 +379,20 @@ pub struct GameDistributionListResponse {
pub next_cursor: Option<String>,
}
/// 「我的收藏(收录)」列表接口(`GET /api/game-distribution/my-collections`)的响应体。
///
/// 形状与公开目录 `GameDistributionListResponse` 一致(同一份公开投影),但**分页语义不同**:
/// 默认一页 20 条、上限 50 条(公开目录另有自己的口径),排序按「收藏时间倒序 + collection_id
/// 升序兜底」。`next_cursor` 是真实游标(`"{created_at_micros}:{collection_id}"`),
/// 最后一页为 `null`;游标格式非法时服务端返回 400。
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionMyCollectionsResponse {
pub games: Vec<GameDistributionGameSummary>,
#[serde(default)]
pub next_cursor: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDistributionCreateGameRequest {
@@ -567,13 +567,18 @@ pub(crate) fn map_game_distribution_collection_state_result(
}
/// 「我的收藏(收录)」列表:逐条用公开目录同一份公开投影映射,形状因此与公开目录一致。
///
/// 同时透传真实的下页游标(`None` = 最后一页),让 api-server 能原样发出 `nextCursor`。
pub(crate) fn map_game_distribution_collection_list_result(
result: crate::module_bindings::GameDistributionCollectionListResult,
) -> Result<Vec<GameDistributionPublicGameRecord>, SpacetimeClientError> {
) -> Result<(Vec<GameDistributionPublicGameRecord>, Option<String>), SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
Ok(result.games.into_iter().map(map_public_game).collect())
Ok((
result.games.into_iter().map(map_public_game).collect(),
result.next_cursor,
))
}
fn map_public_game(
@@ -787,12 +787,20 @@ impl SpacetimeClient {
.await
}
/// 「我的收藏(收录)」列表;只返回当前公开可读的作品,形状与公开目录一致。
/// 「我的收藏(收录)」列表(分页);只返回当前公开可读的作品,形状与公开目录一致。
///
/// 返回 `(games, next_cursor)`:`next_cursor` 为 `None` 表示这一页就是最后一页。
pub async fn list_game_distribution_collections(
&self,
user_id: String,
) -> Result<Vec<GameDistributionPublicGameRecord>, SpacetimeClientError> {
let input = crate::module_bindings::GameDistributionCollectionListInput { user_id };
limit: u32,
cursor: Option<String>,
) -> Result<(Vec<GameDistributionPublicGameRecord>, Option<String>), SpacetimeClientError> {
let input = crate::module_bindings::GameDistributionCollectionListInput {
user_id,
limit,
cursor,
};
self.call_after_connect(
"list_game_distribution_collections",
move |connection, sender| {
@@ -8,6 +8,8 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[sats(crate = __lib)]
pub struct GameDistributionCollectionListInput {
pub user_id: String,
pub limit: u32,
pub cursor: Option<String>,
}
impl __sdk::InModule for GameDistributionCollectionListInput {
@@ -11,6 +11,7 @@ use super::game_distribution_public_game_snapshot_type::GameDistributionPublicGa
pub struct GameDistributionCollectionListResult {
pub ok: bool,
pub games: Vec<GameDistributionPublicGameSnapshot>,
pub next_cursor: Option<String>,
pub error_message: Option<String>,
}
@@ -1282,9 +1282,14 @@ pub struct GameDistributionUncollectGameInput {
}
/// 读取某用户的收藏(收录)列表。
///
/// 分页参数与后台列表同构:`limit` 为 0 时取默认、超界截断到上限(`module_game_distribution`
/// 的 `GAME_DISTRIBUTION_COLLECTION_PAGE_LIMIT_*` 是唯一口径),`cursor` 为 `"{micros}:{collection_id}"`。
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct GameDistributionCollectionListInput {
pub user_id: String,
pub limit: u32,
pub cursor: Option<String>,
}
/// 读取「某用户是否已收藏某作品」;公开详情用它算 `collected`。
@@ -1587,10 +1592,14 @@ impl GameDistributionCollectionMutationResult {
}
/// 「我的收藏(收录)」列表结果;逐条用**公开游戏快照**投影,因此形状与公开目录逐字一致。
///
/// `next_cursor` 是真实的下一页游标:`None` 表示这一页就是最后一页。handler 原样透传成
/// `nextCursor`(最后一页发 `null`),客户端据此决定还要不要继续拉。
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
pub struct GameDistributionCollectionListResult {
pub ok: bool,
pub games: Vec<GameDistributionPublicGameSnapshot>,
pub next_cursor: Option<String>,
pub error_message: Option<String>,
}
@@ -2342,14 +2351,16 @@ pub fn list_game_distribution_collections_and_return(
require_editor_generation_runtime_service_identity(tx, caller)?;
list_game_distribution_collections_tx(tx, input.clone())
}) {
Ok(games) => GameDistributionCollectionListResult {
Ok((games, next_cursor)) => GameDistributionCollectionListResult {
ok: true,
games,
next_cursor,
error_message: None,
},
Err(error) => GameDistributionCollectionListResult {
ok: false,
games: Vec::new(),
next_cursor: None,
error_message: Some(error),
},
}
@@ -4800,34 +4811,35 @@ fn unset_game_distribution_collection_tx(
Ok(false)
}
/// 「我的收藏(收录)」列表。
/// 「我的收藏(收录)」列表(分页)。
///
/// 按 `user_id` 索引取该用户的全部收藏行,逐条用**既有** `public_game_distribution_snapshot`
/// 投影(与公开目录 / 公开详情同一份),因此形状与公开目录逐字一致。
/// 按 `user_id` 索引取该用户的收藏行,逐条用**既有** `public_game_distribution_snapshot` 投影
/// (与公开目录 / 公开详情同一份),因此形状与公开目录逐字一致。
///
/// 只返回当前公开可读的作品:未公开 / 已软删除 / 没有当前公开版本的行在**投影时**被跳过,
/// 但**不删除行**(`module_game_distribution::game_distribution_collection_visible` 就是这条
/// 判定的纯函数形式)。作品重新公开后,同一行会自动回到列表里。
///
/// 顺序**必须先过滤、再排序切页**:可见性是一条**过滤**规则,游标位置定义在已过滤的序列上。
/// 若先把未过滤的行切页、再逐页过滤,被滤掉的行会凭空占掉这一页的名额(用户看到「不足一页」),
/// 而下一页的游标又指回过滤前的序列,于是每翻一页都会漏掉自己的若干条收藏——页越大漏得越多。
fn list_game_distribution_collections_tx(
ctx: &ReducerContext,
input: GameDistributionCollectionListInput,
) -> Result<Vec<GameDistributionPublicGameSnapshot>, String> {
) -> Result<(Vec<GameDistributionPublicGameSnapshot>, Option<String>), String> {
let user_id = required_game_distribution_text(input.user_id, "user_id")?;
let mut collections = ctx
let cursor = input
.cursor
.and_then(normalize_game_distribution_optional)
.map(|value| {
module_game_distribution::parse_game_distribution_collection_cursor(value.as_str())
})
.transpose()?;
let visible = ctx
.db
.game_distribution_collection()
.by_game_distribution_collection_user_id()
.filter(&user_id)
.collect::<Vec<_>>();
// 最近收藏在前;同一时刻按 game_id 升序,保证顺序稳定(不依赖表的物理顺序)。
collections.sort_by(|left, right| {
right
.created_at
.cmp(&left.created_at)
.then_with(|| left.game_id.cmp(&right.game_id))
});
Ok(collections
.into_iter()
.filter_map(|collection| {
let game = ctx
.db
@@ -4843,9 +4855,23 @@ fn list_game_distribution_collections_tx(
if !visible {
return None;
}
public_game_distribution_snapshot(ctx, &game)
Some(
module_game_distribution::GameDistributionCollectionPageItem {
collection_id: collection.collection_id.clone(),
created_at_micros: collection.created_at.to_micros_since_unix_epoch(),
payload: game,
},
)
})
.collect())
.collect::<Vec<_>>();
let (page, next_cursor) =
module_game_distribution::game_distribution_collection_page(visible, cursor, input.limit);
Ok((
page.iter()
.filter_map(|game| public_game_distribution_snapshot(ctx, game))
.collect(),
next_cursor,
))
}
///
@@ -5546,7 +5572,8 @@ mod tests {
);
}
/// 列表事务:按 `user_id` 索引取行,只投影当前公开可读的作品,且不删除任何行。
/// 列表事务:按 `user_id` 索引取行,只投影当前公开可读的作品,不删除任何行,
/// 并且**先过滤再切页**(游标位置必须落在已过滤的序列上)。
#[test]
fn collection_list_projects_only_currently_public_games() {
let source = include_str!("game_distribution.rs");
@@ -5558,6 +5585,40 @@ mod tests {
!body.contains(".delete("),
"投影阶段不得删除收藏行:作品重新公开后同一行必须自动回来"
);
// 分页必须走共享纯函数(游标解析 + 排序切页),而不是在事务里另写一套 sort/truncate。
assert!(
body.contains("module_game_distribution::parse_game_distribution_collection_cursor")
);
assert!(body.contains("module_game_distribution::game_distribution_collection_page"));
let visible_at = body
.find("game_distribution_collection_visible")
.expect("可见性判定必须在事务内");
let page_at = body
.find("game_distribution_collection_page(")
.expect("切页必须在事务内");
assert!(
visible_at < page_at,
"必须先按可见性过滤、再排序切页:反过来会每翻一页漏掉自己的若干条收藏"
);
}
/// 列表结果必须带真实的 `next_cursor`:过程成功路径回传它,失败路径回 `None`。
#[test]
fn collection_list_result_carries_next_cursor() {
let source = include_str!("game_distribution.rs");
let body = function_body(
source,
"pub fn list_game_distribution_collections_and_return(",
);
assert!(body.contains("Ok((games, next_cursor))"));
assert!(
body.contains("next_cursor,"),
"成功路径必须把事务返回的真实游标放进结果"
);
assert!(
body.contains("next_cursor: None,"),
"失败路径必须显式给出 next_cursor(不能靠缺省值)"
);
}
/// 收藏前置失败关闭:作品不存在与「状态不允许收藏」必须用不同文案,后者命中 api-server 的