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
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:
@@ -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| {
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
+1
@@ -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 的
|
||||
|
||||
Reference in New Issue
Block a user