From 7ed99d7353b8c310b677b13287780fc05d4c7abb Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 15 Jul 2026 15:11:11 +0800 Subject: [PATCH 01/18] =?UTF-8?q?=E6=8B=86=E5=88=86SpacetimeDB=E8=B0=83?= =?UTF-8?q?=E7=94=A8=E6=B1=A0=E4=B8=8E=E7=BC=93=E5=AD=98=E8=AF=BB=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将HTTP调用池改为无订阅连接并增加单一共享缓存读连接 统一缓存重建总超时并拒绝发布断线连接 补充连接拓扑回归测试与生产运维说明 --- deploy/container/api-server.env.example | 1 + deploy/env/api-server.env.example | 1 + .../shared-memory/decision-log.md | 9 + ...timeDB连接池租约Drop兜底与取消安全-2026-06-11.md | 15 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 4 +- server-rs/crates/spacetime-client/README.md | 2 +- server-rs/crates/spacetime-client/src/lib.rs | 264 ++++++++++++++++-- 7 files changed, 269 insertions(+), 27 deletions(-) diff --git a/deploy/container/api-server.env.example b/deploy/container/api-server.env.example index 002bc4990..0cca35b21 100644 --- a/deploy/container/api-server.env.example +++ b/deploy/container/api-server.env.example @@ -58,6 +58,7 @@ AUTH_REFRESH_COOKIE_SECURE=false GENARRATIVE_SPACETIME_SERVER_URL=http://spacetimedb:3101 GENARRATIVE_SPACETIME_DATABASE=genarrative-loadtest GENARRATIVE_SPACETIME_TOKEN= +# HTTP 角色使用 8 条无 read-model 订阅的调用连接,并额外创建 1 条共享缓存读连接。 GENARRATIVE_SPACETIME_POOL_SIZE=8 GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS=45 diff --git a/deploy/env/api-server.env.example b/deploy/env/api-server.env.example index 2e4406d75..617553225 100644 --- a/deploy/env/api-server.env.example +++ b/deploy/env/api-server.env.example @@ -69,6 +69,7 @@ GENARRATIVE_SPACETIME_DATABASE=genarrative-prod GENARRATIVE_SPACETIME_TOKEN= # 只保存受保护文件路径;不要把 bootstrap secret 明文写入生产 env。 GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET_FILE=/var/lib/genarrative/spacetime/runtime-service-bootstrap-secret.txt +# HTTP 角色使用 8 条无 read-model 订阅的调用连接,并额外创建 1 条共享缓存读连接。 GENARRATIVE_SPACETIME_POOL_SIZE=8 GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS=45 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 9ccb95532..dea2c90c6 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4112,3 +4112,12 @@ - 留存口径:筛选范围内 `profile_dashboard_state.created_at` 的北京时间注册日构成 cohort;在精确 `D+1` / `D+7` 存在有效登录 user scope 日聚合即留存。观察日必须早于今天;D1、D7 分别返回留存人数、可观察人数和四舍五入后的基点率,按人数加权汇总,零分母前端显示 `-`。 - 影响范围:SpacetimeDB Dashboard 聚合 procedure、`spacetime-client` facade、`/admin/api/dashboard` 与 shared contracts、`apps/admin-web` Dashboard 页面和运营文档。 - 验证方式:SpacetimeDB 聚合与 api-server 定向 Rust 测试、Dashboard Vitest、`npm run admin-web:typecheck`、`npm run check:spacetime-schema`、`npm run check:spacetime-runtime-access`、`npm run check:encoding`、`git diff --check`,并用桌面 / 移动浏览器核对留存、日期、每日 UV、零值与滚动同步。 + +## 2026-07-14 SpacetimeDB 调用池与缓存读连接分离 + +- 背景:HTTP 角色原先在每个 `GENARRATIVE_SPACETIME_POOL_SIZE` 槽位首次建连时订阅同一批 read model;release 配置为 8 时会保存 8 份相同行缓存和 subscription handles,放大 api-server 内存。 +- 决策:`pool_size` 条连接只承接 procedure / reducer 调用并保持无订阅;HTTP 角色额外创建且只创建 1 条共享缓存读连接,全部 `read_after_connect` 读取统一路由到该连接。缓存连接只在应用 facade 中作为只读用途,不宣称 SDK 或 identity 具备连接级只读权限。 +- 并发与恢复:缓存读连接通过 `Arc` 共享,读取不占用调用池 permit,也不使用单槽租约串行化;只有首次初始化和 broken 后重建使用单飞锁。首次建连与全部订阅共用一次总超时预算;required subscriptions 全部 applied、optional 阶段连接仍未 broken 后才发布新连接,旧连接由在途读取自然释放。 +- 就绪边界:HTTP `/readyz` 同时验证调用池握手和缓存读连接;required subscription 失败必须不就绪。非 HTTP worker / controller 不创建缓存读连接,继续使用 1 条调用连接和各自的队列窄订阅。 +- 运维口径:`GENARRATIVE_SPACETIME_POOL_SIZE=8` 表示 8 条调用连接,HTTP 基础拓扑另加 1 条缓存读连接;外部生成和充值过期监听的独立窄订阅不计入该值。读模型行缓存从 8 份降为 1 份,但 SDK 空 table metadata、8 条调用 socket 和 runner 仍存在,不承诺总 RSS 等比例降为八分之一。 +- 验证方式:`cargo test -p spacetime-client --manifest-path server-rs/Cargo.toml --lib`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`、`git diff --check`;发布后在 8 个调用槽暖机后对比 api-server cgroup memory / PSS,并确认 `/readyz` 与代表性 gallery、公开详情、创作入口和用户标签读取正常。 diff --git a/docs/【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md b/docs/【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md index e72bec65a..c8045545f 100644 --- a/docs/【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md +++ b/docs/【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md @@ -30,11 +30,24 @@ ## 验收 -- `cargo test -p spacetime-client --manifest-path server-rs/Cargo.toml --lib`(35 通过,含上述新测试) +- `cargo test -p spacetime-client --manifest-path server-rs/Cargo.toml --lib`(44 通过,含上述连接池与缓存连接测试) - `cargo test -p api-server --manifest-path server-rs/Cargo.toml readyz`(2 通过) - `cargo check -p api-server --manifest-path server-rs/Cargo.toml` +## 2026-07-14 缓存读连接拆分 + +HTTP 角色的连接拓扑调整为“`pool_size` 条调用连接 + 1 条缓存读连接”: + +1. 调用连接池只承接 procedure / reducer 回调,不订阅 read model,也不装载订阅行;`GENARRATIVE_SPACETIME_POOL_SIZE=8` 表示 8 条调用连接,不包含额外的缓存读连接。 +2. 缓存读连接只由 `read_after_connect` 使用,并持有一份 required / optional read-model subscriptions。多个本地读取通过 `Arc` 共享同一 SDK `ClientCache`,初始化和断线重建使用单飞锁,读取本身不经过单槽 semaphore 串行化。 +3. 缓存读连接断线后,下一次读取只重建一条连接并等待 required subscriptions 全部 applied;首次建连与 required / optional 订阅共用一次总超时预算,optional 阶段发生断线时禁止发布 broken 连接;旧连接由在途读取持有到结束后再断开。 +4. `/readyz` 对 HTTP 角色同时检查调用池连接和缓存读连接;required subscription 失败时不得报告 ready。worker / controller 关闭 read-model cache 时不创建额外连接。 +5. 这里的“只读”是 facade 用途边界。SpacetimeDB Rust SDK 2.6 没有连接级 read-only builder;缓存连接仍使用相同 runtime identity,但代码不向 procedure / reducer 调用路径暴露它。 + +该拆分把相同 read-model 行缓存从 `pool_size` 份降为 1 份;SDK 每条连接仍会注册空 table metadata,总 WebSocket 数在 HTTP 角色中会从 `pool_size` 增加到 `pool_size + 1`,因此不能把总 RSS 简单承诺为原来的 `1 / pool_size`。 + ## 运维提示 - 此修复解决的是"取消导致的永久泄漏"。StDB 真慢时仍会出现成批 45 秒超时(连接被在途请求合法占用),那是容量/上游问题,应结合 `GENARRATIVE_SPACETIME_POOL_SIZE` 与 StDB 负载排查,不要再怀疑池泄漏。 - 健康检查 `/readyz` 在池被在途请求占满时仍可能短暂 503(stage=pool_acquire),恢复后自动转好,无需重启。 +- HTTP 角色排查连接数时按“调用池 + 1 条缓存读连接”计算;外部生成唤醒和充值过期监听还有各自的窄订阅连接,不属于调用池或缓存读连接。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 11be34970..db8dfdc3b 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -57,7 +57,9 @@ Windows 本地如果已在 `%LOCALAPPDATA%\Genarrative\ffmpeg\bin` 安装 FFmpeg 本地排查外部内容生成 worker 队列时,默认同一 Rust 进程同时监听 HTTP 并消费 `external_generation_job` 队列;更接近生产的验证应分别启动 `api`、`external-generation-worker` 和 `external-generation-controller`。生产默认 `GENARRATIVE_PROCESS_ROLE=api`,外部生成任务由独立 `GENARRATIVE_PROCESS_ROLE=external-generation-worker` 进程消费;生产与容器扩缩容验证保持 `queue`。当前进入持久队列的外部生成动作包括:拼图 `compile_puzzle_draft` / `generate_puzzle_images` / `generate_puzzle_ui_background`,跳一跳 `compile-draft` / `regenerate-tiles`,拼消消 `compile-draft` / `regenerate-atlas`,敲木鱼 `compile-draft` / `regenerate-hit-object`,以及图片画布 `editor_image_generation` / `editor_image_edit` / `editor_background_removal` / `editor_icon_spritesheet_generation` / `editor_ui_design_asset_extraction` / `editor_character_animation_generation` / `editor_video_generation` / `editor_sound_effect_generation` / `editor_background_music_generation`。非外部 provider 生成动作继续 inline,不进入队列。显式把本地进程角色设为 `api` 且没有 worker 时,HTTP 只返回 queued/running,不会兜底执行外部 provider。 -生产拆分角色时,`external-generation-worker` 和 `external-generation-controller` 的专属 env 示例会把 `GENARRATIVE_SPACETIME_POOL_SIZE` 覆盖为 `1`;非 HTTP 角色只保留 `external_generation_job` 队列窄订阅作为响应式唤醒信号,实际抢占和扩缩容判断仍走 SpacetimeDB procedure,且不再订阅 API 读模型连接池。worker / controller 不执行模型定价 seed,启动时先调用受 runtime writer 鉴权的 queue-stats procedure 做只读预检,身份不匹配时 fail-fast;当前正式 systemd unit 通过共同加载 `/etc/genarrative/api-server.env` 继承同一 `GENARRATIVE_SPACETIME_TOKEN`,专属角色 env 示例不重复配置该 token。`GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS` 与 controller poll interval 只作为订阅失效、漏事件和 lease 过期这类时间条件的兜底,不作为正常领取任务的主路径。 +HTTP 角色的 `GENARRATIVE_SPACETIME_POOL_SIZE` 只表示 procedure / reducer 调用池大小;池连接不订阅 read model。HTTP 角色会额外创建 1 条共享缓存读连接,统一保存公开读模型、近期游玩统计、创作入口配置和用户标签等订阅行,因此配置为 `8` 时基础连接拓扑是 8 条调用连接加 1 条缓存读连接。`/readyz` 同时检查调用池与缓存读连接的 required subscriptions,缓存未准备好时不能放量。 + +生产拆分角色时,`external-generation-worker` 和 `external-generation-controller` 的专属 env 示例会把 `GENARRATIVE_SPACETIME_POOL_SIZE` 覆盖为 `1`;非 HTTP 角色不创建 API 缓存读连接,只保留 `external_generation_job` 队列窄订阅作为响应式唤醒信号,实际抢占和扩缩容判断仍走 SpacetimeDB procedure。worker / controller 不执行模型定价 seed,启动时先调用受 runtime writer 鉴权的 queue-stats procedure 做只读预检,身份不匹配时 fail-fast;当前正式 systemd unit 通过共同加载 `/etc/genarrative/api-server.env` 继承同一 `GENARRATIVE_SPACETIME_TOKEN`,专属角色 env 示例不重复配置该 token。`GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS` 与 controller poll interval 只作为订阅失效、漏事件和 lease 过期这类时间条件的兜底,不作为正常领取任务的主路径。 生产 worker 默认 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS=600`,只覆盖 worker 心跳抖动和短暂断连窗口,不再把 lease 当成完整任务时长;默认 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS=900`,角色动画 / 视频类长任务使用 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS=1800`。worker 在单次尝试超过执行预算后会停止当前尝试、写入失败 / 重试状态并释放 worker 槽位;如果 SpacetimeDB 当时不可写,当前租约最多再保留到 lease 过期,之后任务重新变为可领取。生产部署和 provision 脚本会给 `/etc/genarrative/api-server.env` 与 `/etc/genarrative/external-generation-worker.env` 补齐这些变量;已有自定义值不覆盖,只会把历史旧默认 `3600` 迁移为 `600`。 diff --git a/server-rs/crates/spacetime-client/README.md b/server-rs/crates/spacetime-client/README.md index 124c06c14..638f07223 100644 --- a/server-rs/crates/spacetime-client/README.md +++ b/server-rs/crates/spacetime-client/README.md @@ -24,7 +24,7 @@ 当前目录已不再只是占位。`WP-SC Spacetime Client` 在当前稳定 SpacetimeDB facade 范围内已经完成收尾: 1. 通过 `npm run spacetime:generate -- --rust-only` 生成并纳管公开 Rust bindings。 -2. `DbConnection` 连接池、握手等待、超时和断线清理已封装在 `SpacetimeClient` 内部。 +2. `DbConnection` 调用连接池、单一缓存读连接、握手等待、超时和断线清理已封装在 `SpacetimeClient` 内部;调用池不订阅 read model,HTTP 角色额外使用一条共享连接保存订阅行缓存。 3. 已稳定的 assets、auth、AI task、Big Fish、Custom World、Puzzle、Runtime/Profile/Save、Story session、combat、inventory、NPC facade 均通过 typed 方法对外暴露。 4. 生成绑定到 BFF record / module record 的 row snapshot mapper 已集中在 `mapper.rs`。 5. SDK 调用错误、reducer 业务错误、procedure 业务错误、缺快照错误和本地输入校验错误已统一收口到 `SpacetimeClientError` helper。 diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index b1ead3932..8144db3ab 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -370,6 +370,7 @@ pub struct AuthStoreProjectionSyncRecord { pub struct SpacetimeClient { config: SpacetimeClientConfig, pool: Arc, + cached_read_connection: Option>, health_state: Arc>, creation_entry_config_cache: Arc>>, feature_gate_config_cache: Arc>>>, @@ -454,11 +455,20 @@ struct PooledConnectionSlot { struct PooledConnection { connection: DbConnection, - _read_model_subscriptions: Vec, runner: Option>, broken: Arc, } +struct CachedReadConnectionState { + connection: RwLock>>, + initialize: tokio::sync::Mutex<()>, +} + +struct CachedReadConnection { + connection: PooledConnection, + _read_model_subscriptions: Vec, +} + struct PooledConnectionLease { slot_index: usize, connection: Option, @@ -505,10 +515,17 @@ impl SpacetimeClient { slots, permits: Arc::new(Semaphore::new(pool_size)), }); + let cached_read_connection = config.subscribe_cached_read_models.then(|| { + Arc::new(CachedReadConnectionState { + connection: RwLock::new(None), + initialize: tokio::sync::Mutex::new(()), + }) + }); Self { config, pool, + cached_read_connection, health_state: Arc::new(RwLock::new(SpacetimeClientHealthState::default())), creation_entry_config_cache: Arc::new(RwLock::new(None)), feature_gate_config_cache: Arc::new(RwLock::new(None)), @@ -655,11 +672,11 @@ impl SpacetimeClient { { let started_at = Instant::now(); let metrics_guard = telemetry::begin_read(read_name); - let lease = match self - .acquire_connection_with_timeout(self.config.procedure_timeout) + let connection = match self + .cached_read_connection_with_timeout(self.config.procedure_timeout) .await { - Ok(lease) => lease, + Ok(connection) => connection, Err(error) => { log_spacetime_client_failure( "read", @@ -673,14 +690,7 @@ impl SpacetimeClient { return final_result; } }; - let final_result = if let Some(connection) = lease.connection.as_ref() { - read(&connection.connection) - } else { - Err(SpacetimeClientError::Runtime( - "SpacetimeDB 连接租约缺少连接".to_string(), - )) - }; - self.release_connection(lease).await; + let final_result = read(&connection.connection.connection); if let Err(error) = &final_result { log_spacetime_client_failure( @@ -719,10 +729,22 @@ impl SpacetimeClient { }; let started_at = Instant::now(); let checked_at_micros = current_unix_micros(); - let result = self.acquire_connection_with_timeout(timeout).await; - match result { + let result = match self.acquire_connection_with_timeout(timeout).await { Ok(lease) => { self.release_connection(lease).await; + if self.cached_read_connection.is_some() { + let remaining = timeout.saturating_sub(started_at.elapsed()); + self.cached_read_connection_with_timeout(remaining) + .await + .map(|_| ()) + } else { + Ok(()) + } + } + Err(error) => Err(error), + }; + match result { + Ok(()) => { let mut health_state = self.health_state.write().await; health_state.last_success_at_micros = Some(checked_at_micros); health_state.last_error = None; @@ -826,6 +848,68 @@ impl SpacetimeClient { Ok(lease) } + async fn cached_read_connection_with_timeout( + &self, + operation_timeout: Duration, + ) -> Result, SpacetimeStageError> { + let started_at = Instant::now(); + let state = self.cached_read_connection.as_ref().ok_or_else(|| { + SpacetimeStageError::new( + SpacetimeClientStage::ReadCache, + SpacetimeClientError::Runtime("当前进程未启用 SpacetimeDB 缓存读连接".to_string()), + ) + })?; + + if let Some(connection) = state + .connection + .read() + .await + .clone() + .filter(|connection| !connection.is_broken()) + { + return Ok(connection); + } + + let remaining = operation_timeout.saturating_sub(started_at.elapsed()); + let _initialize = timeout(remaining, state.initialize.lock()) + .await + .map_err(|_| { + SpacetimeStageError::new( + SpacetimeClientStage::ReadCache, + SpacetimeClientError::Timeout(SpacetimeClientStage::ReadCache), + ) + })?; + + if let Some(connection) = state + .connection + .read() + .await + .clone() + .filter(|connection| !connection.is_broken()) + { + return Ok(connection); + } + + let remaining = operation_timeout.saturating_sub(started_at.elapsed()); + let connection = timeout(remaining, self.build_cached_read_connection(remaining)) + .await + .map_err(|_| { + SpacetimeStageError::new( + SpacetimeClientStage::ReadCache, + SpacetimeClientError::Timeout(SpacetimeClientStage::ReadCache), + ) + })??; + if connection.is_broken() { + return Err(SpacetimeStageError::new( + SpacetimeClientStage::ReadCache, + SpacetimeClientError::ConnectDropped, + )); + } + let connection = Arc::new(connection); + *state.connection.write().await = Some(connection.clone()); + Ok(connection) + } + async fn build_pooled_connection( &self, operation_timeout: Duration, @@ -894,21 +978,33 @@ impl SpacetimeClient { SpacetimeStageError::new(SpacetimeClientStage::ConnectHandshake, error) })?; - let read_model_subscriptions = if self.config.subscribe_cached_read_models { - self.subscribe_cached_read_models(&connection, broken.clone(), operation_timeout) - .await? - } else { - Vec::new() - }; - Ok(PooledConnection { connection, - _read_model_subscriptions: read_model_subscriptions, runner: Some(runner), broken, }) } + async fn build_cached_read_connection( + &self, + operation_timeout: Duration, + ) -> Result { + let connection = self.build_pooled_connection(operation_timeout).await?; + let subscriptions = self + .subscribe_cached_read_models( + &connection.connection, + connection.broken.clone(), + operation_timeout, + ) + .await?; + Self::ensure_cached_read_connection_alive(connection.broken.as_ref())?; + + Ok(CachedReadConnection { + connection, + _read_model_subscriptions: subscriptions, + }) + } + async fn subscribe_cached_read_models( &self, connection: &DbConnection, @@ -917,6 +1013,7 @@ impl SpacetimeClient { ) -> Result, SpacetimeStageError> { let mut subscriptions = Vec::new(); for query in REQUIRED_CACHED_READ_MODEL_QUERIES { + Self::ensure_cached_read_connection_alive(broken.as_ref())?; let subscription = self .subscribe_cached_read_model_query( connection, @@ -930,7 +1027,8 @@ impl SpacetimeClient { } for query in OPTIONAL_CACHED_READ_MODEL_QUERIES { - if let Ok(subscription) = self + Self::ensure_cached_read_connection_alive(broken.as_ref())?; + match self .subscribe_cached_read_model_query( connection, broken.clone(), @@ -940,13 +1038,33 @@ impl SpacetimeClient { ) .await { - subscriptions.push(subscription); + Ok(subscription) => subscriptions.push(subscription), + Err(error) => { + Self::ensure_cached_read_connection_alive(broken.as_ref())?; + warn!( + query, + spacetime_stage = error.stage.as_str(), + error = %error.error, + "SpacetimeDB optional cached read-model subscription failed" + ); + } } } + Self::ensure_cached_read_connection_alive(broken.as_ref())?; Ok(subscriptions) } + fn ensure_cached_read_connection_alive(broken: &AtomicBool) -> Result<(), SpacetimeStageError> { + if broken.load(Ordering::SeqCst) { + return Err(SpacetimeStageError::new( + SpacetimeClientStage::ReadModelSubscribe, + SpacetimeClientError::ConnectDropped, + )); + } + Ok(()) + } + async fn subscribe_cached_read_model_query( &self, connection: &DbConnection, @@ -1105,6 +1223,12 @@ impl PooledConnection { } } +impl CachedReadConnection { + fn is_broken(&self) -> bool { + self.connection.is_broken() + } +} + impl Drop for PooledConnection { fn drop(&mut self) { let _ = self.connection.disconnect(); @@ -1119,6 +1243,10 @@ impl fmt::Debug for SpacetimeClient { f.debug_struct("SpacetimeClient") .field("config", &self.config) .field("pool_size", &self.pool.slots.len()) + .field( + "cached_read_connection_enabled", + &self.cached_read_connection.is_some(), + ) .finish() } } @@ -1230,6 +1358,94 @@ mod tests { }) } + fn test_client_with_cached_reads(pool_size: u32) -> SpacetimeClient { + SpacetimeClient::new(SpacetimeClientConfig { + server_url: "http://127.0.0.1:9".to_string(), + database: "pool-test".to_string(), + token: None, + pool_size, + procedure_timeout: Duration::from_millis(200), + subscribe_cached_read_models: true, + }) + } + + #[test] + fn cached_read_models_use_one_connection_outside_the_procedure_pool() { + let client = test_client_with_cached_reads(8); + + assert_eq!(client.pool.slots.len(), 8); + assert!(client.cached_read_connection.is_some()); + assert!( + client.pool.slots.iter().all(|slot| slot + .connection + .try_lock() + .expect("slot lock") + .is_none()) + ); + } + + #[test] + fn non_http_client_does_not_allocate_cached_read_connection_state() { + let client = test_client(1, Duration::from_millis(200)); + + assert_eq!(client.pool.slots.len(), 1); + assert!(client.cached_read_connection.is_none()); + } + + #[tokio::test] + async fn cached_read_fails_before_connect_when_role_disables_read_models() { + let client = test_client(1, Duration::from_millis(200)); + let result = client + .cached_read_connection_with_timeout(Duration::from_millis(200)) + .await; + + let error = match result { + Ok(_) => panic!("未启用 read model 的进程不应创建缓存读连接"), + Err(error) => error, + }; + assert_eq!(error.stage, SpacetimeClientStage::ReadCache); + assert!(matches!(error.error, SpacetimeClientError::Runtime(_))); + } + + #[tokio::test] + async fn cached_read_initialization_wait_respects_total_timeout() { + let client = test_client_with_cached_reads(1); + let state = client + .cached_read_connection + .as_ref() + .expect("cached read state") + .clone(); + let _initialize = state.initialize.lock().await; + let operation_timeout = Duration::from_millis(25); + let started_at = Instant::now(); + + let result = client + .cached_read_connection_with_timeout(operation_timeout) + .await; + let error = match result { + Ok(_) => panic!("singleflight wait should time out"), + Err(error) => error, + }; + + assert_eq!(error.stage, SpacetimeClientStage::ReadCache); + assert!(matches!( + error.error, + SpacetimeClientError::Timeout(SpacetimeClientStage::ReadCache) + )); + assert!(started_at.elapsed() < Duration::from_millis(150)); + } + + #[test] + fn broken_cached_read_connection_cannot_be_published() { + let broken = AtomicBool::new(true); + + let error = SpacetimeClient::ensure_cached_read_connection_alive(&broken) + .expect_err("broken cached read connection should fail"); + + assert_eq!(error.stage, SpacetimeClientStage::ReadModelSubscribe); + assert!(matches!(error.error, SpacetimeClientError::ConnectDropped)); + } + /// 复现线上故障机制:修复前请求 future 被取消时租约不会归还,槽位永久停留在 in_use, /// 后续 acquire 拿着 permit 空转挂死。修复后租约 Drop 必须同时复位槽位与 permit。 #[tokio::test] From 8d1c640eb54f3322c29e521b235aad5f8654566d Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 15 Jul 2026 16:58:18 +0800 Subject: [PATCH 02/18] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=B2=BE=E9=80=89?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E8=AF=BB=E5=8F=96=E4=B8=8E=E5=AE=A1=E6=A0=B8?= =?UTF-8?q?=E8=BF=94=E8=BF=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为已通过、已展示且返还完成的精选资产补充精确匿名读取授权 将精选批准、泥点返还和返还完成标记收进同一SpacetimeDB事务 保留确定性返还流水的幂等语义并支持修复历史半完成记录 补充精选公开门禁测试并同步后端与创作主页文档 --- .../shared-memory/decision-log.md | 1 + ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- ...作】创作主页与项目入口改版计划-2026-06-18.md | 2 +- server-rs/crates/api-server/src/admin.rs | 48 +----- .../src/asset_metadata/objects.rs | 4 + .../src/editor_project_storage.rs | 149 +++++++++++++++++- .../spacetime-module/src/runtime/profile.rs | 22 +++ 7 files changed, 180 insertions(+), 48 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index dea2c90c6..7c6dec1b2 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -177,6 +177,7 @@ - 决策:`陶泥儿精选` 的公开事实改为独立 `editor_showcase_asset` 审核表。生成素材默认不公开;用户在账号级素材库对 `sourceType="generated"` 且有媒体内容的素材提交审核,后端快照素材信息并写入 `pending`。后台审核通过后写入 `approved`,但默认 `display_enabled=false` 且 `showcase_category=null`,运营可按前台具体 Tab 手动设置分类并开启展示;未设置分类的素材展示开启后进入前台“全部”,但不进入角色 / UI / 音乐 / 美宣具体分类。审核通过时按 `generation_cost_mud_points` 返还 50% 泥点;拒绝后写入 `rejected`。公开接口 `GET /api/editor/showcase/resources` 返回已通过、展示开启且媒体非空的快照,按通过时间和 `showcaseId` 倒序分页,并可携带后台配置的固定活动卡。旧 `editor_project_resource.public_showcase_enabled` 和旧 PATCH 接口只保留兼容,不再驱动精选公开。 - 影响范围:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`、`spacetime-client` 绑定与 mapper、`api-server` 编辑器和后台路由、admin-web 精选审核页、素材库右键菜单、`/creation` 精选瀑布流、图片画布文档和后端表目录。 - 验证方式:运行 `npm run spacetime:generate`、`npm run check:spacetime-schema`、`cargo check --manifest-path server-rs/Cargo.toml -p spacetime-module -p spacetime-client -p api-server`、前端 / 后台 typecheck 与精选相关组件测试,确认默认不公开、提交后 pending、审核通过后展示和返还、展示开关与点赞生效。 +- 后续修正:精选批准、确定性返还流水和返还完成标记必须由同一个 SpacetimeDB procedure 在单事务内落地,失败时不得先留下 `approved`;已公开精选私有对象通过同 owner 的精确 `assetObjectId` / `objectKey` 派生匿名读取授权,不把 `generated-*` 前缀整体公开。 - 关联文档:`docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 ## 2026-07-03 外部编辑器 API 生成默认写入画布与素材库 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 7876582e1..dc1b8b25f 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -58,7 +58,7 @@ npm run check:server-rs-ddd - 认证与账号:`/api/auth/*`、`/api/profile/me`,包括短信、密码、微信、refresh session、多端会话和登出。 - 个人中心:`/api/profile/*`,包括钱包流水、任务、领奖、充值、反馈、邀请和兑换等账号侧能力。 - 平台基础能力:`/api/llm/*`、`/api/speech/volcengine/*`,只保留通用 LLM 和语音代理。 -- 资产基础能力:`/api/assets/direct-upload-tickets`、`/api/assets/sts-upload-credentials`、`/api/assets/objects/*`、`/api/assets/read-url`、`/api/assets/read-bytes`,负责直传、确认、绑定和读取。两个读取入口共用同一授权函数,并通过受 runtime service identity 限制的 procedure 在同一事务快照内按配置 bucket 与精确 key 权威查询 `asset_object`、计算 `public_work_asset_read_grant`;不得把任意连接的订阅 cache miss 或命中解释为当前授权真相。一旦存在 metadata,即使 key 命中 legacy 前缀,也必须按 `PublicRead`、当前登录 owner,或同 owner 的公开作品派生授权精确读取;只有同 bucket / key 的权威查询确认未登记时,才允许显式 `legacyPublicPath` 命中 `platform_oss::LEGACY_PUBLIC_PREFIXES` curated 白名单后匿名兼容。已登记资产继续保持 `private`,公开作品只获得与正式发布快照生命周期一致的派生读授权,不得批量改为 `PublicRead` 或放开 `generated-*` 前缀。任意未登记 `objectKey`、跨 owner 和未获授权的匿名私有读取统一返回不存在,`read-bytes` 不得成为绕过 `read-url` 授权的同源代理;公开派生授权、`PublicRead` 和 legacy 兼容读取签发的 URL 统一限制为最长 600 秒,owner / admin 读取保持原有有效期口径。 +- 资产基础能力:`/api/assets/direct-upload-tickets`、`/api/assets/sts-upload-credentials`、`/api/assets/objects/*`、`/api/assets/read-url`、`/api/assets/read-bytes`,负责直传、确认、绑定和读取。两个读取入口共用同一授权函数,并通过受 runtime service identity 限制的 procedure 在同一事务快照内按配置 bucket 与精确 key 权威查询 `asset_object`、计算公开作品或公开精选派生授权;不得把任意连接的订阅 cache miss 或命中解释为当前授权真相。一旦存在 metadata,即使 key 命中 legacy 前缀,也必须按 `PublicRead`、当前登录 owner、同 owner 的公开作品派生授权,或同 owner 且已通过、已展示、返还完成的 `editor_showcase_asset` 精确授权读取;只有同 bucket / key 的权威查询确认未登记时,才允许显式 `legacyPublicPath` 命中 `platform_oss::LEGACY_PUBLIC_PREFIXES` curated 白名单后匿名兼容。已登记资产继续保持 `private`,公开作品和公开精选只获得与正式公开快照生命周期一致的精确派生读授权,不得批量改为 `PublicRead` 或放开 `generated-*` 前缀。任意未登记 `objectKey`、跨 owner 和未获授权的匿名私有读取统一返回不存在,`read-bytes` 不得成为绕过 `read-url` 授权的同源代理;公开派生授权、`PublicRead` 和 legacy 兼容读取签发的 URL 统一限制为最长 600 秒,owner / admin 读取保持原有有效期口径。 - 外部 OpenAPI:`/api/external/v1/openapi.json`、`/api/external/v1/assets/direct-upload-tickets`、`/api/external/v1/assets/objects/confirm`、`/api/external/v1/assets/read-url`、`/api/external/v1/editor/*`,使用 Bearer API Key 鉴权;API Key 管理仍在登录态 `/api/profile/api-keys`,不进入外部 OpenAPI JSON。主站和 External 的 asset object confirm 都必须从已认证主体派生 owner,不能信任请求体 owner;同 bucket / key 已登记后不得改变 owner。 - 创作 / 游玩支撑能力:`/api/creation-entry/config`、`/api/ai/tasks*`、`/api/runtime/frontend-config`、`/api/runtime/chat/*`、`/api/runtime/settings`、`/api/runtime/save/snapshot`、`/api/profile/browse-history`、`/api/profile/save-archives*`、`/api/profile/play-stats`、`/api/assets/history`、`/api/assets/character-visual/*`、`/api/assets/character-animation/*`、`/api/assets/character-workflow-cache*`、`/api/assets/hyper3d/*`、`/api/runtime/custom-world/asset-studio/*`、`/api/editor/projects*`、`/api/editor/projects/{projectId}/agent-conversations`、`/api/editor/agent-conversations/{conversationId}*`。`/api/runtime/frontend-config` 由 `api-server` 从运行时环境变量下发非敏感 UI 开关;画板右侧 Agent 入口由 `GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR` 控制,默认关闭,前端不再读取 `VITE_*` 构建期变量决定生产显示。`/api/runtime/custom-world/asset-studio/*` 解析默认角色形象 / 动作提示词时可以在 OSS 缓存不可用或未配置时按无缓存返回默认提示;保存 workflow 缓存和真实素材读写仍必须要求 OSS 正常可用。 - 后台入口配置:`/admin/api/creation-entry/config`、`/admin/api/creation-entry/config/banners` 和 `/admin/api/creation-entry/config/interactions`。 diff --git a/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md b/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md index b909b56d4..2a1a3d336 100644 --- a/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md +++ b/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md @@ -75,7 +75,7 @@ `陶泥儿精选` 是页面底部的全站公开画布生成素材瀑布流,不承载玩法入口列表。瀑布流卡片按真实素材宽高设置预览比例,同一行允许出现不同高度卡片,不使用固定等高网格。创作入口配置仍继续来自 `/api/creation-entry/config`,供旧创作入口和具体 `/creation/` 工作台使用,但不作为本页精选区内容。 -精选内容只使用用户从账号级素材库主动提交、后台审核通过且展示状态开启的 `editor_showcase_asset` 快照。新生成素材不会默认公开,审核通过后也不会自动展示;运营可在后台按前台具体 Tab 设置分类(角色、UI、音乐、美宣)并开启展示,未设置分类的素材不会隐藏,会进入前台“全部”。旧 `editor_project_resource.public_showcase_enabled` 只保留历史兼容,不再作为 `/creation` 精选事实源。账号级 `editor_asset` 仍是素材库私有事实;只有 `sourceType="generated"`、有媒体内容、提交审核并通过的素材才可进入精选,上传素材、公开作品图片和 `mock_generated` 资源都不进入精选。审核通过时按生成成本返还 50% 泥点,返还流水使用确定性 `editor-showcase-refund:{showcaseId}` 保证幂等。公开 BFF 必须返回作者公开展示字段:优先 `authorDisplayName` / `display_name`,没有展示名时兜底 `authorPublicUserCode` / 陶泥号;前端展示绝不能兜底到内部 `ownerUserId` / `user_id`。若现有数据缺少提示词、作者公开标识或成本字段,v1 显示保守占位,不伪造内容。 +精选内容只使用用户从账号级素材库主动提交、后台审核通过且展示状态开启的 `editor_showcase_asset` 快照。新生成素材不会默认公开,审核通过后也不会自动展示;运营可在后台按前台具体 Tab 设置分类(角色、UI、音乐、美宣)并开启展示,未设置分类的素材不会隐藏,会进入前台“全部”。旧 `editor_project_resource.public_showcase_enabled` 只保留历史兼容,不再作为 `/creation` 精选事实源。账号级 `editor_asset` 仍是素材库私有事实;只有 `sourceType="generated"`、有媒体内容、提交审核并通过的素材才可进入精选,上传素材、公开作品图片和 `mock_generated` 资源都不进入精选。审核通过时按生成成本返还 50% 泥点,返还流水使用确定性 `editor-showcase-refund:{showcaseId}` 保证幂等;批准状态、钱包返还流水和 `refund_completed_at` 必须在同一个 SpacetimeDB 事务中完成,任一步失败都保持待审核,历史已通过但未返还记录重复批准时按同一流水补齐且不得重复入账。已通过、已开启展示且返还完成的精选快照必须按同 owner 的精确 `assetObjectId` 或 `objectKey` 派生匿名读取授权,不能放开整个 `generated-*` 前缀。公开 BFF 必须返回作者公开展示字段:优先 `authorDisplayName` / `display_name`,没有展示名时兜底 `authorPublicUserCode` / 陶泥号;前端展示绝不能兜底到内部 `ownerUserId` / `user_id`。若现有数据缺少提示词、作者公开标识或成本字段,v1 显示保守占位,不伪造内容。 瀑布流通过 `GET /api/editor/showcase/resources` 按通过审核时间 / `showcaseId` 倒序 cursor 分页读取,每页最多 36 条;响应有 `nextCursor` 时,页面滚动到底部继续请求 `?cursor=...` 并追加到现有瀑布流,而不是固定只展示首屏数量。响应可以额外携带后台配置的固定活动卡,用于在列表首位展示运营精选。 diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index 5621af679..b13d2a1aa 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -55,9 +55,9 @@ use spacetime_client::{ AdminDashboardStatsRecord, AdminDashboardStatsRecordInput, AdminEditorAssetListRecordInput, AdminEditorAssetRecord, EditorShowcaseAssetAdminListRecordInput, EditorShowcaseAssetAdminReviewRecordInput, EditorShowcaseAssetDisplayUpdateRecordInput, - EditorShowcaseAssetRecord, EditorShowcaseAssetRefundMarkRecordInput, - EditorShowcaseCampaignConfigGetRecordInput, EditorShowcaseCampaignConfigRecord, - EditorShowcaseCampaignConfigUpsertRecordInput, SpacetimeClientError, + EditorShowcaseAssetRecord, EditorShowcaseCampaignConfigGetRecordInput, + EditorShowcaseCampaignConfigRecord, EditorShowcaseCampaignConfigUpsertRecordInput, + SpacetimeClientError, }; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use tokio::sync::{OnceCell, Semaphore}; @@ -724,7 +724,6 @@ pub async fn admin_review_editor_showcase_asset( }) .await .map_err(map_admin_spacetime_error)?; - let record = refund_approved_editor_showcase_asset_if_needed(&state, record).await?; Ok(json_success_body( Some(&request_context), AdminEditorShowcaseAssetResponse { @@ -943,47 +942,6 @@ fn map_admin_feature_gate_config( } } -async fn refund_approved_editor_showcase_asset_if_needed( - state: &AppState, - record: EditorShowcaseAssetRecord, -) -> Result { - if record.review_status != "approved" - || record.refund_mud_points == 0 - || record.refund_completed_at.is_some() - { - return Ok(record); - } - let Some(ledger_id) = record.refund_ledger_id.clone() else { - return Ok(record); - }; - state - .spacetime_client() - .refund_profile_wallet_points_with_metadata( - record.owner_user_id.clone(), - record.refund_mud_points, - ledger_id.clone(), - current_utc_micros(), - serde_json::json!({ - "source": "editor_showcase_approval", - "showcaseId": record.showcase_id, - "assetId": record.asset_id, - "generationCostMudPoints": record.generation_cost_mud_points, - }) - .to_string(), - ) - .await - .map_err(map_admin_spacetime_error)?; - state - .spacetime_client() - .mark_editor_showcase_asset_refunded(EditorShowcaseAssetRefundMarkRecordInput { - showcase_id: record.showcase_id, - refund_ledger_id: ledger_id, - updated_at_micros: current_utc_micros(), - }) - .await - .map_err(map_admin_spacetime_error) -} - fn admin_editor_showcase_asset_payload_from_record( state: &AppState, record: EditorShowcaseAssetRecord, diff --git a/server-rs/crates/spacetime-module/src/asset_metadata/objects.rs b/server-rs/crates/spacetime-module/src/asset_metadata/objects.rs index f5c14f6e2..e4fae51d8 100644 --- a/server-rs/crates/spacetime-module/src/asset_metadata/objects.rs +++ b/server-rs/crates/spacetime-module/src/asset_metadata/objects.rs @@ -136,6 +136,10 @@ pub fn get_asset_read_access_by_location_and_return( let record = find_asset_object_by_location(tx, &input)?; let public_work_granted = record.as_ref().is_some_and(|asset_object| { crate::public_asset_access::asset_object_has_public_work_read_grant(tx, asset_object) + || crate::editor_project_storage::asset_object_has_public_showcase_read_grant( + tx, + asset_object, + ) }); Ok((record, public_work_granted)) }) { diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index 2ff915a06..425084fce 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -1156,7 +1156,10 @@ pub fn admin_review_editor_showcase_asset_and_return( ctx: &mut ProcedureContext, input: EditorShowcaseAssetAdminReviewInput, ) -> EditorShowcaseAssetProcedureResult { - match ctx.try_with_tx(|tx| admin_review_editor_showcase_asset(tx, input.clone())) { + match ctx.try_with_tx(|tx| { + let asset = admin_review_editor_showcase_asset(tx, input.clone())?; + refund_approved_editor_showcase_asset(tx, asset, input.updated_at_micros) + }) { Ok(asset) => editor_showcase_asset_ok(Some(asset)), Err(message) => editor_showcase_asset_error(message), } @@ -2133,6 +2136,45 @@ fn list_public_editor_showcase_assets( Ok(assets) } +pub(crate) fn asset_object_has_public_showcase_read_grant( + ctx: &ReducerContext, + asset_object: &module_assets::AssetObjectUpsertSnapshot, +) -> bool { + let asset_object = module_assets::build_asset_object_record(asset_object.clone()); + let Some(owner_user_id) = asset_object + .owner_user_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return false; + }; + + ctx.db + .editor_showcase_asset() + .by_editor_showcase_asset_owner_user_id() + .filter(owner_user_id) + .filter(is_public_showcase_asset) + .any(|asset| { + module_assets::asset_object_matches_public_read_grant( + &asset_object, + &module_assets::PublicAssetReadGrant { + owner_user_id: asset.owner_user_id, + asset_object_id: asset.asset_object_id, + object_key: asset.object_key, + }, + ) + }) +} + +fn is_public_showcase_asset(asset: &EditorShowcaseAsset) -> bool { + asset.review_status == EDITOR_SHOWCASE_STATUS_APPROVED + && asset.display_enabled + && !asset.asset_deleted_while_pending + && (asset.refund_mud_points == 0 || asset.refund_completed_at.is_some()) + && !asset.image_src.trim().is_empty() +} + fn admin_list_editor_showcase_assets( ctx: &ReducerContext, input: EditorShowcaseAssetAdminListInput, @@ -2238,6 +2280,48 @@ fn admin_review_editor_showcase_asset( .ok_or_else(|| "精选审核记录更新失败".to_string()) } +fn refund_approved_editor_showcase_asset( + ctx: &ReducerContext, + asset: EditorShowcaseAssetSnapshot, + updated_at_micros: i64, +) -> Result { + if asset.review_status != EDITOR_SHOWCASE_STATUS_APPROVED + || asset.refund_mud_points == 0 + || asset.refund_completed_at_micros.is_some() + { + return Ok(asset); + } + let refund_ledger_id = asset + .refund_ledger_id + .clone() + .ok_or_else(|| "精选返还流水缺失".to_string())?; + let refunded_at = Timestamp::from_micros_since_unix_epoch(updated_at_micros); + let metadata_json = serde_json::json!({ + "source": "editor_showcase_approval", + "showcaseId": asset.showcase_id.as_str(), + "assetId": asset.asset_id.as_str(), + "generationCostMudPoints": asset.generation_cost_mud_points, + }) + .to_string(); + crate::runtime::grant_profile_wallet_points_with_metadata( + ctx, + asset.owner_user_id.as_str(), + asset.refund_mud_points, + RuntimeProfileWalletLedgerSourceType::AssetOperationRefund, + refund_ledger_id.as_str(), + refunded_at, + metadata_json.as_str(), + )?; + mark_editor_showcase_asset_refunded( + ctx, + EditorShowcaseAssetRefundMarkInput { + showcase_id: asset.showcase_id, + refund_ledger_id, + updated_at_micros, + }, + ) +} + fn update_editor_showcase_asset_display( ctx: &ReducerContext, input: EditorShowcaseAssetDisplayUpdateInput, @@ -4276,6 +4360,47 @@ mod tests { use super::*; use serde_json::json; + fn public_showcase_asset() -> EditorShowcaseAsset { + let now = Timestamp::from_micros_since_unix_epoch(1_000_000); + EditorShowcaseAsset { + showcase_id: "editor-showcase:asset-1".to_string(), + asset_id: "asset-1".to_string(), + owner_user_id: "user-owner".to_string(), + label: "精选素材".to_string(), + asset_object_id: Some("asset-object-1".to_string()), + image_src: "/generated-characters/editor/asset-1.png".to_string(), + object_key: Some("generated-characters/editor/asset-1.png".to_string()), + width: 1024, + height: 1024, + source_type: "generated".to_string(), + prompt: None, + actual_prompt: None, + model: None, + provider: None, + task_id: None, + source_resource_id: None, + asset_kind: Some("character".to_string()), + generation_inputs_json: None, + thumbnail_src: None, + generation_cost_mud_points: 5, + refund_mud_points: 3, + review_status: EDITOR_SHOWCASE_STATUS_APPROVED.to_string(), + display_enabled: true, + like_count: 0, + asset_deleted_while_pending: false, + reviewed_by_admin_user_id: Some("admin".to_string()), + review_note: None, + refund_ledger_id: Some("editor-showcase-refund:editor-showcase:asset-1".to_string()), + refund_completed_at: Some(now), + submitted_at: now, + reviewed_at: Some(now), + approved_at: Some(now), + rejected_at: None, + updated_at: now, + showcase_category: Some("characters".to_string()), + } + } + fn tiered_pricing(model: &str, unit: &str, tiers: &[&str]) -> EditorGenerationModelPricing { EditorGenerationModelPricing { model: model.to_string(), @@ -4652,6 +4777,28 @@ mod tests { )); } + #[test] + fn public_showcase_read_grant_requires_approved_displayed_refunded_media() { + assert!(is_public_showcase_asset(&public_showcase_asset())); + + assert!(!is_public_showcase_asset(&EditorShowcaseAsset { + review_status: EDITOR_SHOWCASE_STATUS_PENDING.to_string(), + ..public_showcase_asset() + })); + assert!(!is_public_showcase_asset(&EditorShowcaseAsset { + display_enabled: false, + ..public_showcase_asset() + })); + assert!(!is_public_showcase_asset(&EditorShowcaseAsset { + refund_completed_at: None, + ..public_showcase_asset() + })); + assert!(!is_public_showcase_asset(&EditorShowcaseAsset { + image_src: String::new(), + ..public_showcase_asset() + })); + } + #[test] fn editor_asset_task_group_uses_final_output_as_primary() { let assets = vec![ diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index 4de291ff9..3e5df5323 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -8680,6 +8680,28 @@ pub(crate) fn grant_profile_wallet_points( ) } +pub(crate) fn grant_profile_wallet_points_with_metadata( + ctx: &ReducerContext, + user_id: &str, + amount_delta: u64, + source_type: RuntimeProfileWalletLedgerSourceType, + ledger_id: &str, + created_at: Timestamp, + metadata_json: &str, +) -> Result { + apply_profile_wallet_signed_delta( + ctx, + user_id, + convert_runtime_profile_wallet_unsigned_delta(amount_delta) + .map_err(|error| error.to_string())?, + source_type, + ledger_id, + created_at, + true, + metadata_json, + ) +} + fn apply_profile_wallet_adjustment( ctx: &ReducerContext, input: RuntimeProfileWalletAdjustmentInput, From 793ec1b6b0a1becde1de9a99d560ac1ec2f4ac52 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 15 Jul 2026 20:02:34 +0800 Subject: [PATCH 03/18] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E7=B4=A0=E6=9D=90=E6=9F=A5=E8=AF=A2=E5=88=86=E9=A1=B5=E4=B8=8E?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E5=BD=92=E7=BB=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 支持陶泥号解析、稳定游标和后台素材多页查询 补齐查询防抖、请求取消、刷新保留与加载更多状态 新增可信来源索引和拆分批次完成事实,修复跨项目与删除切片归组 同步生成绑定、数据契约、项目记忆和回归测试 --- apps/admin-web/src/api/adminApiClient.ts | 3 +- apps/admin-web/src/api/adminApiTypes.ts | 1 + .../pages/AdminEditorAssetQueryPage.test.tsx | 459 +++++++- .../src/pages/AdminEditorAssetQueryPage.tsx | 163 ++- docs/project-memory/shared-memory/pitfalls.md | 8 + ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 4 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 18 +- server-rs/crates/api-server/src/admin.rs | 413 +++++-- .../crates/api-server/src/editor_project.rs | 142 ++- .../crates/shared-contracts/src/admin.rs | 1 + .../spacetime-client/src/editor_project.rs | 50 + server-rs/crates/spacetime-client/src/lib.rs | 3 +- .../crates/spacetime-client/src/mapper.rs | 4 +- .../src/mapper/editor_project.rs | 62 + .../spacetime-client/src/module_bindings.rs | 69 ++ .../admin_editor_asset_snapshot_type.rs | 1 + ...asset_group_cohort_and_return_procedure.rs | 62 + .../editor_asset_create_input_type.rs | 2 + ..._asset_group_cohort_complete_input_type.rs | 20 + .../editor_asset_group_cohort_table.rs | 162 +++ .../editor_asset_group_cohort_type.rs | 67 ++ ...or_asset_group_source_lookup_input_type.rs | 18 + ...tor_asset_group_source_provenance_table.rs | 174 +++ ...itor_asset_group_source_provenance_type.rs | 64 ++ .../editor_asset_snapshot_type.rs | 1 + .../src/module_bindings/editor_asset_type.rs | 9 + ...asset_group_source_and_return_procedure.rs | 59 + .../src/editor_project_storage.rs | 1008 ++++++++++++++++- .../crates/spacetime-module/src/migration.rs | 7 + 29 files changed, 2885 insertions(+), 169 deletions(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/complete_editor_asset_group_cohort_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_asset_group_cohort_complete_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_asset_group_cohort_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_asset_group_cohort_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_asset_group_source_lookup_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_asset_group_source_provenance_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_asset_group_source_provenance_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/find_editor_asset_group_source_and_return_procedure.rs diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 73540117a..8b1f4eb25 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -398,10 +398,11 @@ export function getAdminAssetReadUrl( export function listAdminEditorAssets( token: string, query: AdminEditorAssetListQuery = {}, + signal?: AbortSignal, ) { return request( `/admin/api/editor-assets${buildEditorAssetListQuery(query)}`, - { token }, + { token, signal }, ); } diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 8841b9ae0..67d5740f0 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -460,6 +460,7 @@ export interface AdminEditorAssetPayload { model?: string | null; provider?: string | null; taskId?: string | null; + groupTaskId?: string | null; assetKind?: string | null; generationInputs?: Record | null; sourceResourceId?: string | null; diff --git a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx index 9d360b81c..5bd550cd3 100644 --- a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx +++ b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx @@ -15,10 +15,16 @@ import { getAdminAssetReadUrl, listAdminEditorAssets, } from '../api/adminApiClient'; -import type { AdminEditorAssetPayload } from '../api/adminApiTypes'; +import type { + AdminEditorAssetListResponse, + AdminEditorAssetPayload, +} from '../api/adminApiTypes'; import { AdminEditorAssetQueryPage } from './AdminEditorAssetQueryPage'; vi.mock('../api/adminApiClient', () => ({ + formatAdminApiError: vi.fn((error: unknown) => + error instanceof Error ? error.message : '请求失败', + ), getAdminAssetReadUrl: vi.fn(), isAdminApiError: vi.fn( (error: unknown) => @@ -186,6 +192,16 @@ function generatedAssetAt(index: number): AdminEditorAssetPayload { }; } +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + function thumbnailElementForLabel(label: string) { const row = screen.getByText(label).closest('tr'); const thumbnail = row?.querySelector('.admin-asset-query-thumb'); @@ -215,8 +231,8 @@ test('后台素材查询按用户、搜索和时间调用查询接口', async () await screen.findByRole('img', { name: '素材:角色形象 1' }); - fireEvent.change(screen.getByLabelText('用户 ID'), { - target: { value: 'user-1' }, + fireEvent.change(screen.getByLabelText('用户 ID / 陶泥号'), { + target: { value: 'SY-00000055' }, }); fireEvent.change(screen.getByLabelText('搜索'), { target: { value: '陶泥角色' }, @@ -229,16 +245,329 @@ test('后台素材查询按用户、搜索和时间调用查询接口', async () }); await waitFor(() => { - expect(listAdminEditorAssets).toHaveBeenLastCalledWith('admin-token', { - ownerUserId: 'user-1', - keyword: '陶泥角色', - createdAfter: '2026-07-01T00:00:00+08:00', - createdBefore: '2026-07-04T23:59:59.999+08:00', - limit: 80, - }); + expect(listAdminEditorAssets).toHaveBeenLastCalledWith( + 'admin-token', + { + ownerUserId: 'SY-00000055', + keyword: '陶泥角色', + createdAfter: '2026-07-01T00:00:00+08:00', + createdBefore: '2026-07-04T23:59:59.999+08:00', + limit: 80, + }, + expect.anything(), + ); }); }); +test('后台素材查询逐字输入时只请求最终筛选值', async () => { + installIntersectionObserverMock(); + render( + , + ); + + await screen.findByText('角色形象 1'); + vi.mocked(listAdminEditorAssets).mockClear(); + vi.useFakeTimers(); + const ownerInput = screen.getByLabelText('用户 ID / 陶泥号'); + const values = [ + 'S', + 'SY', + 'SY-', + 'SY-0', + 'SY-00', + 'SY-000', + 'SY-0000', + 'SY-00000', + 'SY-000000', + 'SY-0000005', + 'SY-00000055', + ]; + for (const [index, value] of values.entries()) { + fireEvent.change(ownerInput, { target: { value } }); + if (index < values.length - 1) { + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); + expect(listAdminEditorAssets).not.toHaveBeenCalled(); + } + } + + expect(listAdminEditorAssets).not.toHaveBeenCalled(); + await act(async () => { + await vi.advanceTimersByTimeAsync(299); + }); + expect(listAdminEditorAssets).not.toHaveBeenCalled(); + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + expect(listAdminEditorAssets).toHaveBeenCalledTimes(1); + expect(listAdminEditorAssets).toHaveBeenLastCalledWith( + 'admin-token', + { + ownerUserId: 'SY-00000055', + keyword: null, + createdAfter: null, + createdBefore: null, + limit: 80, + }, + expect.anything(), + ); +}); + +test('后台素材查询只应用最新筛选请求的结果和加载状态', async () => { + const staleResponse = createDeferred(); + const currentResponse = createDeferred(); + vi.mocked(listAdminEditorAssets) + .mockResolvedValueOnce({ + entries: [generatedAsset], + nextCursor: null, + }) + .mockImplementationOnce(() => staleResponse.promise) + .mockImplementationOnce(() => currentResponse.promise); + + render( + , + ); + + await screen.findByText('角色形象 1'); + fireEvent.change(screen.getByLabelText('用户 ID / 陶泥号'), { + target: { value: 'user-stale' }, + }); + await waitFor(() => expect(listAdminEditorAssets).toHaveBeenCalledTimes(2)); + fireEvent.change(screen.getByLabelText('用户 ID / 陶泥号'), { + target: { value: 'SY-00000055' }, + }); + await waitFor(() => expect(listAdminEditorAssets).toHaveBeenCalledTimes(3)); + + await act(async () => { + staleResponse.resolve({ + entries: [ + { ...generatedAsset, assetId: 'asset-stale', label: '旧筛选素材' }, + ], + nextCursor: 'cursor-stale', + }); + await staleResponse.promise; + }); + + expect(screen.queryByText('旧筛选素材')).toBeNull(); + expect(screen.getByRole('button', { name: '刷新中' })).toBeTruthy(); + + await act(async () => { + currentResponse.resolve({ + entries: [ + { ...generatedAsset, assetId: 'asset-current', label: '当前筛选素材' }, + ], + nextCursor: null, + }); + await currentResponse.promise; + }); + + expect(await screen.findByText('当前筛选素材')).toBeTruthy(); + expect(screen.queryByText('旧筛选素材')).toBeNull(); + expect(screen.getByRole('button', { name: '刷新' })).toBeTruthy(); +}); + +test('后台素材查询筛选失败时隐藏旧结果且不展示空态', async () => { + vi.mocked(listAdminEditorAssets) + .mockResolvedValueOnce({ + entries: [generatedAssetAt(1)], + nextCursor: 'cursor-old', + }) + .mockRejectedValueOnce(new Error('筛选失败')); + + render( + , + ); + + expect(await screen.findByText('角色形象 1')).toBeTruthy(); + expect(screen.getByRole('button', { name: '读取更多' })).toBeTruthy(); + + fireEvent.change(screen.getByLabelText('用户 ID / 陶泥号'), { + target: { value: 'SY-00000055' }, + }); + + expect(screen.queryByText('角色形象 1')).toBeNull(); + expect(screen.queryByRole('button', { name: '读取更多' })).toBeNull(); + expect(await screen.findByText('筛选失败')).toBeTruthy(); + expect(screen.queryByText('角色形象 1')).toBeNull(); + expect(screen.queryByRole('button', { name: '读取更多' })).toBeNull(); + expect(screen.queryByText('暂无生成素材')).toBeNull(); +}); + +test('后台素材查询取消旧筛选请求时不展示错误', async () => { + installIntersectionObserverMock(); + let staleSignal: AbortSignal | undefined; + vi.mocked(listAdminEditorAssets) + .mockResolvedValueOnce({ + entries: [generatedAsset], + nextCursor: null, + }) + .mockImplementationOnce((_token, _query, signal) => { + staleSignal = signal; + return new Promise((_resolve, reject) => { + signal?.addEventListener( + 'abort', + () => reject(new DOMException('请求已取消', 'AbortError')), + { once: true }, + ); + }); + }) + .mockResolvedValueOnce({ + entries: [ + { ...generatedAsset, assetId: 'asset-current', label: '最新筛选素材' }, + ], + nextCursor: null, + }); + + render( + , + ); + + await screen.findByText('角色形象 1'); + fireEvent.change(screen.getByLabelText('搜索'), { + target: { value: '旧筛选' }, + }); + await waitFor(() => expect(listAdminEditorAssets).toHaveBeenCalledTimes(2)); + expect(staleSignal?.aborted).toBe(false); + vi.useFakeTimers(); + + fireEvent.change(screen.getByLabelText('搜索'), { + target: { value: '最新筛选' }, + }); + expect(staleSignal?.aborted).toBe(true); + await act(async () => { + await Promise.resolve(); + }); + expect(screen.queryByRole('status')).toBeNull(); + expect(listAdminEditorAssets).toHaveBeenCalledTimes(2); + + await act(async () => { + await vi.advanceTimersByTimeAsync(299); + }); + expect(listAdminEditorAssets).toHaveBeenCalledTimes(2); + expect(screen.queryByRole('status')).toBeNull(); + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + expect(listAdminEditorAssets).toHaveBeenCalledTimes(3); + expect(screen.getByText('最新筛选素材')).toBeTruthy(); + expect(screen.queryByRole('status')).toBeNull(); +}); + +test('后台素材查询手动刷新失败时保留当前结果和游标', async () => { + const refreshResponse = createDeferred(); + vi.mocked(listAdminEditorAssets) + .mockResolvedValueOnce({ + entries: [generatedAssetAt(1)], + nextCursor: 'cursor-1', + }) + .mockImplementationOnce(() => refreshResponse.promise); + + render( + , + ); + + expect(await screen.findByText('角色形象 1')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: '刷新' })); + expect(screen.getByText('角色形象 1')).toBeTruthy(); + expect( + screen.getByRole('button', { name: '读取更多' }).hasAttribute('disabled'), + ).toBe(true); + + await act(async () => { + refreshResponse.reject(new Error('刷新失败')); + try { + await refreshResponse.promise; + } catch { + // The page owns the rejected request. + } + }); + + expect(await screen.findByText('刷新失败')).toBeTruthy(); + expect(screen.getByText('角色形象 1')).toBeTruthy(); + expect( + screen.getByRole('button', { name: '读取更多' }).hasAttribute('disabled'), + ).toBe(false); + expect(screen.queryByText('暂无生成素材')).toBeNull(); +}); + +test('后台素材查询读取更多进行中手动刷新失败时保留原页', async () => { + const staleLoadMoreResponse = createDeferred(); + const refreshResponse = createDeferred(); + let staleLoadMoreSignal: AbortSignal | undefined; + vi.mocked(listAdminEditorAssets) + .mockResolvedValueOnce({ + entries: [generatedAssetAt(1)], + nextCursor: 'cursor-1', + }) + .mockImplementationOnce((_token, _query, signal) => { + staleLoadMoreSignal = signal; + return staleLoadMoreResponse.promise; + }) + .mockImplementationOnce(() => refreshResponse.promise) + .mockResolvedValueOnce({ + entries: [generatedAssetAt(81)], + nextCursor: null, + }); + + render( + , + ); + + fireEvent.click(await screen.findByRole('button', { name: '读取更多' })); + await waitFor(() => expect(listAdminEditorAssets).toHaveBeenCalledTimes(2)); + expect(staleLoadMoreSignal?.aborted).toBe(false); + + fireEvent.click(screen.getByRole('button', { name: '刷新' })); + expect(staleLoadMoreSignal?.aborted).toBe(true); + expect(listAdminEditorAssets).toHaveBeenCalledTimes(3); + expect(screen.getByText('角色形象 1')).toBeTruthy(); + expect( + screen.getByRole('button', { name: '读取更多' }).hasAttribute('disabled'), + ).toBe(true); + + await act(async () => { + refreshResponse.reject(new Error('刷新失败')); + try { + await refreshResponse.promise; + } catch { + // The page owns the rejected request. + } + }); + + expect(await screen.findByText('刷新失败')).toBeTruthy(); + expect(screen.getByText('角色形象 1')).toBeTruthy(); + expect( + screen.getByRole('button', { name: '读取更多' }).hasAttribute('disabled'), + ).toBe(false); + + await act(async () => { + staleLoadMoreResponse.resolve({ + entries: [ + { ...generatedAsset, assetId: 'asset-stale', label: '旧分页素材' }, + ], + nextCursor: 'cursor-stale', + }); + await staleLoadMoreResponse.promise; + }); + expect(screen.queryByText('旧分页素材')).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: '读取更多' })); + expect(await screen.findByText('角色形象 81')).toBeTruthy(); + expect(listAdminEditorAssets).toHaveBeenLastCalledWith( + 'admin-token', + { + ownerUserId: null, + keyword: null, + createdAfter: null, + createdBefore: null, + limit: 80, + cursor: 'cursor-1', + }, + expect.anything(), + ); +}); + test('后台素材查询不展示分类筛选和分类列', async () => { render( , @@ -493,13 +822,87 @@ test('后台素材查询读取更多后为新进入可视区域的素材换签', objectKey: 'generated-character-drafts/editor/spec-81.png', expireSeconds: 300, }); - expect(listAdminEditorAssets).toHaveBeenLastCalledWith('admin-token', { - ownerUserId: null, - keyword: null, - createdAfter: null, - createdBefore: null, - limit: 80, - cursor: 'cursor-1', + expect(listAdminEditorAssets).toHaveBeenLastCalledWith( + 'admin-token', + { + ownerUserId: null, + keyword: null, + createdAfter: null, + createdBefore: null, + limit: 80, + cursor: 'cursor-1', + }, + expect.anything(), + ); +}); + +test('后台素材查询筛选刷新后忽略旧读取更多响应和游标', async () => { + const staleLoadMoreResponse = createDeferred(); + const refreshedResponse = createDeferred(); + let staleLoadMoreSignal: AbortSignal | undefined; + vi.mocked(listAdminEditorAssets) + .mockResolvedValueOnce({ + entries: [generatedAssetAt(1)], + nextCursor: 'cursor-old', + }) + .mockImplementationOnce((_token, _query, signal) => { + staleLoadMoreSignal = signal; + return staleLoadMoreResponse.promise; + }) + .mockImplementationOnce(() => refreshedResponse.promise) + .mockResolvedValueOnce({ entries: [], nextCursor: null }); + + render( + , + ); + + fireEvent.click(await screen.findByRole('button', { name: '读取更多' })); + await waitFor(() => expect(listAdminEditorAssets).toHaveBeenCalledTimes(2)); + fireEvent.change(screen.getByLabelText('用户 ID / 陶泥号'), { + target: { value: 'SY-00000055' }, + }); + expect(staleLoadMoreSignal?.aborted).toBe(true); + await waitFor(() => expect(listAdminEditorAssets).toHaveBeenCalledTimes(3)); + await waitFor(() => + expect(screen.queryByRole('button', { name: '读取更多' })).toBeNull(), + ); + + await act(async () => { + refreshedResponse.resolve({ + entries: [ + { ...generatedAsset, assetId: 'asset-current', label: '当前用户素材' }, + ], + nextCursor: 'cursor-current', + }); + await refreshedResponse.promise; + }); + expect(await screen.findByText('当前用户素材')).toBeTruthy(); + + await act(async () => { + staleLoadMoreResponse.resolve({ + entries: [ + { ...generatedAsset, assetId: 'asset-stale', label: '旧分页素材' }, + ], + nextCursor: 'cursor-stale', + }); + await staleLoadMoreResponse.promise; + }); + + expect(screen.queryByText('旧分页素材')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '读取更多' })); + await waitFor(() => { + expect(listAdminEditorAssets).toHaveBeenLastCalledWith( + 'admin-token', + { + ownerUserId: 'SY-00000055', + keyword: null, + createdAfter: null, + createdBefore: null, + limit: 80, + cursor: 'cursor-current', + }, + expect.anything(), + ); }); }); @@ -962,6 +1365,28 @@ test('后台素材查询可查看素材详情', async () => { expect(within(dialog).getByText(/"style": "clay"/u)).toBeTruthy(); }); +test('后台素材详情分别展示真实操作和归组 Task ID', async () => { + vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({ + entries: [ + { + ...generatedAsset, + taskId: 'editor-atlas-split-operation-1', + groupTaskId: 'vector-engine-source-task-1', + }, + ], + nextCursor: null, + }); + render( + , + ); + + fireEvent.click(await screen.findByRole('button', { name: '详情' })); + + const dialog = screen.getByRole('dialog', { name: '素材详情' }); + expect(within(dialog).getByText('editor-atlas-split-operation-1')).toBeTruthy(); + expect(within(dialog).getByText('vector-engine-source-task-1')).toBeTruthy(); +}); + test('后台素材查询可打开弹窗查看完整提示词', async () => { render( , diff --git a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx index e23a00aae..ee2337e62 100644 --- a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx +++ b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx @@ -1,6 +1,6 @@ import { ChevronDown, ChevronRight, Eye, FileText, RefreshCcw, X } from 'lucide-react'; import { Fragment, type ReactNode } from 'react'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { getAdminAssetReadUrl, @@ -24,6 +24,7 @@ const ADMIN_ASSET_READ_EXPIRE_SECONDS = 300; const ADMIN_ASSET_READ_DISPATCH_SPACING_MS = 40; const ADMIN_ASSET_READ_RETRY_DELAYS_MS = [400, 1_200, 3_000] as const; const ADMIN_ASSET_THUMBNAIL_ROOT_MARGIN = '240px 0px'; +const ADMIN_EDITOR_ASSET_FILTER_DEBOUNCE_MS = 300; const AUDIO_ASSET_COVER_SRC = `${import.meta.env.DEV ? import.meta.env.BASE_URL : '/'}creation-home/audio-asset-cover.png`; let adminAssetReadDispatchTail = Promise.resolve(); @@ -37,6 +38,9 @@ export function AdminEditorAssetQueryPage({ const [createdAfter, setCreatedAfter] = useState(''); const [createdBefore, setCreatedBefore] = useState(''); const [nextCursor, setNextCursor] = useState(null); + const [committedQueryKey, setCommittedQueryKey] = useState( + null, + ); const [isLoading, setIsLoading] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false); const [errorMessage, setErrorMessage] = useState(''); @@ -51,49 +55,147 @@ export function AdminEditorAssetQueryPage({ title: string; prompt: string; } | null>(null); + const listRequestGenerationRef = useRef(0); + const listRequestAbortControllerRef = useRef(null); + const automaticQueryInitializedRef = useRef(false); + const automaticQueryTokenRef = useRef(token); + const activeQuery = buildListQuery(); + const activeQueryKey = editorAssetListQueryKey(token, activeQuery); + const hasCurrentQuerySnapshot = committedQueryKey === activeQueryKey; + const visibleEntries = hasCurrentQuerySnapshot ? entries : []; + const visibleNextCursor = hasCurrentQuerySnapshot ? nextCursor : null; useEffect(() => { - void refreshPage(); + const runImmediately = + !automaticQueryInitializedRef.current || + automaticQueryTokenRef.current !== token; + automaticQueryInitializedRef.current = true; + automaticQueryTokenRef.current = token; + + if (runImmediately) { + void refreshPage(); + return; + } + + invalidateListRequest(); + setIsLoading(true); + setIsLoadingMore(false); + setErrorMessage(''); + const timer = setTimeout( + () => void refreshPage(), + ADMIN_EDITOR_ASSET_FILTER_DEBOUNCE_MS, + ); + return () => clearTimeout(timer); // eslint-disable-next-line react-hooks/exhaustive-deps }, [token, ownerUserId, keyword, createdAfter, createdBefore]); + useEffect( + () => () => { + automaticQueryInitializedRef.current = false; + invalidateListRequest(); + }, + [], + ); + async function refreshPage() { + const query = buildListQuery(); + const queryKey = editorAssetListQueryKey(token, query); + const { requestGeneration, abortController } = beginListRequest(); setIsLoading(true); + setIsLoadingMore(false); setErrorMessage(''); try { - const response = await listAdminEditorAssets(token, buildListQuery()); + const response = await listAdminEditorAssets( + token, + query, + abortController.signal, + ); + if (listRequestGenerationRef.current !== requestGeneration) { + return; + } setEntries(response.entries); setNextCursor(response.nextCursor ?? null); + setCommittedQueryKey(queryKey); } catch (error: unknown) { + if ( + listRequestGenerationRef.current !== requestGeneration || + isAbortError(error) + ) { + return; + } handlePageError(error, onUnauthorized, setErrorMessage); } finally { - setIsLoading(false); + if (listRequestGenerationRef.current === requestGeneration) { + if (listRequestAbortControllerRef.current === abortController) { + listRequestAbortControllerRef.current = null; + } + setIsLoading(false); + } } } async function loadMore() { - if (!nextCursor || isLoadingMore) { + if (!visibleNextCursor || isLoading || isLoadingMore) { return; } + const query = buildListQuery(); + const queryKey = editorAssetListQueryKey(token, query); + if (committedQueryKey !== queryKey) { + return; + } + const cursor = visibleNextCursor; + const { requestGeneration, abortController } = beginListRequest(); setIsLoadingMore(true); setErrorMessage(''); try { - const response = await listAdminEditorAssets(token, { - ...buildListQuery(), - cursor: nextCursor, - }); + const response = await listAdminEditorAssets( + token, + { + ...query, + cursor, + }, + abortController.signal, + ); + if (listRequestGenerationRef.current !== requestGeneration) { + return; + } setEntries((current) => mergeAssetEntries(current, response.entries)); setNextCursor(response.nextCursor ?? null); } catch (error: unknown) { + if ( + listRequestGenerationRef.current !== requestGeneration || + isAbortError(error) + ) { + return; + } handlePageError(error, onUnauthorized, setErrorMessage); } finally { - setIsLoadingMore(false); + if (listRequestGenerationRef.current === requestGeneration) { + if (listRequestAbortControllerRef.current === abortController) { + listRequestAbortControllerRef.current = null; + } + setIsLoadingMore(false); + } } } + function beginListRequest() { + invalidateListRequest(); + const abortController = new AbortController(); + listRequestAbortControllerRef.current = abortController; + const requestGeneration = listRequestGenerationRef.current; + return { requestGeneration, abortController }; + } + + function invalidateListRequest() { + listRequestGenerationRef.current += 1; + listRequestAbortControllerRef.current?.abort(); + listRequestAbortControllerRef.current = null; + } + function buildListQuery(): AdminEditorAssetListQuery { return { - ownerUserId: ownerUserId || null, + ownerUserId: ownerUserId.trim() || null, keyword: keyword.trim() || null, createdAfter: dateInputToStartRfc3339(createdAfter), createdBefore: dateInputToEndRfc3339(createdBefore), @@ -143,7 +245,7 @@ export function AdminEditorAssetQueryPage({ />