diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 482f616d..d5a10fa2 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,15 @@ --- +## 2026-05-16 公开作品列表短期由 BFF 订阅读模型缓存 + +- 背景:作品列表压测和实时性讨论中,曾考虑让浏览器前端直接订阅公开作品列表,减少 HTTP 拉取和 BFF 压力。 +- 决策:本轮不直接把作品列表整体交给前端订阅。短期继续由 `api-server` / BFF 通过 `spacetime-client` 长期订阅 SpacetimeDB 公开 read model 并读取本地 cache,维持首屏、排序、字段归一、权限降级和 HTTP fallback。中期可以新增或统一稳定的专用公开作品列表 read model,例如 `public_work_gallery_entry`,作为前端可选直连订阅对象。 +- 边界:前端不得直接订阅 `puzzle_work_profile`、`custom_world_profile` 等领域源表,也不得在前端自行 join、聚合或执行公开权限逻辑;这些逻辑必须先沉到后端投影 / read model。 +- 影响范围:发现页、推荐流、各玩法公开广场、`api-server` 公开列表缓存、SpacetimeDB public view / public 读模型设计。 +- 验证方式:新增公开作品列表订阅能力时,检查前端只消费专用 public read model 或 BFF HTTP DTO;检查源表 row shape、权限判断和跨玩法聚合没有下沉到前端页面。 +- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + ## 2026-05-16 api-server OpenTelemetry 统一补齐 traces metrics logs - 背景:压测与运行观测需要把 HTTP、SpacetimeDB 调用和应用日志串起来,同时保留本地 `journalctl` / 文件日志做故障排障。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 5ff37b30..ad65ed19 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -99,6 +99,14 @@ - 验证:搜索 `server-rs/crates/spacetime-client/src/puzzle.rs` 不应再出现 gallery 主路径调用 `list_puzzle_gallery_then`;执行 `cargo check --manifest-path server-rs/Cargo.toml -p spacetime-client`、`cargo check --manifest-path server-rs/Cargo.toml -p api-server` 和 schema/runtime access 检查。 - 关联:`server-rs/crates/spacetime-module/src/puzzle.rs`、`server-rs/crates/spacetime-client/src/lib.rs`、`server-rs/crates/spacetime-client/src/puzzle.rs`、`/api/runtime/puzzle/gallery`。 +## 自定义世界广场和创作入口配置不要每次 HTTP 请求调用只读 procedure + +- 现象:`/api/runtime/custom-world-gallery` 每次请求调用 `list_custom_world_gallery_entries` procedure;入口熔断中间件每个玩法请求调用 `get_creation_entry_config` procedure,50RPS 以上会把 SpacetimeDB procedure 调用变成热点。 +- 原因:`custom_world_gallery_entry`、`creation_entry_config` 和 `creation_entry_type_config` 已经是可订阅读模型或配置表,但 HTTP 路径仍按“请求到来再查 procedure”处理。 +- 处理:`spacetime-client` 长连接订阅 `custom_world_gallery_entry`、`public_work_play_daily_stat` 的 `custom-world` 桶、`creation_entry_config` 和 `creation_entry_type_config`;custom-world gallery 从本地 cache 排序并聚合 7 日播放数;入口配置优先读订阅 cache,cache 缺失时用最近一次成功内存快照,再兜底调用 `get_creation_entry_config` 完成旧库兼容。旧 `list_custom_world_gallery_entries` procedure 只允许作为旧库缺少 gallery 行时的一次性同步兜底。 +- 验证:搜索 `server-rs/crates/spacetime-client/src/custom_world.rs`,gallery 主路径应是 `read_after_connect` 读取 `custom_world_gallery_entry()`;搜索 `server-rs/crates/spacetime-client/src/runtime.rs`,`get_creation_entry_config` 应优先读取 `creation_entry_config()` 和 `creation_entry_type_config()`。执行 `cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`。 +- 关联:`server-rs/crates/spacetime-client/src/lib.rs`、`server-rs/crates/spacetime-client/src/custom_world.rs`、`server-rs/crates/spacetime-client/src/runtime.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + ## 陶泥儿 logo 生图慢请求先缩短 prompt 并单张串行 - 现象:使用 VectorEngine `gpt-image-2-all` 生成陶泥儿 logo 概念图时,部分 prompt 会超过 10 分钟仍无响应,或返回 `429` / `当前分组上游负载已饱和`;同一批次里后续图片会被前面的慢请求拖住。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 084b3364..85388efa 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -89,7 +89,7 @@ npm run check:server-rs-ddd 3. 删除字段、改名、重排字段、改类型或修改字段属性前,必须先询问用户并确认迁移计划。 4. Vec 字段不要直接写无法 const 求值的 default;需要默认空集合时优先使用 `Option>` 加 `#[default(None::>)]`,业务层归一为空数组。 5. 运行态读表必须按已声明索引访问。只要 table 上存在覆盖查询前缀的 `#[index(...)]` 或主键 / unique accessor,列表、详情、快照组装和计数都先用对应 accessor `.filter(...)` / `.find(...)`,再在内存中处理索引无法覆盖的残余条件;不得用 `.iter().filter(...)` 扫整表替代现成索引。 -6. 面向公开列表的只读投影优先做成 public view,并由 `api-server` 的 `spacetime-client` 长期订阅后读本地 cache。不要让 HTTP 列表接口每次请求都调用 procedure 重新组装全量列表;需要请求时间窗口的轻量统计可订阅公开统计表后在 `api-server` 本地聚合,需要写入副作用的详情、点赞、游玩记录仍可走 procedure / reducer。 +6. 面向公开列表的只读投影优先做成 public view / public 读模型表,并由 `api-server` 的 `spacetime-client` 长期订阅后读本地 cache。短期不把作品列表整体交给浏览器前端直接订阅;不要让 HTTP 列表接口每次请求都调用 procedure 重新组装全量列表。需要请求时间窗口的轻量统计可订阅公开统计表后在 `api-server` 本地聚合,需要写入副作用的详情、点赞、游玩记录仍可走 procedure / reducer。中期如要让前端可选直连订阅,只能新增或统一稳定的专用 public read model,例如 `public_work_gallery_entry`,并保持字段、排序键、公开权限和降级语义由后端投影定义;前端不得直接订阅 `puzzle_work_profile`、`custom_world_profile` 等领域源表,也不得自己做 join、聚合或权限逻辑。首屏、排序、字段归一、权限降级和 HTTP fallback 仍由 `api-server` BFF 维持。 7. 多列索引按 SpacetimeDB 绑定生成的元组参数直接传入,例如 `.filter((source_type, profile_id, played_day))`;前缀查询只传前缀元组,例如 `.filter((scope_kind, scope_id.as_str()))`。不要为了绕过类型问题退回整表遍历。 8. procedure result 必须返回 typed snapshot / typed value。`spacetime-client` mapper 不得再通过 `row_json/session_json/work_json/items_json/run_json/event_json/feedback_json: Option` 做跨层 JSON 字符串传输,也不得在 mapper 里反序列化旧 `*JsonRecord` 兼容结构。业务内部持久化字段如 `profile_payload_json`、`levels_json` 等不属于 procedure result 载荷例外,仍按各自表契约处理。 9. 修改后运行: @@ -293,6 +293,7 @@ npm run check:server-rs-ddd - Rust 结构体:`CustomWorldGalleryEntry` - 源码:`server-rs/crates/spacetime-module/src/custom_world/mod.rs` +- 作用:自定义世界公开作品列表读模型。`api-server` 的 `spacetime-client` 长期订阅 `SELECT * FROM custom_world_gallery_entry` 与 `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'custom-world'`,`/api/runtime/custom-world-gallery` 从本地 cache 排序并聚合 `recentPlayCount7d`,不再每个 HTTP 请求调用 `list_custom_world_gallery_entries` procedure。旧 procedure 只用于兼容旧库缺少 gallery 读模型行时的一次性同步兜底。 ### `custom_world_profile` @@ -465,13 +466,31 @@ npm run check:server-rs-ddd - Rust 结构体:`PuzzleWorkProfileRow` - 源码:`server-rs/crates/spacetime-module/src/puzzle.rs` -### `puzzle_gallery_view` +### SpacetimeDB view:`puzzle_gallery_view` - Rust view:`puzzle_gallery_view` - 返回类型:`Vec` - 源码:`server-rs/crates/spacetime-module/src/puzzle.rs` - 说明:拼图广场公开列表投影,只暴露 `publication_status = Published` 的作品;`api-server` 的 `spacetime-client` 长期订阅 `SELECT * FROM puzzle_gallery_view` 与 `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'puzzle'` 后,从本地 cache 构造 `/api/runtime/puzzle/gallery` 响应,并在本地按当前请求时间聚合 `recentPlayCount7d`,不再每个 HTTP 请求调用 `list_puzzle_gallery` procedure。 +### api-server 长期订阅读模型 + +`spacetime-client` 建立每个池连接时会等待下列订阅初始同步: + +- `SELECT * FROM puzzle_gallery_view` +- `SELECT * FROM custom_world_gallery_entry` + +下列订阅用于统计或配置缓存,订阅失败不会让公开列表连接整体不可用,调用方保留兼容兜底: + +- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'puzzle'` +- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'custom-world'` +- `SELECT * FROM creation_entry_config` +- `SELECT * FROM creation_entry_type_config` + +`GET /api/creation-entry/config` 和入口熔断优先从订阅 cache 读取创作入口配置;cache 缺失时使用最近一次成功读取的内存快照,再兜底调用 `get_creation_entry_config` procedure 完成空库种子或旧库兼容。 + +未来可选:若发现页、推荐流和各玩法广场需要统一给浏览器前端直接订阅公开作品列表,只新增 / 统一专用 public read model,例如 `public_work_gallery_entry`。该 read model 必须是后端投影后的公开作品卡片契约,覆盖作品类型、公开作品号、标题、摘要、封面、作者展示名、排序键、公开统计和入口开关后的可见性,不暴露玩法领域源表 row shape。前端可选择订阅这个稳定投影来减少 HTTP 拉取,但不能订阅 `puzzle_work_profile`、`custom_world_profile` 等源表后自行拼装列表;BFF 仍保留首屏、SEO / 分享、旧客户端、订阅失败和灰度期间的 HTTP fallback。 + ### `quest_log` - Rust 结构体:`QuestLog` diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 1709ffc4..ad890d24 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -157,6 +157,7 @@ Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分 - `genarrative-api.service` 设置 `LimitNOFILE=65535`、`TasksMax=2048`;上线后用 `systemctl show genarrative-api.service -p LimitNOFILE -p TasksMax` 和 `cat /proc/$(pidof api-server)/limits` 核对。 - Nginx `/api/` 与 `/admin/api/` 通过 `genarrative_api` upstream 代理到 `127.0.0.1:8082`,upstream keepalive 为 64;压测时看 `/var/log/nginx/genarrative.access.log` 中的 `request_time`、`upstream_connect_time`、`upstream_header_time`、`upstream_response_time`、`upstream_status`、`request_id`。 - 作品列表 K6 脚本一次 iteration 默认请求两个公开接口,因此约 50 HTTP req/s 的目标命令使用 `SCENARIO=spike START_RPS=5 PEAK_RPS=25 HOLD=60s END_RPS=5 DETAIL_RATIO=0 npm run loadtest:k6:works`。 +- 作品列表短期继续由 `api-server` / BFF 订阅 SpacetimeDB 公开 read model 后读本地 cache,不让浏览器前端直接订阅完整列表;未来如新增 `public_work_gallery_entry` 等专用公开作品列表 read model,前端只可订阅该稳定投影,不能订阅玩法源表后自行 join、聚合或判断权限。 - 50 HTTP req/s 验收目标为 `http_req_failed < 1%`、`p95 < 2s`、`dropped_iterations = 0`,同时压测窗口内 Nginx 无新增 502。 OpenTelemetry 现阶段可选 OTLP traces / metrics / logs,但本地日志与 Nginx 文件日志仍保留: diff --git a/server-rs/crates/spacetime-client/src/custom_world.rs b/server-rs/crates/spacetime-client/src/custom_world.rs index 62b7be19..d201c616 100644 --- a/server-rs/crates/spacetime-client/src/custom_world.rs +++ b/server-rs/crates/spacetime-client/src/custom_world.rs @@ -181,6 +181,55 @@ impl SpacetimeClient { pub async fn list_custom_world_gallery_entries( &self, ) -> Result, SpacetimeClientError> { + let records = self.read_custom_world_gallery_entries_from_cache().await?; + if !records.is_empty() + || self + .custom_world_gallery_legacy_sync_attempted + .swap(true, std::sync::atomic::Ordering::SeqCst) + { + return Ok(records); + } + + let _ = self + .sync_custom_world_gallery_entries_via_legacy_procedure() + .await; + self.read_custom_world_gallery_entries_from_cache().await + } + + async fn read_custom_world_gallery_entries_from_cache( + &self, + ) -> Result, SpacetimeClientError> { + self.read_after_connect(move |connection| { + let recent_play_counts = public_work_recent_play_counts(connection, "custom-world"); + let mut entries = connection + .db() + .custom_world_gallery_entry() + .iter() + .collect::>(); + entries.sort_by(|left, right| { + right + .published_at + .cmp(&left.published_at) + .then(right.updated_at.cmp(&left.updated_at)) + }); + + Ok(entries + .into_iter() + .map(|entry| { + let recent_play_count_7d = recent_play_counts + .get(&entry.profile_id) + .copied() + .unwrap_or(0); + map_custom_world_gallery_entry_row(entry, recent_play_count_7d) + }) + .collect()) + }) + .await + } + + async fn sync_custom_world_gallery_entries_via_legacy_procedure( + &self, + ) -> Result<(), SpacetimeClientError> { self.call_after_connect( "list_custom_world_gallery_entries", move |connection, sender| { @@ -188,8 +237,8 @@ impl SpacetimeClient { .procedures() .list_custom_world_gallery_entries_then(move |_, result| { let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_custom_world_gallery_list_result); + .map(|_| ()) + .map_err(SpacetimeClientError::from_sdk_error); send_once(&sender, mapped); }); }, diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index cd09a684..4401f25f 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -96,6 +96,7 @@ pub mod story_runtime; pub mod visual_novel; use std::{ + collections::HashMap, error::Error, fmt, sync::atomic::{AtomicBool, Ordering}, @@ -225,7 +226,7 @@ use module_story::{ use shared_kernel::format_timestamp_micros; use spacetimedb_sdk::{DbContext, Table}; use tokio::{ - sync::{OwnedSemaphorePermit, Semaphore, oneshot}, + sync::{OwnedSemaphorePermit, RwLock, Semaphore, oneshot}, time::timeout, }; @@ -257,6 +258,8 @@ pub struct AuthStoreSnapshotImportRecord { pub struct SpacetimeClient { config: SpacetimeClientConfig, pool: Arc, + creation_entry_config_cache: Arc>>, + custom_world_gallery_legacy_sync_attempted: Arc, } #[derive(Debug)] @@ -269,6 +272,8 @@ pub enum SpacetimeClientError { } const DEFAULT_PROCEDURE_TIMEOUT: Duration = Duration::from_secs(30); +const PUBLIC_WORK_PLAY_DAY_MICROS: i64 = 86_400_000_000; +const PUBLIC_WORK_RECENT_PLAY_WINDOW_DAYS: i64 = 7; type ProcedureResultSender = Arc>>>>; @@ -286,7 +291,7 @@ struct PooledConnectionSlot { struct PooledConnection { connection: DbConnection, - _gallery_subscription: Vec, + _read_model_subscriptions: Vec, runner: Option>, broken: Arc, } @@ -321,7 +326,12 @@ impl SpacetimeClient { permits: Arc::new(Semaphore::new(pool_size)), }); - Self { config, pool } + Self { + config, + pool, + creation_entry_config_cache: Arc::new(RwLock::new(None)), + custom_world_gallery_legacy_sync_attempted: Arc::new(AtomicBool::new(false)), + } } async fn call_after_connect( @@ -415,6 +425,14 @@ impl SpacetimeClient { final_result } + async fn cache_creation_entry_config(&self, config: CreationEntryConfigRecord) { + *self.creation_entry_config_cache.write().await = Some(config); + } + + async fn read_cached_creation_entry_config(&self) -> Option { + self.creation_entry_config_cache.read().await.clone() + } + async fn acquire_connection(&self) -> Result { let permit = timeout( self.config.procedure_timeout, @@ -503,19 +521,19 @@ impl SpacetimeClient { .map_err(|_| SpacetimeClientError::Timeout)? .map_err(|_| SpacetimeClientError::ConnectDropped)??; - let gallery_subscription = self - .subscribe_puzzle_gallery_views(&connection, broken.clone()) + let read_model_subscriptions = self + .subscribe_cached_read_models(&connection, broken.clone()) .await?; Ok(PooledConnection { connection, - _gallery_subscription: gallery_subscription, + _read_model_subscriptions: read_model_subscriptions, runner: Some(runner), broken, }) } - async fn subscribe_puzzle_gallery_views( + async fn subscribe_cached_read_models( &self, connection: &DbConnection, broken: Arc, @@ -523,37 +541,67 @@ impl SpacetimeClient { let mut subscriptions = Vec::new(); for query in [ "SELECT * FROM puzzle_gallery_view", + "SELECT * FROM custom_world_gallery_entry", ] { - let (sender, receiver) = oneshot::channel::>(); - let applied_sender = Arc::new(Mutex::new(Some(sender))); - let on_applied_sender = applied_sender.clone(); - let on_error_sender = applied_sender.clone(); - let broken_flag = broken.clone(); - let subscription = connection - .subscription_builder() - .on_applied(move |_| { - send_connect_once(&on_applied_sender, Ok(())); - }) - .on_error(move |_, error| { - broken_flag.store(true, Ordering::SeqCst); - send_connect_once( - &on_error_sender, - Err(SpacetimeClientError::Procedure(error.to_string())), - ); - }) - .subscribe(query); - - timeout(self.config.procedure_timeout, receiver) - .await - .map_err(|_| SpacetimeClientError::Timeout)? - .map_err(|_| SpacetimeClientError::ConnectDropped)??; - + let subscription = self + .subscribe_cached_read_model_query(connection, broken.clone(), query, true) + .await?; subscriptions.push(subscription); } + for query in [ + "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'puzzle'", + "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'custom-world'", + "SELECT * FROM creation_entry_config", + "SELECT * FROM creation_entry_type_config", + ] { + if let Ok(subscription) = self + .subscribe_cached_read_model_query(connection, broken.clone(), query, false) + .await + { + subscriptions.push(subscription); + } + } + Ok(subscriptions) } + async fn subscribe_cached_read_model_query( + &self, + connection: &DbConnection, + broken: Arc, + query: &'static str, + mark_broken_on_error: bool, + ) -> Result { + let (sender, receiver) = oneshot::channel::>(); + let applied_sender = Arc::new(Mutex::new(Some(sender))); + let on_applied_sender = applied_sender.clone(); + let on_error_sender = applied_sender.clone(); + let broken_flag = broken.clone(); + let subscription = connection + .subscription_builder() + .on_applied(move |_| { + send_connect_once(&on_applied_sender, Ok(())); + }) + .on_error(move |_, error| { + if mark_broken_on_error { + broken_flag.store(true, Ordering::SeqCst); + } + send_connect_once( + &on_error_sender, + Err(SpacetimeClientError::Procedure(error.to_string())), + ); + }) + .subscribe(query); + + timeout(self.config.procedure_timeout, receiver) + .await + .map_err(|_| SpacetimeClientError::Timeout)? + .map_err(|_| SpacetimeClientError::ConnectDropped)??; + + Ok(subscription) + } + async fn release_connection(&self, mut lease: PooledConnectionLease) { let mut slot_guard = self.pool.slots[lease.slot_index].lock().await; slot_guard.in_use = false; @@ -581,6 +629,39 @@ impl SpacetimeClient { } } +fn current_unix_micros() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_micros() as i64) + .unwrap_or(0) +} + +fn current_public_work_day() -> i64 { + current_unix_micros().div_euclid(PUBLIC_WORK_PLAY_DAY_MICROS) +} + +fn public_work_recent_play_counts( + connection: &DbConnection, + source_type: &str, +) -> HashMap { + let current_day = current_public_work_day(); + let first_day = current_day - (PUBLIC_WORK_RECENT_PLAY_WINDOW_DAYS - 1); + let mut counts = HashMap::new(); + + for row in connection.db().public_work_play_daily_stat().iter() { + if row.source_type != source_type + || row.played_day < first_day + || row.played_day > current_day + { + continue; + } + let entry: &mut u32 = counts.entry(row.profile_id).or_insert(0); + *entry = (*entry).saturating_add(row.play_count); + } + + counts +} + impl SpacetimeClientError { pub(crate) fn from_sdk_error(error: impl fmt::Display) -> Self { Self::Procedure(error.to_string()) diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index cef4511b..fe7fa2c8 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -830,6 +830,48 @@ pub(crate) fn map_creation_entry_config_procedure_result( )) } +pub(crate) fn build_creation_entry_config_record_from_rows( + header: CreationEntryConfig, + mut creation_types: Vec, +) -> CreationEntryConfigRecord { + creation_types.sort_by(|left, right| { + left.sort_order + .cmp(&right.sort_order) + .then_with(|| left.id.cmp(&right.id)) + }); + + module_runtime::build_creation_entry_config_response( + module_runtime::CreationEntryConfigSnapshot { + config_id: header.config_id, + start_card: module_runtime::CreationEntryStartCardSnapshot { + title: header.start_title, + description: header.start_description, + idle_badge: header.start_idle_badge, + busy_badge: header.start_busy_badge, + }, + type_modal: module_runtime::CreationEntryTypeModalSnapshot { + title: header.modal_title, + description: header.modal_description, + }, + creation_types: creation_types + .into_iter() + .map(|item| module_runtime::CreationEntryTypeSnapshot { + id: item.id, + title: item.title, + subtitle: item.subtitle, + badge: item.badge, + image_src: item.image_src, + visible: item.visible, + open: item.open, + sort_order: item.sort_order, + updated_at_micros: item.updated_at.to_micros_since_unix_epoch(), + }) + .collect(), + updated_at_micros: header.updated_at.to_micros_since_unix_epoch(), + }, + ) +} + fn map_creation_entry_config_snapshot( snapshot: CreationEntryConfigSnapshot, ) -> module_runtime::CreationEntryConfigSnapshot { @@ -1437,20 +1479,6 @@ pub(crate) fn map_custom_world_library_detail_result( }) } -pub(crate) fn map_custom_world_gallery_list_result( - result: CustomWorldGalleryListResult, -) -> Result, SpacetimeClientError> { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - Ok(result - .entries - .into_iter() - .map(map_custom_world_gallery_entry_snapshot) - .collect::, _>>()?) -} - pub(crate) fn map_custom_world_library_mutation_result( result: CustomWorldLibraryMutationResult, ) -> Result { @@ -2676,6 +2704,38 @@ pub(crate) fn map_custom_world_gallery_entry_snapshot( }) } +pub(crate) fn map_custom_world_gallery_entry_row( + row: CustomWorldGalleryEntry, + recent_play_count_7d: u32, +) -> CustomWorldGalleryEntryRecord { + CustomWorldGalleryEntryRecord { + owner_user_id: row.owner_user_id, + profile_id: row.profile_id, + public_work_code: row.public_work_code, + author_public_user_code: row.author_public_user_code, + visibility: "published".to_string(), + published_at: Some(format_timestamp_micros( + row.published_at.to_micros_since_unix_epoch(), + )), + updated_at: format_timestamp_micros(row.updated_at.to_micros_since_unix_epoch()), + author_display_name: row.author_display_name, + world_name: row.world_name, + subtitle: row.subtitle, + summary_text: row.summary_text, + cover_image_src: row.cover_image_src, + theme_mode: format_custom_world_theme_mode(map_custom_world_theme_mode_back( + row.theme_mode, + )) + .to_string(), + playable_npc_count: row.playable_npc_count, + landmark_count: row.landmark_count, + play_count: row.play_count, + remix_count: row.remix_count, + like_count: row.like_count, + recent_play_count_7d, + } +} + pub(crate) fn map_custom_world_published_profile_compile_snapshot( snapshot: CustomWorldPublishedProfileCompileSnapshot, ) -> Result { diff --git a/server-rs/crates/spacetime-client/src/puzzle.rs b/server-rs/crates/spacetime-client/src/puzzle.rs index 1006e6f1..3bd25ecd 100644 --- a/server-rs/crates/spacetime-client/src/puzzle.rs +++ b/server-rs/crates/spacetime-client/src/puzzle.rs @@ -5,45 +5,6 @@ use crate::module_bindings::delete_puzzle_work_procedure::delete_puzzle_work; use crate::module_bindings::record_puzzle_work_like_procedure::record_puzzle_work_like; use crate::module_bindings::remix_puzzle_work_procedure::remix_puzzle_work; use crate::module_bindings::save_puzzle_ui_background_procedure::save_puzzle_ui_background; -use std::collections::HashMap; -use std::time::{SystemTime, UNIX_EPOCH}; - -const PUBLIC_WORK_PLAY_DAY_MICROS: i64 = 86_400_000_000; -const PUBLIC_WORK_RECENT_PLAY_WINDOW_DAYS: i64 = 7; - -fn current_unix_micros() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_micros() as i64) - .unwrap_or(0) -} - -fn current_public_work_day() -> i64 { - current_unix_micros().div_euclid(PUBLIC_WORK_PLAY_DAY_MICROS) -} - -fn puzzle_gallery_recent_play_counts(connection: &DbConnection) -> HashMap { - let current_day = current_public_work_day(); - let first_day = current_day - (PUBLIC_WORK_RECENT_PLAY_WINDOW_DAYS - 1); - let mut counts = HashMap::new(); - - for row in connection - .db() - .public_work_play_daily_stat() - .iter() - { - if row.source_type != "puzzle" - || row.played_day < first_day - || row.played_day > current_day - { - continue; - } - let entry: &mut u32 = counts.entry(row.profile_id).or_insert(0); - *entry = (*entry).saturating_add(row.play_count); - } - - counts -} impl SpacetimeClient { pub async fn create_puzzle_agent_session( @@ -443,9 +404,13 @@ impl SpacetimeClient { &self, ) -> Result, SpacetimeClientError> { self.read_after_connect(move |connection| { - let mut items = connection.db().puzzle_gallery_view().iter().collect::>(); + let mut items = connection + .db() + .puzzle_gallery_view() + .iter() + .collect::>(); items.sort_by(|left, right| right.updated_at_micros.cmp(&left.updated_at_micros)); - let recent_play_counts = puzzle_gallery_recent_play_counts(connection); + let recent_play_counts = public_work_recent_play_counts(connection, "puzzle"); Ok(items .into_iter() .map(|item| { diff --git a/server-rs/crates/spacetime-client/src/runtime.rs b/server-rs/crates/spacetime-client/src/runtime.rs index a4b3aa29..da9cd9d7 100644 --- a/server-rs/crates/spacetime-client/src/runtime.rs +++ b/server-rs/crates/spacetime-client/src/runtime.rs @@ -3,6 +3,49 @@ use super::*; impl SpacetimeClient { pub async fn get_creation_entry_config( &self, + ) -> Result { + match self + .read_after_connect(move |connection| { + let config_id = module_runtime::CREATION_ENTRY_CONFIG_GLOBAL_ID.to_string(); + let header = connection + .db() + .creation_entry_config() + .config_id() + .find(&config_id) + .ok_or_else(|| SpacetimeClientError::missing_snapshot("创作入口配置快照"))?; + let creation_types = connection + .db() + .creation_entry_type_config() + .iter() + .collect::>(); + Ok(build_creation_entry_config_record_from_rows( + header, + creation_types, + )) + }) + .await + { + Ok(config) => { + self.cache_creation_entry_config(config.clone()).await; + Ok(config) + } + Err(_) => { + if let Some(config) = self.read_cached_creation_entry_config().await { + return Ok(config); + } + match self.fetch_creation_entry_config_via_procedure().await { + Ok(config) => { + self.cache_creation_entry_config(config.clone()).await; + Ok(config) + } + Err(fallback_error) => Err(fallback_error), + } + } + } + } + + async fn fetch_creation_entry_config_via_procedure( + &self, ) -> Result { self.call_after_connect("get_creation_entry_config", move |connection, sender| { connection @@ -22,20 +65,26 @@ impl SpacetimeClient { input: module_runtime::CreationEntryTypeAdminUpsertInput, ) -> Result { let procedure_input: CreationEntryTypeAdminUpsertInput = input.into(); - self.call_after_connect( - "upsert_creation_entry_type_config", - move |connection, sender| { - connection - .procedures() - .upsert_creation_entry_type_config_then(procedure_input, move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_creation_entry_config_procedure_result); - send_once(&sender, mapped); - }); - }, - ) - .await + let config = self + .call_after_connect( + "upsert_creation_entry_type_config", + move |connection, sender| { + connection + .procedures() + .upsert_creation_entry_type_config_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_creation_entry_config_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await?; + self.cache_creation_entry_config(config.clone()).await; + Ok(config) } pub async fn get_runtime_settings( diff --git a/server-rs/crates/spacetime-client/src/telemetry.rs b/server-rs/crates/spacetime-client/src/telemetry.rs index 9fdd9885..4893b1fd 100644 --- a/server-rs/crates/spacetime-client/src/telemetry.rs +++ b/server-rs/crates/spacetime-client/src/telemetry.rs @@ -52,17 +52,10 @@ fn spacetime_metrics() -> &'static SpacetimeMetrics { }) } -fn record_procedure( - procedure: &'static str, - duration: Duration, - failed: bool, -) { +fn record_procedure(procedure: &'static str, duration: Duration, failed: bool) { let labels = vec![ KeyValue::new("procedure", procedure), - KeyValue::new( - "status_class", - if failed { "error" } else { "ok" }, - ), + KeyValue::new("status_class", if failed { "error" } else { "ok" }), ]; let metrics = spacetime_metrics(); metrics.calls.add(1, &labels);