From 72d7214646ea59ce55530c4c6cb48915d95b3348 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 27 Aug 2026 13:36:28 +0800 Subject: [PATCH 1/9] =?UTF-8?q?=E6=94=B6=E5=8F=A3=E8=B7=A8=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E9=89=B4=E6=9D=83=E8=AF=BB=E5=8F=96=E4=B8=8E=E8=8A=82?= =?UTF-8?q?=E7=82=B9=20outbox=20=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生产 Bearer 鉴权改用 SpacetimeDB 会话校验 procedure 为认证整包投影增加单调水位保护 新增鉴权客户端 facade 与生成 bindings 补齐 tracking 与钱包退款 outbox 启动恢复 同步后端架构、运维和项目决策文档 --- .../shared-memory/decision-log.md | 6 + ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 4 +- server-rs/Cargo.lock | 1 + server-rs/crates/api-server/src/auth.rs | 100 ++++--- .../crates/api-server/src/tracking_outbox.rs | 43 +++ .../api-server/src/wallet_refund_outbox.rs | 277 +++++++++++++++++- .../spacetime-client/src/active/mapper.rs | 5 +- .../src/active/mapper/auth.rs | 17 ++ server-rs/crates/spacetime-client/src/auth.rs | 24 ++ .../spacetime-client/src/module_bindings.rs | 6 + .../auth_session_validation_input_type.rs | 17 ++ ...ession_validation_procedure_result_type.rs | 16 + .../validate_auth_session_procedure.rs | 59 ++++ server-rs/crates/spacetime-module/Cargo.toml | 1 + .../spacetime-module/src/auth/procedures.rs | 101 +++++++ 16 files changed, 634 insertions(+), 45 deletions(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/auth_session_validation_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/auth_session_validation_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/validate_auth_session_procedure.rs diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 4ffd04577..19936f239 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -7747,3 +7747,9 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - DirectProject 的 Codex cwd 固定为真实 `game/` 目录时,原生文件工具和 patch 必须使用 cwd 相对路径(`index.html`、`style.css`、`game.js`);`game/...` 仅用于 AGC manifest、回执和客户端投影,不能作为 cwd 内原生 patch 路径,以避免 `writing outside of the project`。 - 直连 Runtime 已取得 Developer Key 时,资源编辑的 `remote_credentials` 是该操作的完整身份边界;其中冻结平台快照为空表示 Developer 模式,禁止再从进程全局 GUI 登录态补回账号快照。平台账号模式仍只使用同一组凭据捕获的快照。 - 回归覆盖 Direct 系统提示路径合同和 Developer Key / GUI 快照隔离;未触碰用户项目 `.agent` 锁、账本或凭据。 + +## 2026-08-27 API 鉴权读取与节点本地 outbox 恢复切片 + +- 生产 Bearer 中间件的会话有效性改由 SpacetimeDB typed `validate_auth_session` procedure 在事务内校验 `user_account.token_version`、会话归属、撤销时间和过期时间;`InMemoryAuthStore` 仅保留启动恢复工作集及测试夹具,不作为生产请求鉴权读取源。该切片不等于登录、刷新、验证码和微信 state 的全量迁移,后续写路径仍需继续收口。 +- `sync_auth_store_projection` 增加 `auth_store_projection_meta.updated_at` 单调水位检查,早到的整包快照失败关闭;正式认证表仍是权威源,水位只是迁移期跨 API 实例的延迟快照保护。 +- tracking outbox 与 wallet refund outbox worker 启动即执行恢复;退款 outbox 会恢复崩溃遗留 `tmp-*` 文件并隔离损坏 / 冲突文件。两者仍是节点本地 durable fallback,不能用粘性会话替代共享持久化;生产节点必须保留对应目录并纳入恢复演练。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index df3653c50..f61169570 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -413,7 +413,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - Rust 结构体:`AuthStoreProjectionMeta` - 源码:`server-rs/crates/spacetime-module/src/auth/tables.rs` -认证恢复策略:`api-server` 启动时只从 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)导出 typed `AuthStoreProjectionView`,再恢复 `module-auth` 的进程内认证工作集;运行中 Bearer `sid` 或 refresh cookie 在本进程工作集内未命中时直接按失效处理,不再从 SpacetimeDB 导出整包认证状态刷新内存,避免旧投影把重复手机号或旧会话重新灌回进程。`module-auth` 只保留内存工作集和 projection 导入 / 导出能力,不再保留 JSON 快照导入 / 导出能力,也不写本地持久化文件;`auth-store.json` / `GENARRATIVE_AUTH_STORE_PATH` 不再是兼容恢复源。认证创建、登录会话、刷新、退出、改密、重置密码、绑定和资料变更等写操作必须在返回客户端前通过 `sync_auth_store_projection` 成功同步 SpacetimeDB 正式认证表;同步失败时接口返回错误,不允许把只存在于当前进程内存的账号或会话当成成功结果。新用户注册奖励、邀请码绑定和登录埋点必须排在认证同步成功之后,避免认证没落库时先写出钱包或邀请关系。若启动恢复阶段 SpacetimeDB 不可连接或超时,`api-server` 会按固定间隔持续重试认证工作集恢复,恢复成功后才开始监听 HTTP,避免一次短超时让进程永久停留在依赖不可用状态。 +认证恢复策略:`api-server` 启动时只从 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)导出 typed `AuthStoreProjectionView`,再恢复 `module-auth` 的进程内认证工作集;生产 Bearer 中间件不再从 `InMemoryAuthStore` 读取用户或会话,而是每次通过 typed `validate_auth_session` procedure 在 SpacetimeDB 事务内校验 `token_version`、会话归属、撤销时间和过期时间,SpacetimeDB 不可用时 fail closed 返回服务错误。测试构建仍可使用显式的内存测试夹具。运行中 refresh cookie 在本进程工作集内未命中时直接按失效处理,不再从 SpacetimeDB 导出整包认证状态刷新内存,避免旧投影把重复手机号或旧会话重新灌回进程。`module-auth` 只保留内存工作集和 projection 导入 / 导出能力,不再保留 JSON 快照导入 / 导出能力,也不写本地持久化文件;`auth-store.json` / `GENARRATIVE_AUTH_STORE_PATH` 不再是兼容恢复源。认证创建、登录会话、刷新、退出、改密、重置密码、绑定和资料变更等写操作仍必须在返回客户端前通过 `sync_auth_store_projection` 成功同步 SpacetimeDB 正式认证表;同步失败时接口返回错误,不允许把只存在于当前进程内存的账号或会话当成成功结果。投影同步使用 `auth_store_projection_meta.updated_at` 作为单调水位,拒绝早于当前水位的整包快照,避免两个 API 实例的延迟快照互相覆盖;这只是迁移期并发保护,不改变正式认证表的权威地位。新用户注册奖励、邀请码绑定和登录埋点必须排在认证同步成功之后,避免认证没落库时先写出钱包或邀请关系。若启动恢复阶段 SpacetimeDB 不可连接或超时,`api-server` 会按固定间隔持续重试认证工作集恢复,恢复成功后才开始监听 HTTP,避免一次短超时让进程永久停留在依赖不可用状态。 `auth_store_snapshot` 表和旧 `import_auth_store_snapshot_json` / `export_auth_store_snapshot_from_tables` procedure 已删除。认证投影同步只读写 `user_account`、`auth_identity`、`refresh_session` 和 `auth_store_projection_meta`;`auth_identity` 不再写 `phone_e164`、`display_name`、`avatar_url`,这些账号资料只以 `user_account` 为准。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 7de599e9c..bfc5e04f8 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -853,7 +853,9 @@ GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES=268435456 GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS=5000 ``` -outbox 采用 NDJSON 文件保存原始事件。达到 `BATCH_SIZE` 时会立刻把当前 active 文件原子封存为 sealed 文件,并马上切到新的 active 继续写入;后台 worker 异步 flush sealed 文件,HTTP 请求线程不等待 SpacetimeDB。`FLUSH_INTERVAL_MS` 只负责兜底封存长时间未满批的 active 文件。SpacetimeDB 批量 procedure 返回成功后删除 sealed 文件,失败则保留文件并重试。`MAX_BYTES` 是每个 outbox 实例的磁盘保护阈值,不是 flush 阈值;超过后低价值 route tracking 和 BgFilter provider 失败审计可以被丢弃并记录日志 / 指标,关键同步事件不进入该丢弃路径。api-server 使用配置目录本身,BgFilter worker 固定使用其 `bgfilter-worker/` 子目录,两个进程不得操作同一个 active 文件。sealed 文件若出现无法解析的坏行,会重命名为 `corrupt-*` 隔离并记录 `genarrative.tracking_outbox.files.corrupt` 指标,避免一个坏文件阻塞后续批量入库。进程收到退出信号后会在 `GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS` 窗口内封存各自 active 文件并尽力 flush sealed 文件,超时或 SpacetimeDB 暂不可用时保留本地文件给下次同角色启动继续投递。该机制对已 enqueue 记录提供至少一次投递语义,依赖 `tracking_event.event_id` 幂等跳过重复事件;BgFilter 尚未 enqueue 或因硬上限 / 保护阈值被丢弃的审计不在该保证内。 +outbox 采用 NDJSON 文件保存原始事件。达到 `BATCH_SIZE` 时会立刻把当前 active 文件原子封存为 sealed 文件,并马上切到新的 active 继续写入;后台 worker 异步 flush sealed 文件,HTTP 请求线程不等待 SpacetimeDB。worker 启动时会先封存并 flush 已存在的 active / sealed 文件,恢复窗口内 SpacetimeDB 暂不可用则保留文件并按后续周期重试;`FLUSH_INTERVAL_MS` 只负责兜底封存长时间未满批的 active 文件。SpacetimeDB 批量 procedure 返回成功后删除 sealed 文件,失败则保留文件并重试。`MAX_BYTES` 是每个 outbox 实例的磁盘保护阈值,不是 flush 阈值;超过后低价值 route tracking 和 BgFilter provider 失败审计可以被丢弃并记录日志 / 指标,关键同步事件不进入该丢弃路径。api-server 使用配置目录本身,BgFilter worker 固定使用其 `bgfilter-worker/` 子目录,两个进程不得操作同一个 active 文件。sealed 文件若出现无法解析的坏行,会重命名为 `corrupt-*` 隔离并记录 `genarrative.tracking_outbox.files.corrupt` 指标,避免一个坏文件阻塞后续批量入库。进程收到退出信号后会在 `GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS` 窗口内封存各自 active 文件并尽力 flush sealed 文件,超时或 SpacetimeDB 暂不可用时保留本地文件给下次同角色启动继续投递。该机制对已 enqueue 记录提供至少一次投递语义,依赖 `tracking_event.event_id` 幂等跳过重复事件;BgFilter 尚未 enqueue 或因硬上限 / 保护阈值被丢弃的审计不在该保证内。 + +钱包退款 outbox 同样在 worker 启动时立即扫描 pending 文件;如果进程在“临时文件写完但尚未改名”阶段崩溃,启动恢复会校验 `tmp-*` 内容并原子提升为按 ledger id 命名的 pending 文件,损坏或冲突文件移入 `corrupt-*` 隔离目录,不会静默丢弃关键退款。该目录仍是节点本地 durable fallback,不能替代 SpacetimeDB 钱包账本;发布和主机替换必须保留 `/var/lib/genarrative/wallet-refund-outbox` 并纳入节点恢复 / 备份演练。 release 机器如果日志每秒刷 `tracking outbox ... Permission denied (os error 13)`,先检查 `/etc/genarrative/api-server.env` 是否缺少 `GENARRATIVE_TRACKING_OUTBOX_DIR`。缺少时 `api-server` 会回退到本地开发默认相对路径 `server-rs/.data/tracking-outbox`,而 systemd 的工作目录是只读发布目录 `/opt/genarrative/releases/`,`genarrative` 用户无法在其中创建 `server-rs`。修复顺序: diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index 3689d1b5b..bb51bc777 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -5526,6 +5526,7 @@ dependencies = [ "shared-contracts", "spacetimedb", "spacetimedb-lib", + "time", ] [[package]] diff --git a/server-rs/crates/api-server/src/auth.rs b/server-rs/crates/api-server/src/auth.rs index cac3b201d..e1c9639d0 100644 --- a/server-rs/crates/api-server/src/auth.rs +++ b/server-rs/crates/api-server/src/auth.rs @@ -19,6 +19,7 @@ use serde_json::{Value, json}; use shared_contracts::auth::RuntimeGuestTokenResponse; #[cfg(any())] use shared_kernel::{format_rfc3339, new_uuid_simple_string}; +#[cfg(test)] use time::OffsetDateTime; use tracing::warn; @@ -236,54 +237,77 @@ async fn authenticate_request( ); AppError::from_status(StatusCode::UNAUTHORIZED) })?; - let current_user = state - .auth_user_service() - .get_user_by_id(claims.user_id()) - .map_err(|error| { - warn!( - %request_id, - error = %error, - "Bearer JWT 用户快照读取失败" - ); - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) - })?; - let Some(current_user) = current_user else { - warn!( - %request_id, - user_id = %claims.user_id(), - "Bearer JWT 对应用户不存在" - ); - return Err(AppError::from_status(StatusCode::UNAUTHORIZED)); - }; - if current_user.token_version != claims.token_version() { - warn!( - %request_id, - user_id = %claims.user_id(), - token_version = claims.token_version(), - current_token_version = current_user.token_version, - "Bearer JWT 版本已失效" - ); - return Err(AppError::from_status(StatusCode::UNAUTHORIZED) - .with_message("当前登录态已失效,请重新登录")); - } - + #[cfg(not(test))] let session_is_active = state - .refresh_session_service() - .is_session_active_for_user( - claims.user_id(), - claims.session_id(), - OffsetDateTime::now_utc(), - ) + .spacetime_client() + .validate_auth_session(spacetime_client::AuthSessionValidationRecordInput { + user_id: claims.user_id().to_string(), + session_id: claims.session_id().to_string(), + token_version: claims.token_version(), + }) + .await .map_err(|error| { warn!( %request_id, user_id = %claims.user_id(), session_id = %claims.session_id(), error = %error, - "Bearer JWT refresh session 状态读取失败" + "Bearer JWT SpacetimeDB 会话状态读取失败" ); AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) })?; + + #[cfg(test)] + let session_is_active = { + let current_user = state + .auth_user_service() + .get_user_by_id(claims.user_id()) + .map_err(|error| { + warn!( + %request_id, + error = %error, + "Bearer JWT 用户快照读取失败" + ); + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + })?; + let Some(current_user) = current_user else { + warn!( + %request_id, + user_id = %claims.user_id(), + "Bearer JWT 对应用户不存在" + ); + return Err(AppError::from_status(StatusCode::UNAUTHORIZED)); + }; + if current_user.token_version != claims.token_version() { + warn!( + %request_id, + user_id = %claims.user_id(), + token_version = claims.token_version(), + current_token_version = current_user.token_version, + "Bearer JWT 版本已失效" + ); + return Err(AppError::from_status(StatusCode::UNAUTHORIZED) + .with_message("当前登录态已失效,请重新登录")); + } + + state + .refresh_session_service() + .is_session_active_for_user( + claims.user_id(), + claims.session_id(), + OffsetDateTime::now_utc(), + ) + .map_err(|error| { + warn!( + %request_id, + user_id = %claims.user_id(), + session_id = %claims.session_id(), + error = %error, + "Bearer JWT refresh session 状态读取失败" + ); + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + })? + }; if !session_is_active { warn!( %request_id, diff --git a/server-rs/crates/api-server/src/tracking_outbox.rs b/server-rs/crates/api-server/src/tracking_outbox.rs index 0f85a68c9..272f08df4 100644 --- a/server-rs/crates/api-server/src/tracking_outbox.rs +++ b/server-rs/crates/api-server/src/tracking_outbox.rs @@ -140,6 +140,10 @@ impl TrackingOutbox { pub fn spawn_worker(self: Arc) { tokio::spawn(async move { + if let Err(error) = self.flush_sealed_files_once().await { + warn!(error = %error, "tracking outbox 启动恢复写入 SpacetimeDB 失败,将保留文件等待重试"); + } + loop { tokio::select! { _ = sleep(self.flush_interval) => { @@ -657,6 +661,45 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[tokio::test] + async fn worker_flushes_existing_active_file_immediately_on_startup() { + let dir = test_dir("worker-startup"); + std::fs::create_dir_all(&dir).unwrap(); + let active_path = dir.join(ACTIVE_FILE_NAME); + let record = TrackingOutboxRecord { + event: sample_event("startup-event"), + }; + std::fs::write(&active_path, serde_json::to_vec(&record).unwrap()).unwrap(); + + let outbox = test_outbox(dir.clone(), 500, 1024 * 1024); + outbox.spawn_worker(); + + for _ in 0..100 { + if !active_path.exists() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + + assert!( + !active_path.exists(), + "worker should recover active file without waiting for interval" + ); + let sealed_count = std::fs::read_dir(&dir) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| { + entry + .file_name() + .to_str() + .is_some_and(|name| name.starts_with(SEALED_FILE_PREFIX)) + }) + .count(); + assert_eq!(sealed_count, 1); + + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn directory_size_excludes_quarantined_corrupt_files() { let dir = test_dir("directory-size"); diff --git a/server-rs/crates/api-server/src/wallet_refund_outbox.rs b/server-rs/crates/api-server/src/wallet_refund_outbox.rs index 7c27580f3..a97769086 100644 --- a/server-rs/crates/api-server/src/wallet_refund_outbox.rs +++ b/server-rs/crates/api-server/src/wallet_refund_outbox.rs @@ -84,7 +84,10 @@ impl WalletRefundOutbox { fs::create_dir_all(&self.dir).await?; let pending_path = self.pending_path_for_ledger(&record.ledger_id); - if fs::metadata(&pending_path).await.is_ok() { + if self + .reuse_existing_pending_file(&pending_path, &record.ledger_id) + .await? + { self.flush_notify.notify_one(); return Ok(WalletRefundOutboxEnqueueOutcome::Enqueued); } @@ -108,7 +111,10 @@ impl WalletRefundOutbox { file.flush().await?; file.sync_data().await?; drop(file); - if fs::metadata(&pending_path).await.is_ok() { + if self + .reuse_existing_pending_file(&pending_path, &record.ledger_id) + .await? + { let _ = fs::remove_file(&temp_path).await; self.flush_notify.notify_one(); return Ok(WalletRefundOutboxEnqueueOutcome::Enqueued); @@ -119,8 +125,54 @@ impl WalletRefundOutbox { Ok(WalletRefundOutboxEnqueueOutcome::Enqueued) } + async fn reuse_existing_pending_file( + &self, + pending_path: &Path, + ledger_id: &str, + ) -> Result { + let metadata = match fs::metadata(pending_path).await { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error.into()), + }; + if !metadata.is_file() { + return Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + format!( + "refund pending path is not a regular file: {}", + pending_path.display() + ), + ) + .into()); + } + + match read_refund_record(pending_path).await { + Ok(existing) if existing.ledger_id == ledger_id => Ok(true), + Ok(_) => Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + format!("refund ledger hash collision at {}", pending_path.display()), + ) + .into()), + Err(error) if error.is_data_corruption() => { + // Preserve the malformed durable file for inspection, then allow this + // enqueue to install a valid file for the same ledger id. + self.quarantine_file(pending_path).await?; + warn!( + source = %pending_path.display(), + "wallet refund outbox 已隔离损坏 pending 文件,继续写入新的幂等退款记录" + ); + Ok(false) + } + Err(error) => Err(error), + } + } + pub fn spawn_worker(self: Arc) { tokio::spawn(async move { + if let Err(error) = self.flush_pending_files_once().await { + warn!(error = %error, "wallet refund outbox 启动恢复退款失败,将保留文件等待重试"); + } + loop { tokio::select! { _ = sleep(self.flush_interval) => { @@ -144,6 +196,7 @@ impl WalletRefundOutbox { async fn flush_pending_files_once(&self) -> Result<(), WalletRefundOutboxError> { fs::create_dir_all(&self.dir).await?; + self.recover_temporary_files().await?; let pending_files = self.list_pending_files().await?; for path in pending_files.into_iter().take(self.batch_size) { let record = match read_refund_record(&path).await { @@ -197,6 +250,117 @@ impl WalletRefundOutbox { Ok(()) } + async fn recover_temporary_files(&self) -> Result<(), WalletRefundOutboxError> { + let _guard = self.enqueue_lock.lock().await; + let temporary_files = self.list_temporary_files().await?; + for path in temporary_files { + let record = match read_refund_record(&path).await { + Ok(record) => record, + Err(error) if error.is_data_corruption() => { + self.quarantine_file(&path).await?; + warn!( + error = %error, + source = %path.display(), + "wallet refund outbox 崩溃遗留临时文件无法解析,已隔离" + ); + continue; + } + Err(error) => return Err(error), + }; + + let pending_path = self.pending_path_for_ledger(&record.ledger_id); + match fs::metadata(&pending_path).await { + Ok(metadata) if metadata.is_file() => { + match read_refund_record(&pending_path).await { + Ok(existing) if existing.ledger_id == record.ledger_id => { + remove_file_and_sync(&path, &self.dir).await?; + debug!( + ledger_id = %record.ledger_id, + source = %path.display(), + target = %pending_path.display(), + "wallet refund outbox 临时文件与已有幂等文件重复,已删除临时副本" + ); + continue; + } + Err(error) if error.is_data_corruption() => { + self.quarantine_file(&pending_path).await?; + } + Ok(_) => { + self.quarantine_file(&path).await?; + warn!( + ledger_id = %record.ledger_id, + source = %path.display(), + target = %pending_path.display(), + "wallet refund outbox 临时文件命名冲突,已隔离临时文件" + ); + continue; + } + Err(error) => return Err(error), + } + } + Ok(_) => { + return Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + format!( + "refund pending path is not a regular file: {}", + pending_path.display() + ), + ) + .into()); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + + match fs::hard_link(&path, &pending_path).await { + Ok(()) => { + sync_directory_metadata(&self.dir).await?; + remove_file_and_sync(&path, &self.dir).await?; + debug!( + ledger_id = %record.ledger_id, + source = %path.display(), + target = %pending_path.display(), + "wallet refund outbox 崩溃遗留临时文件已恢复为幂等退款文件" + ); + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + // Another writer won the same ledger id. Keep the first durable file and + // remove only this duplicate temporary link. + remove_file_and_sync(&path, &self.dir).await?; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + // The file may have been completed by another process after the scan. + continue; + } + Err(error) => return Err(error.into()), + } + } + Ok(()) + } + + async fn list_temporary_files(&self) -> Result, WalletRefundOutboxError> { + let mut entries = fs::read_dir(&self.dir).await?; + let mut files = Vec::new(); + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|value| value.to_str()) else { + continue; + }; + if name.starts_with(TEMP_FILE_PREFIX) && name.ends_with(OUTBOX_FILE_EXTENSION) { + files.push(path); + } + } + files.sort(); + Ok(files) + } + + async fn quarantine_file(&self, path: &Path) -> Result<(), WalletRefundOutboxError> { + let corrupt_path = self.corrupt_path_for(path); + fs::rename(path, &corrupt_path).await?; + sync_directory_metadata(&self.dir).await?; + Ok(()) + } + async fn list_pending_files(&self) -> Result, WalletRefundOutboxError> { let mut entries = fs::read_dir(&self.dir).await?; let mut files = Vec::new(); @@ -335,10 +499,19 @@ fn ledger_id_hash(ledger_id: &str) -> String { fn is_pending_outbox_file_name(name: &std::ffi::OsStr) -> bool { name.to_str().is_some_and(|value| { - value.starts_with(PENDING_FILE_PREFIX) && value.ends_with(OUTBOX_FILE_EXTENSION) + (value.starts_with(PENDING_FILE_PREFIX) || value.starts_with(TEMP_FILE_PREFIX)) + && value.ends_with(OUTBOX_FILE_EXTENSION) }) } +async fn remove_file_and_sync(path: &Path, dir: &Path) -> Result<(), WalletRefundOutboxError> { + match fs::remove_file(path).await { + Ok(()) => sync_directory_metadata(dir).await, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + } +} + async fn sync_directory_metadata(path: &Path) -> Result<(), WalletRefundOutboxError> { let path = path.to_path_buf(); tokio::task::spawn_blocking(move || { @@ -459,6 +632,36 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[tokio::test] + async fn enqueue_does_not_silently_accept_corrupt_pending_file() { + let dir = test_dir("corrupt-pending-enqueue"); + std::fs::create_dir_all(&dir).unwrap(); + let outbox = test_outbox(dir.clone(), 1024 * 1024); + let record = sample_record("ledger-corrupt-pending"); + let pending_path = outbox.pending_path_for_ledger(&record.ledger_id); + std::fs::write(&pending_path, b"{not-json}").unwrap(); + + outbox.enqueue(record.clone()).await.unwrap(); + + assert_eq!( + read_refund_record(&pending_path).await.unwrap().ledger_id, + record.ledger_id + ); + let corrupt_count = std::fs::read_dir(&dir) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| { + entry + .file_name() + .to_str() + .is_some_and(|name| name.starts_with(CORRUPT_FILE_PREFIX)) + }) + .count(); + assert_eq!(corrupt_count, 1); + + let _ = std::fs::remove_dir_all(dir); + } + #[tokio::test] async fn shutdown_flush_keeps_file_when_spacetime_is_unavailable() { let dir = test_dir("shutdown"); @@ -480,4 +683,72 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + + #[tokio::test] + async fn flush_recovers_valid_crash_left_temp_file() { + let dir = test_dir("recover-temp"); + std::fs::create_dir_all(&dir).unwrap(); + let outbox = test_outbox(dir.clone(), 1024 * 1024); + let record = sample_record("ledger-temp"); + let temp_path = outbox.temp_path(); + std::fs::write(&temp_path, serde_json::to_vec(&record).unwrap()).unwrap(); + + let result = outbox.flush_pending_files_once().await; + + assert!(matches!(result, Err(WalletRefundOutboxError::Spacetime(_)))); + assert!(!temp_path.exists()); + assert!(outbox.pending_path_for_ledger(&record.ledger_id).exists()); + + let _ = std::fs::remove_dir_all(dir); + } + + #[tokio::test] + async fn flush_quarantines_corrupt_crash_left_temp_file() { + let dir = test_dir("recover-corrupt-temp"); + std::fs::create_dir_all(&dir).unwrap(); + let outbox = test_outbox(dir.clone(), 1024 * 1024); + let temp_path = outbox.temp_path(); + std::fs::write(&temp_path, b"{not-json}").unwrap(); + + outbox.flush_pending_files_once().await.unwrap(); + + assert!(!temp_path.exists()); + let corrupt_count = std::fs::read_dir(&dir) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| { + entry + .file_name() + .to_str() + .is_some_and(|name| name.starts_with(CORRUPT_FILE_PREFIX)) + }) + .count(); + assert_eq!(corrupt_count, 1); + + let _ = std::fs::remove_dir_all(dir); + } + + #[tokio::test] + async fn worker_recovers_temp_file_immediately_on_startup() { + let dir = test_dir("worker-startup"); + std::fs::create_dir_all(&dir).unwrap(); + let outbox = test_outbox(dir.clone(), 1024 * 1024); + let record = sample_record("ledger-worker-startup"); + let temp_path = outbox.temp_path(); + std::fs::write(&temp_path, serde_json::to_vec(&record).unwrap()).unwrap(); + + outbox.clone().spawn_worker(); + + for _ in 0..100 { + if !temp_path.exists() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + + assert!(!temp_path.exists()); + assert!(outbox.pending_path_for_ledger(&record.ledger_id).exists()); + + let _ = std::fs::remove_dir_all(dir); + } } diff --git a/server-rs/crates/spacetime-client/src/active/mapper.rs b/server-rs/crates/spacetime-client/src/active/mapper.rs index 2d925be39..66845286e 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper.rs @@ -38,6 +38,7 @@ pub use self::ai::{ AiResultReferenceRecord, AiTaskMutationRecord, AiTaskRecord, AiTaskStageRecord, AiTextChunkRecord, }; +pub use self::auth::AuthSessionValidationRecordInput; pub use self::editor_agent::{ EditorAgentConversationCreateRecordInput, EditorAgentConversationDeleteRecordInput, EditorAgentConversationRecord, EditorAgentConversationTouchRecordInput, @@ -93,8 +94,8 @@ pub(crate) use self::assets::{ map_optional_asset_object_procedure_result, map_procedure_result, }; pub(crate) use self::auth::{ - map_auth_store_projection_procedure_result, map_auth_store_projection_sync_procedure_result, - map_auth_store_projection_view_input, + map_auth_session_validation_result, map_auth_store_projection_procedure_result, + map_auth_store_projection_sync_procedure_result, map_auth_store_projection_view_input, }; pub(crate) use self::editor_agent::{ map_editor_agent_conversation_list_procedure_result, diff --git a/server-rs/crates/spacetime-client/src/active/mapper/auth.rs b/server-rs/crates/spacetime-client/src/active/mapper/auth.rs index e0ba264bf..c6210fffd 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/auth.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/auth.rs @@ -1,5 +1,22 @@ use super::*; +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AuthSessionValidationRecordInput { + pub user_id: String, + pub session_id: String, + pub token_version: u64, +} + +pub(crate) fn map_auth_session_validation_result( + result: crate::module_bindings::AuthSessionValidationProcedureResult, +) -> Result { + if result.error_message.is_some() { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + Ok(result.active) +} + pub(crate) fn map_auth_store_projection_procedure_result( result: crate::module_bindings::AuthStoreProjectionProcedureResult, ) -> Result { diff --git a/server-rs/crates/spacetime-client/src/auth.rs b/server-rs/crates/spacetime-client/src/auth.rs index 89f14e61a..9d55dac1e 100644 --- a/server-rs/crates/spacetime-client/src/auth.rs +++ b/server-rs/crates/spacetime-client/src/auth.rs @@ -1,6 +1,30 @@ use super::*; impl SpacetimeClient { + pub async fn validate_auth_session( + &self, + input: AuthSessionValidationRecordInput, + ) -> Result { + let procedure_input = crate::module_bindings::AuthSessionValidationInput { + user_id: input.user_id, + session_id: input.session_id, + token_version: input.token_version, + }; + + self.call_after_connect("validate_auth_session", move |connection, sender| { + connection.procedures().validate_auth_session_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_auth_session_validation_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + pub async fn export_auth_store_projection_from_tables( &self, ) -> Result { diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index b9e6e2dad..f808362ee 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -115,6 +115,8 @@ pub mod asset_operation_wallet_settlement_type; pub mod attach_ai_result_reference_and_return_procedure; pub mod auth_identity_table; pub mod auth_identity_type; +pub mod auth_session_validation_input_type; +pub mod auth_session_validation_procedure_result_type; pub mod auth_store_projection_identity_type; pub mod auth_store_projection_meta_table; pub mod auth_store_projection_meta_type; @@ -844,6 +846,7 @@ pub mod user_account_table; pub mod user_account_type; pub mod user_browse_history_table; pub mod user_browse_history_type; +pub mod validate_auth_session_procedure; pub mod visual_novel_agent_message_row_type; pub mod visual_novel_agent_message_table; pub mod visual_novel_agent_session_row_type; @@ -974,6 +977,8 @@ pub use asset_operation_wallet_settlement_type::AssetOperationWalletSettlement; pub use attach_ai_result_reference_and_return_procedure::attach_ai_result_reference_and_return; pub use auth_identity_table::*; pub use auth_identity_type::AuthIdentity; +pub use auth_session_validation_input_type::AuthSessionValidationInput; +pub use auth_session_validation_procedure_result_type::AuthSessionValidationProcedureResult; pub use auth_store_projection_identity_type::AuthStoreProjectionIdentity; pub use auth_store_projection_meta_table::*; pub use auth_store_projection_meta_type::AuthStoreProjectionMeta; @@ -1703,6 +1708,7 @@ pub use user_account_table::*; pub use user_account_type::UserAccount; pub use user_browse_history_table::*; pub use user_browse_history_type::UserBrowseHistory; +pub use validate_auth_session_procedure::validate_auth_session; pub use visual_novel_agent_message_row_type::VisualNovelAgentMessageRow; pub use visual_novel_agent_message_table::*; pub use visual_novel_agent_session_row_type::VisualNovelAgentSessionRow; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/auth_session_validation_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/auth_session_validation_input_type.rs new file mode 100644 index 000000000..2d1479970 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/auth_session_validation_input_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AuthSessionValidationInput { + pub user_id: String, + pub session_id: String, + pub token_version: u64, +} + +impl __sdk::InModule for AuthSessionValidationInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/auth_session_validation_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/auth_session_validation_procedure_result_type.rs new file mode 100644 index 000000000..5e9afc3d5 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/auth_session_validation_procedure_result_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AuthSessionValidationProcedureResult { + pub active: bool, + pub error_message: Option, +} + +impl __sdk::InModule for AuthSessionValidationProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/validate_auth_session_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/validate_auth_session_procedure.rs new file mode 100644 index 000000000..e96904972 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/validate_auth_session_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::auth_session_validation_input_type::AuthSessionValidationInput; +use super::auth_session_validation_procedure_result_type::AuthSessionValidationProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ValidateAuthSessionArgs { + pub input: AuthSessionValidationInput, +} + +impl __sdk::InModule for ValidateAuthSessionArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `validate_auth_session`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait validate_auth_session { + fn validate_auth_session(&self, input: AuthSessionValidationInput) { + self.validate_auth_session_then(input, |_, _| {}); + } + + fn validate_auth_session_then( + &self, + input: AuthSessionValidationInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl validate_auth_session for super::RemoteProcedures { + fn validate_auth_session_then( + &self, + input: AuthSessionValidationInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, AuthSessionValidationProcedureResult>( + "validate_auth_session", + ValidateAuthSessionArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-module/Cargo.toml b/server-rs/crates/spacetime-module/Cargo.toml index 9f264dd64..d2a516e1b 100644 --- a/server-rs/crates/spacetime-module/Cargo.toml +++ b/server-rs/crates/spacetime-module/Cargo.toml @@ -20,3 +20,4 @@ module-runtime = { workspace = true, features = ["spacetime-types"] } sha2 = { workspace = true } spacetimedb = { workspace = true, features = ["unstable"] } spacetimedb-lib = { workspace = true, features = ["serde"] } +time = { workspace = true, features = ["parsing"] } diff --git a/server-rs/crates/spacetime-module/src/auth/procedures.rs b/server-rs/crates/spacetime-module/src/auth/procedures.rs index 5823f5450..8d5ff8eec 100644 --- a/server-rs/crates/spacetime-module/src/auth/procedures.rs +++ b/server-rs/crates/spacetime-module/src/auth/procedures.rs @@ -1,4 +1,6 @@ use crate::{ProcedureContext, ReducerContext, SpacetimeType, Table, Timestamp}; +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; use super::tables::{ AuthIdentity, AuthStoreProjectionMeta, RefreshSession, UserAccount, auth_identity, @@ -77,6 +79,68 @@ pub struct AuthStoreProjectionSyncProcedureResult { pub error_message: Option, } +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct AuthSessionValidationInput { + pub user_id: String, + pub session_id: String, + pub token_version: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct AuthSessionValidationProcedureResult { + pub active: bool, + pub error_message: Option, +} + +#[spacetimedb::procedure] +pub fn validate_auth_session( + ctx: &mut ProcedureContext, + input: AuthSessionValidationInput, +) -> AuthSessionValidationProcedureResult { + match ctx.try_with_tx(|tx| validate_auth_session_tx(tx, input.clone())) { + Ok(active) => AuthSessionValidationProcedureResult { + active, + error_message: None, + }, + Err(message) => AuthSessionValidationProcedureResult { + active: false, + error_message: Some(message), + }, + } +} + +fn validate_auth_session_tx( + ctx: &ReducerContext, + input: AuthSessionValidationInput, +) -> Result { + let Some(user) = ctx.db.user_account().user_id().find(&input.user_id) else { + return Ok(false); + }; + if user.token_version != input.token_version { + return Ok(false); + } + + let Some(session) = ctx + .db + .refresh_session() + .session_id() + .find(&input.session_id) + else { + return Ok(false); + }; + if session.user_id != input.user_id || session.revoked_at.is_some() { + return Ok(false); + } + + let expires_at = OffsetDateTime::parse(&session.expires_at, &Rfc3339) + .map_err(|_| "refresh session 过期时间格式非法".to_string())?; + let now = OffsetDateTime::from_unix_timestamp_nanos( + i128::from(ctx.timestamp.to_micros_since_unix_epoch()) * 1_000, + ) + .map_err(|_| "SpacetimeDB 当前时间超出认证时间范围".to_string())?; + Ok(expires_at > now) +} + #[spacetimedb::procedure] pub fn sync_auth_store_projection( ctx: &mut ProcedureContext, @@ -118,6 +182,14 @@ fn sync_auth_store_projection_tx( ctx: &ReducerContext, input: AuthStoreProjectionView, ) -> Result { + let current_updated_at_micros = ctx + .db + .auth_store_projection_meta() + .meta_id() + .find(&AUTH_STORE_PROJECTION_META_ID.to_string()) + .map(|row| row.updated_at.to_micros_since_unix_epoch()); + ensure_newer_auth_projection_version(current_updated_at_micros, input.updated_at_micros)?; + let user_ids = input .users .iter() @@ -255,6 +327,27 @@ fn sync_auth_store_projection_tx( }) } +/// Full projections are emitted by API-local auth worksets. The metadata row +/// is the monotonic watermark that keeps a delayed snapshot from deleting or +/// replacing data written by a newer snapshot on another API instance. The +/// check and the subsequent writes run in the same procedure transaction, so +/// concurrent calls are serialized by SpacetimeDB. Replaying the same +/// version remains idempotent. +fn ensure_newer_auth_projection_version( + current_updated_at_micros: Option, + incoming_updated_at_micros: i64, +) -> Result<(), String> { + if let Some(current_updated_at_micros) = current_updated_at_micros { + if incoming_updated_at_micros < current_updated_at_micros { + return Err(format!( + "认证投影版本冲突:请求版本 {incoming_updated_at_micros} 早于当前版本 {current_updated_at_micros}" + )); + } + } + + Ok(()) +} + fn export_auth_store_projection_from_tables_tx( ctx: &ReducerContext, ) -> Result { @@ -465,4 +558,12 @@ mod tests { ); assert_eq!(created_at, "2026-07-01T00:00:00Z"); } + + #[test] + fn auth_projection_version_must_advance_monotonically() { + assert!(ensure_newer_auth_projection_version(None, 1).is_ok()); + assert!(ensure_newer_auth_projection_version(Some(10), 11).is_ok()); + assert!(ensure_newer_auth_projection_version(Some(10), 10).is_ok()); + assert!(ensure_newer_auth_projection_version(Some(10), 9).is_err()); + } } -- 2.52.0 From 36a4d37d24b38199d07c2d2e4e2c43560fc45f8d Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 27 Aug 2026 14:47:22 +0800 Subject: [PATCH 2/9] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=89=B4=E6=9D=83?= =?UTF-8?q?=E6=8A=95=E5=BD=B1=E5=B9=B6=E5=8F=91=E4=B8=8E=E9=80=80=E6=AC=BE?= =?UTF-8?q?=20outbox=20=E5=86=B2=E7=AA=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 绑定认证工作集投影版本并在 SpacetimeDB 内执行 CAS 为认证 procedure 增加 runtime service identity 校验 防止退款 outbox 跨进程覆盖并校验临时文件事实 更新后端契约与项目决策记录 --- .../shared-memory/decision-log.md | 2 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- server-rs/crates/api-server/src/state.rs | 170 +++++++++++++++--- .../api-server/src/wallet_refund_outbox.rs | 167 +++++++++++++++-- server-rs/crates/module-auth/src/domain.rs | 3 + server-rs/crates/module-auth/src/lib.rs | 76 +++++++- .../src/active/mapper/auth.rs | 2 + .../spacetime-client/src/mapper/auth.rs | 2 + .../auth_store_projection_view_type.rs | 1 + .../spacetime-module/src/auth/procedures.rs | 68 +++++-- 10 files changed, 441 insertions(+), 52 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 19936f239..731298ee0 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -7751,5 +7751,5 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 ## 2026-08-27 API 鉴权读取与节点本地 outbox 恢复切片 - 生产 Bearer 中间件的会话有效性改由 SpacetimeDB typed `validate_auth_session` procedure 在事务内校验 `user_account.token_version`、会话归属、撤销时间和过期时间;`InMemoryAuthStore` 仅保留启动恢复工作集及测试夹具,不作为生产请求鉴权读取源。该切片不等于登录、刷新、验证码和微信 state 的全量迁移,后续写路径仍需继续收口。 -- `sync_auth_store_projection` 增加 `auth_store_projection_meta.updated_at` 单调水位检查,早到的整包快照失败关闭;正式认证表仍是权威源,水位只是迁移期跨 API 实例的延迟快照保护。 +- `validate_auth_session`、认证投影导出和同步 procedure 都从 `ctx.sender()` 取调用方并要求现役 runtime service identity。`sync_auth_store_projection` 使用 API 工作集启动恢复或上次成功同步绑定的 `base_updated_at_micros` 做事务内 CAS,并要求 `updated_at_micros` 严格递增;基线不一致或版本不晚于当前值的整包快照失败关闭,只有确认本次同步前没有遗留未确认本地变更且同步期间无其它本地认证变更时才从正式表恢复,否则保留旧基线继续失败关闭,避免自动恢复覆盖并发未提交变更。同步成功但期间出现新本地变更时最多连续补同步三轮,仍未稳定则失败关闭,并保留待重试 revision。正式认证表仍是权威源,这只是迁移期跨 API 实例的延迟快照保护。 - tracking outbox 与 wallet refund outbox worker 启动即执行恢复;退款 outbox 会恢复崩溃遗留 `tmp-*` 文件并隔离损坏 / 冲突文件。两者仍是节点本地 durable fallback,不能用粘性会话替代共享持久化;生产节点必须保留对应目录并纳入恢复演练。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index f61169570..a7dc7d6dd 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -413,7 +413,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - Rust 结构体:`AuthStoreProjectionMeta` - 源码:`server-rs/crates/spacetime-module/src/auth/tables.rs` -认证恢复策略:`api-server` 启动时只从 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)导出 typed `AuthStoreProjectionView`,再恢复 `module-auth` 的进程内认证工作集;生产 Bearer 中间件不再从 `InMemoryAuthStore` 读取用户或会话,而是每次通过 typed `validate_auth_session` procedure 在 SpacetimeDB 事务内校验 `token_version`、会话归属、撤销时间和过期时间,SpacetimeDB 不可用时 fail closed 返回服务错误。测试构建仍可使用显式的内存测试夹具。运行中 refresh cookie 在本进程工作集内未命中时直接按失效处理,不再从 SpacetimeDB 导出整包认证状态刷新内存,避免旧投影把重复手机号或旧会话重新灌回进程。`module-auth` 只保留内存工作集和 projection 导入 / 导出能力,不再保留 JSON 快照导入 / 导出能力,也不写本地持久化文件;`auth-store.json` / `GENARRATIVE_AUTH_STORE_PATH` 不再是兼容恢复源。认证创建、登录会话、刷新、退出、改密、重置密码、绑定和资料变更等写操作仍必须在返回客户端前通过 `sync_auth_store_projection` 成功同步 SpacetimeDB 正式认证表;同步失败时接口返回错误,不允许把只存在于当前进程内存的账号或会话当成成功结果。投影同步使用 `auth_store_projection_meta.updated_at` 作为单调水位,拒绝早于当前水位的整包快照,避免两个 API 实例的延迟快照互相覆盖;这只是迁移期并发保护,不改变正式认证表的权威地位。新用户注册奖励、邀请码绑定和登录埋点必须排在认证同步成功之后,避免认证没落库时先写出钱包或邀请关系。若启动恢复阶段 SpacetimeDB 不可连接或超时,`api-server` 会按固定间隔持续重试认证工作集恢复,恢复成功后才开始监听 HTTP,避免一次短超时让进程永久停留在依赖不可用状态。 +认证恢复策略:`api-server` 启动时只从 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)导出 typed `AuthStoreProjectionView`,再恢复 `module-auth` 的进程内认证工作集;生产 Bearer 中间件不再从 `InMemoryAuthStore` 读取用户或会话,而是每次通过 typed `validate_auth_session` procedure 在 SpacetimeDB 事务内校验 `token_version`、会话归属、撤销时间和过期时间,SpacetimeDB 不可用时 fail closed 返回服务错误。`validate_auth_session`、投影导出和投影同步均从 `ctx.sender()` 派生调用方,并复用现役 runtime service identity 白名单;启动恢复先完成该服务身份初始化,普通 SpacetimeDB identity 不能读取或改写私有认证表。测试构建仍可使用显式的内存测试夹具。运行中 refresh cookie 在本进程工作集内未命中时直接按失效处理,不再从 SpacetimeDB 导出整包认证状态刷新内存,避免旧投影把重复手机号或旧会话重新灌回进程。`module-auth` 只保留内存工作集和 projection 导入 / 导出能力,不再保留 JSON 快照导入 / 导出能力,也不写本地持久化文件;`auth-store.json` / `GENARRATIVE_AUTH_STORE_PATH` 不再是兼容恢复源。认证创建、登录会话、刷新、退出、改密、重置密码、绑定和资料变更等写操作仍必须在返回客户端前通过 `sync_auth_store_projection` 成功同步 SpacetimeDB 正式认证表;同步失败时接口返回错误,不允许把只存在于当前进程内存的账号或会话当成成功结果。每个 API 工作集绑定启动恢复或上次成功同步得到的 `auth_store_projection_meta.updated_at` 版本作为 `base_updated_at_micros`,SpacetimeDB 在同一事务内执行基线 CAS,并要求新的 `updated_at_micros` 严格递增;基线不一致或版本不晚于当前值时整包写入失败,冲突节点在确认本次同步前没有遗留未确认本地变更且同步期间没有其它本地认证变更后,才可丢弃本地工作集并从正式表恢复,不能用陈旧工作集删除、恢复或覆盖另一节点的新状态;若此前已有待重试 revision 或同期还有本地变更则保留旧基线并继续失败关闭,不用自动恢复覆盖未提交变更;同步成功但期间又出现新本地变更时最多连续补同步三轮,仍未稳定则失败关闭。这只是迁移期并发保护,不改变正式认证表的权威地位。新用户注册奖励、邀请码绑定和登录埋点必须排在认证同步成功之后,避免认证没落库时先写出钱包或邀请关系。若启动恢复阶段 SpacetimeDB 不可连接或超时,`api-server` 会按固定间隔持续重试认证工作集恢复,恢复成功后才开始监听 HTTP,避免一次短超时让进程永久停留在依赖不可用状态。 `auth_store_snapshot` 表和旧 `import_auth_store_snapshot_json` / `export_auth_store_snapshot_from_tables` procedure 已删除。认证投影同步只读写 `user_account`、`auth_identity`、`refresh_session` 和 `auth_store_projection_meta`;`auth_identity` 不再写 `phone_e164`、`display_name`、`avatar_url`,这些账号资料只以 `user_account` 为准。 diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 501182c91..fc82fab00 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -8,7 +8,7 @@ use std::{ fmt, sync::{ Arc, - atomic::{AtomicBool, Ordering}, + atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering}, }, }; @@ -35,7 +35,7 @@ use spacetime_client::{ SpacetimeClient, SpacetimeClientConfig, SpacetimeClientError, SpacetimeClientHealthSnapshot, }; use time::OffsetDateTime; -use tokio::sync::{Semaphore, broadcast}; +use tokio::sync::{Mutex as AsyncMutex, Semaphore, broadcast}; use tracing::{info, warn}; use crate::config::AppConfig; @@ -273,6 +273,14 @@ pub struct AppStateInner { oss_client: Option, #[cfg_attr(test, allow(dead_code))] auth_store: InMemoryAuthStore, + /// 当前进程工作集所基于的正式认证投影版本;跨节点写入使用它做 CAS。 + #[cfg_attr(test, allow(dead_code))] + auth_projection_version: AtomicI64, + /// 最近一次确认写入正式投影时对应的工作集 revision;不一致表示有待重试的本地变更。 + #[cfg_attr(test, allow(dead_code))] + auth_projection_synced_revision: AtomicU64, + #[cfg_attr(test, allow(dead_code))] + auth_projection_sync_lock: AsyncMutex<()>, password_entry_service: PasswordEntryService, refresh_session_service: RefreshSessionService, auth_user_service: AuthUserService, @@ -505,12 +513,13 @@ impl AppState { pub fn new_with_empty_auth_store(config: AppConfig) -> Result { // 中文注释:api-server 不再把本地 auth-store.json 当作用户认证真相源,启动恢复只允许来自 SpacetimeDB。 - Self::new_with_auth_store(config, InMemoryAuthStore::default()) + Self::new_with_auth_store(config, InMemoryAuthStore::default(), 0) } fn new_with_auth_store( config: AppConfig, auth_store: InMemoryAuthStore, + auth_projection_version: i64, ) -> Result { let auth_jwt_config = JwtConfig::new( config.jwt_issuer.clone(), @@ -629,6 +638,9 @@ impl AppState { test_external_background_removal_enqueue: Arc::new(Mutex::new(None)), oss_client, auth_store, + auth_projection_version: AtomicI64::new(auth_projection_version), + auth_projection_synced_revision: AtomicU64::new(0), + auth_projection_sync_lock: AsyncMutex::new(()), password_entry_service, refresh_session_service, auth_user_service, @@ -1288,30 +1300,102 @@ impl AppState { return Ok(()); #[cfg(not(test))] - let updated_at_micros = i64::try_from( - OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000, - ) - .map_err(|_| SpacetimeClientError::Runtime("认证状态更新时间超出 i64 范围".to_string()))?; + let _sync_guard = self.auth_projection_sync_lock.lock().await; #[cfg(not(test))] - let projection = self - .auth_store - .export_projection_view(updated_at_micros) - .map_err(SpacetimeClientError::Runtime)?; - // 当前仍由 module-auth 的进程内工作集执行业务规则;这里只用 typed projection 同步正式认证表。 + let sync_start_revision = self.auth_store.revision(); #[cfg(not(test))] - if let Err(error) = self - .spacetime_client - .sync_auth_store_projection(projection) - .await - { - warn!( - error = %error, - "认证投影同步 SpacetimeDB 正式表失败,当前认证流程中止" - ); - return Err(error); + let mut local_changed_during_sync = + self.auth_projection_synced_revision.load(Ordering::Acquire) != sync_start_revision; + #[cfg(not(test))] + for attempt in 0..3 { + let base_updated_at_micros = self.auth_projection_version.load(Ordering::Acquire); + let now_updated_at_micros = + i64::try_from(OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000).map_err( + |_| SpacetimeClientError::Runtime("认证状态更新时间超出 i64 范围".to_string()), + )?; + let updated_at_micros = if now_updated_at_micros > base_updated_at_micros { + now_updated_at_micros + } else { + base_updated_at_micros.checked_add(1).ok_or_else(|| { + SpacetimeClientError::Runtime("认证状态版本超出 i64 范围".to_string()) + })? + }; + let (mut projection, attempted_revision) = self + .auth_store + .export_projection_view_with_revision(updated_at_micros) + .map_err(SpacetimeClientError::Runtime)?; + if attempted_revision != sync_start_revision { + local_changed_during_sync = true; + } + projection.base_updated_at_micros = base_updated_at_micros; + + // 当前仍由 module-auth 的进程内工作集执行业务规则;这里只用 typed projection 同步正式认证表。 + match self + .spacetime_client + .sync_auth_store_projection(projection) + .await + { + Ok(_) => { + self.auth_projection_version + .store(updated_at_micros, Ordering::Release); + if self.auth_store.revision() == attempted_revision { + self.auth_projection_synced_revision + .store(attempted_revision, Ordering::Release); + return Ok(()); + } + local_changed_during_sync = true; + warn!( + attempt, + "认证投影同步期间工作集发生变化,将继续同步最新工作集" + ); + continue; + } + Err(error) => { + warn!( + error = %error, + "认证投影同步 SpacetimeDB 正式表失败,当前认证流程中止" + ); + if local_changed_during_sync || self.auth_store.revision() != attempted_revision + { + warn!( + "认证投影同步失败期间工作集发生并发变化,跳过自动恢复以避免覆盖未提交变更" + ); + } else if let Ok(current_projection) = self + .spacetime_client + .export_auth_store_projection_from_tables() + .await + { + match self.auth_store.refresh_from_projection_view_if_revision( + current_projection.clone(), + attempted_revision, + ) { + Ok(true) => { + self.auth_projection_version + .store(current_projection.updated_at_micros, Ordering::Release); + self.auth_projection_synced_revision + .store(self.auth_store.revision(), Ordering::Release); + } + Ok(false) => { + warn!( + "认证投影同步冲突期间工作集发生并发变化,跳过自动恢复以避免覆盖未提交变更" + ); + } + Err(refresh_error) => { + warn!( + error = %refresh_error, + "认证投影同步冲突后恢复进程内工作集失败" + ); + } + } + } + return Err(error); + } + } } #[cfg(not(test))] - Ok(()) + Err(SpacetimeClientError::Runtime( + "认证工作集在同步期间持续发生变化,未能完成投影同步".to_string(), + )) } pub async fn try_restore_auth_store_from_spacetime( @@ -1319,6 +1403,11 @@ impl AppState { ) -> Result { let spacetime_client = SpacetimeClient::new(spacetime_client_config_for_startup_restore(&config)); + initialize_editor_generation_runtime_service_identity_for_startup( + &config, + &spacetime_client, + ) + .await?; let mut spacetime_restore_available = false; let mut restore_errors = Vec::new(); @@ -1332,7 +1421,11 @@ impl AppState { projection, AuthStoreRestoreSource::SpacetimeTables, )? { - let state = Self::new_with_auth_store(config, candidate.auth_store)?; + let state = Self::new_with_auth_store( + config, + candidate.auth_store, + candidate.updated_at_micros.unwrap_or_default(), + )?; info!( source = candidate.source.as_str(), updated_at_micros = candidate.updated_at_micros, @@ -1845,6 +1938,7 @@ fn auth_store_candidate_from_projection_view( if projection.users.is_empty() && projection.identities.is_empty() && projection.refresh_sessions.is_empty() + && projection.updated_at_micros == 0 { return Ok(None); } @@ -1886,6 +1980,34 @@ fn spacetime_client_config_for_startup_restore(config: &AppConfig) -> SpacetimeC } } +async fn initialize_editor_generation_runtime_service_identity_for_startup( + config: &AppConfig, + spacetime_client: &SpacetimeClient, +) -> Result<(), AppStateInitError> { + let pricing_store = + EditorGenerationPricingStore::load(config.editor_generation_pricing_override_path.clone()) + .map_err(|error| AppStateInitError::DependencyUnavailable(error.to_string()))?; + let fallback = pricing_store + .snapshot() + .map_err(|error| AppStateInitError::DependencyUnavailable(error.to_string()))?; + let models = editor_generation_pricing_to_records(&fallback) + .map_err(|error| AppStateInitError::DependencyUnavailable(error.to_string()))?; + spacetime_client + .initialize_editor_generation_pricing_config_if_missing( + editor_generation_pricing_upsert_input( + config, + "system:editor-generation-pricing".to_string(), + models, + crate::editor_project::current_utc_micros(), + ), + ) + .await + .map_err(|error| { + AppStateInitError::DependencyUnavailable(format!("初始化模型定价服务身份失败:{error}")) + })?; + Ok(()) +} + impl fmt::Display for AppStateInitError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { diff --git a/server-rs/crates/api-server/src/wallet_refund_outbox.rs b/server-rs/crates/api-server/src/wallet_refund_outbox.rs index a97769086..64ae5d884 100644 --- a/server-rs/crates/api-server/src/wallet_refund_outbox.rs +++ b/server-rs/crates/api-server/src/wallet_refund_outbox.rs @@ -34,7 +34,7 @@ pub struct WalletRefundOutbox { flush_notify: Arc, } -#[derive(Clone, Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] pub(crate) struct WalletRefundOutboxRecord { pub owner_user_id: String, pub amount: u64, @@ -85,7 +85,7 @@ impl WalletRefundOutbox { let pending_path = self.pending_path_for_ledger(&record.ledger_id); if self - .reuse_existing_pending_file(&pending_path, &record.ledger_id) + .reuse_existing_pending_file(&pending_path, &record) .await? { self.flush_notify.notify_one(); @@ -112,23 +112,48 @@ impl WalletRefundOutbox { file.sync_data().await?; drop(file); if self - .reuse_existing_pending_file(&pending_path, &record.ledger_id) + .reuse_existing_pending_file(&pending_path, &record) .await? { let _ = fs::remove_file(&temp_path).await; self.flush_notify.notify_one(); return Ok(WalletRefundOutboxEnqueueOutcome::Enqueued); } - fs::rename(&temp_path, &pending_path).await?; - sync_directory_metadata(&self.dir).await?; - self.flush_notify.notify_one(); - Ok(WalletRefundOutboxEnqueueOutcome::Enqueued) + for _ in 0..2 { + match fs::hard_link(&temp_path, &pending_path).await { + Ok(()) => { + sync_directory_metadata(&self.dir).await?; + remove_file_and_sync(&temp_path, &self.dir).await?; + self.flush_notify.notify_one(); + return Ok(WalletRefundOutboxEnqueueOutcome::Enqueued); + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + if self + .reuse_existing_pending_file(&pending_path, &record) + .await? + { + remove_file_and_sync(&temp_path, &self.dir).await?; + self.flush_notify.notify_one(); + return Ok(WalletRefundOutboxEnqueueOutcome::Enqueued); + } + } + Err(error) => return Err(error.into()), + } + } + Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + format!( + "refund pending path could not be installed: {}", + pending_path.display() + ), + ) + .into()) } async fn reuse_existing_pending_file( &self, pending_path: &Path, - ledger_id: &str, + expected: &WalletRefundOutboxRecord, ) -> Result { let metadata = match fs::metadata(pending_path).await { Ok(metadata) => metadata, @@ -147,7 +172,16 @@ impl WalletRefundOutbox { } match read_refund_record(pending_path).await { - Ok(existing) if existing.ledger_id == ledger_id => Ok(true), + Ok(existing) if existing == *expected => Ok(true), + Ok(existing) if existing.ledger_id == expected.ledger_id => Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + format!( + "refund ledger {} 已存在但退款事实不一致: {}", + expected.ledger_id, + pending_path.display() + ), + ) + .into()), Ok(_) => Err(std::io::Error::new( std::io::ErrorKind::AlreadyExists, format!("refund ledger hash collision at {}", pending_path.display()), @@ -272,7 +306,7 @@ impl WalletRefundOutbox { match fs::metadata(&pending_path).await { Ok(metadata) if metadata.is_file() => { match read_refund_record(&pending_path).await { - Ok(existing) if existing.ledger_id == record.ledger_id => { + Ok(existing) if existing == record => { remove_file_and_sync(&path, &self.dir).await?; debug!( ledger_id = %record.ledger_id, @@ -282,6 +316,16 @@ impl WalletRefundOutbox { ); continue; } + Ok(existing) if existing.ledger_id == record.ledger_id => { + self.quarantine_file(&path).await?; + warn!( + ledger_id = %record.ledger_id, + source = %path.display(), + target = %pending_path.display(), + "wallet refund outbox 临时文件与已有幂等文件事实不一致,已隔离临时文件" + ); + continue; + } Err(error) if error.is_data_corruption() => { self.quarantine_file(&pending_path).await?; } @@ -324,9 +368,51 @@ impl WalletRefundOutbox { ); } Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { - // Another writer won the same ledger id. Keep the first durable file and - // remove only this duplicate temporary link. - remove_file_and_sync(&path, &self.dir).await?; + match self + .reuse_existing_pending_file(&pending_path, &record) + .await + { + Ok(true) => { + // Another writer won the same ledger id with identical facts. Keep + // the first durable file and remove only this duplicate temporary + // link. + remove_file_and_sync(&path, &self.dir).await?; + } + Ok(false) => match fs::hard_link(&path, &pending_path).await { + Ok(()) => { + sync_directory_metadata(&self.dir).await?; + remove_file_and_sync(&path, &self.dir).await?; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + // The file may have been completed by another process after the + // scan. + continue; + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + self.quarantine_file(&path).await?; + warn!( + ledger_id = %record.ledger_id, + source = %path.display(), + target = %pending_path.display(), + "wallet refund outbox 临时文件无法与现有幂等文件合并,已隔离临时文件" + ); + } + Err(error) => return Err(error.into()), + }, + Err(WalletRefundOutboxError::Io(error)) + if error.kind() == std::io::ErrorKind::AlreadyExists => + { + self.quarantine_file(&path).await?; + warn!( + ledger_id = %record.ledger_id, + source = %path.display(), + target = %pending_path.display(), + error = %error, + "wallet refund outbox 临时文件与现有幂等文件事实冲突,已隔离临时文件" + ); + } + Err(error) => return Err(error), + } } Err(error) if error.kind() == std::io::ErrorKind::NotFound => { // The file may have been completed by another process after the scan. @@ -588,6 +674,25 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[tokio::test] + async fn enqueue_rejects_conflicting_existing_ledger_file() { + let dir = test_dir("conflicting-ledger"); + let outbox = test_outbox(dir.clone(), 1024 * 1024); + outbox.enqueue(sample_record("ledger-1")).await.unwrap(); + + let mut conflicting = sample_record("ledger-1"); + conflicting.amount += 1; + let error = outbox + .enqueue(conflicting) + .await + .expect_err("conflicting refund must fail"); + assert!( + matches!(error, WalletRefundOutboxError::Io(error) if error.kind() == std::io::ErrorKind::AlreadyExists) + ); + + let _ = std::fs::remove_dir_all(dir); + } + #[tokio::test] async fn enqueue_drops_when_outbox_exceeds_max_bytes() { let dir = test_dir("max-bytes"); @@ -702,6 +807,42 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[tokio::test] + async fn flush_quarantines_conflicting_crash_left_temp_file() { + let dir = test_dir("recover-conflicting-temp"); + std::fs::create_dir_all(&dir).unwrap(); + let outbox = test_outbox(dir.clone(), 1024 * 1024); + let record = sample_record("ledger-temp-conflict"); + let mut conflicting = record.clone(); + conflicting.amount += 1; + let pending_path = outbox.pending_path_for_ledger(&record.ledger_id); + let temp_path = outbox.temp_path(); + std::fs::write(&pending_path, serde_json::to_vec(&conflicting).unwrap()).unwrap(); + std::fs::write(&temp_path, serde_json::to_vec(&record).unwrap()).unwrap(); + + let result = outbox.flush_pending_files_once().await; + + assert!(matches!(result, Err(WalletRefundOutboxError::Spacetime(_)))); + assert!(!temp_path.exists()); + assert_eq!( + read_refund_record(&pending_path).await.unwrap(), + conflicting + ); + let corrupt_count = std::fs::read_dir(&dir) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| { + entry + .file_name() + .to_str() + .is_some_and(|name| name.starts_with(CORRUPT_FILE_PREFIX)) + }) + .count(); + assert_eq!(corrupt_count, 1); + + let _ = std::fs::remove_dir_all(dir); + } + #[tokio::test] async fn flush_quarantines_corrupt_crash_left_temp_file() { let dir = test_dir("recover-corrupt-temp"); diff --git a/server-rs/crates/module-auth/src/domain.rs b/server-rs/crates/module-auth/src/domain.rs index e602d5dd5..90c12cd90 100644 --- a/server-rs/crates/module-auth/src/domain.rs +++ b/server-rs/crates/module-auth/src/domain.rs @@ -187,6 +187,9 @@ pub struct AuthStoreProjectionView { pub users: Vec, pub identities: Vec, pub refresh_sessions: Vec, + /// 当前进程工作集所基于的正式投影版本,用于事务内 CAS。 + #[serde(default)] + pub base_updated_at_micros: i64, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index 6336495d0..a35682b04 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -12,7 +12,10 @@ pub use events::*; use std::{ collections::{HashMap, HashSet}, - sync::{Arc, Mutex}, + sync::{ + Arc, Mutex, + atomic::{AtomicU64, Ordering}, + }, }; use platform_auth::{ @@ -31,6 +34,7 @@ use tracing::{info, warn}; #[derive(Clone, Debug)] pub struct InMemoryAuthStore { inner: Arc>, + revision: Arc, } #[derive(Debug)] @@ -987,6 +991,7 @@ impl Default for InMemoryAuthStore { fn default() -> Self { Self { inner: Arc::new(Mutex::new(InMemoryAuthStoreState::default())), + revision: Arc::new(AtomicU64::new(0)), } } } @@ -1164,9 +1169,14 @@ impl InMemoryAuthStore { inner: Arc::new(Mutex::new(InMemoryAuthStoreState::from_projection_view( view, )?)), + revision: Arc::new(AtomicU64::new(0)), }) } + pub fn revision(&self) -> u64 { + self.revision.load(Ordering::Acquire) + } + pub fn refresh_from_projection_view( &self, view: AuthStoreProjectionView, @@ -1177,10 +1187,30 @@ impl InMemoryAuthStore { .lock() .map_err(|_| "认证仓储锁已中毒".to_string())?; state.apply_persistent_state(next_state); + self.revision.fetch_add(1, Ordering::Release); Ok(()) } + pub fn refresh_from_projection_view_if_revision( + &self, + view: AuthStoreProjectionView, + expected_revision: u64, + ) -> Result { + let next_state = InMemoryAuthStoreState::from_projection_view(view)?; + let mut state = self + .inner + .lock() + .map_err(|_| "认证仓储锁已中毒".to_string())?; + if self.revision.load(Ordering::Acquire) != expected_revision { + return Ok(false); + } + state.apply_persistent_state(next_state); + self.revision.fetch_add(1, Ordering::Release); + + Ok(true) + } + pub fn export_projection_view( &self, updated_at_micros: i64, @@ -1255,6 +1285,7 @@ impl InMemoryAuthStore { .collect::, String>>()?; Ok(AuthStoreProjectionView { + base_updated_at_micros: 0, updated_at_micros, users, identities, @@ -1262,8 +1293,24 @@ impl InMemoryAuthStore { }) } + pub fn export_projection_view_with_revision( + &self, + updated_at_micros: i64, + ) -> Result<(AuthStoreProjectionView, u64), String> { + for _ in 0..3 { + let before = self.revision.load(Ordering::Acquire); + let view = self.export_projection_view(updated_at_micros)?; + let after = self.revision.load(Ordering::Acquire); + if before == after { + return Ok((view, after)); + } + } + Err("认证工作集在导出期间持续发生变化".to_string()) + } + fn persist_state(&self, state: &InMemoryAuthStoreState) -> Result<(), String> { let _ = state; + self.revision.fetch_add(1, Ordering::Release); Ok(()) } @@ -2783,6 +2830,7 @@ mod tests { fn empty_projection_store() -> InMemoryAuthStore { InMemoryAuthStore::from_projection_view(AuthStoreProjectionView { + base_updated_at_micros: 0, updated_at_micros: 0, users: vec![], identities: vec![], @@ -3208,6 +3256,7 @@ mod tests { async fn phone_login_reuses_user_restored_from_projection() { let phone_service = build_phone_service( InMemoryAuthStore::from_projection_view(AuthStoreProjectionView { + base_updated_at_micros: 0, updated_at_micros: 1, users: vec![projection_user( "user_existing_phone", @@ -3359,6 +3408,30 @@ mod tests { )); } + #[test] + fn conditional_projection_refresh_rejects_stale_revision() { + let store = InMemoryAuthStore::default(); + let projection = AuthStoreProjectionView { + base_updated_at_micros: 0, + updated_at_micros: 1, + users: vec![], + identities: vec![], + refresh_sessions: vec![], + }; + + assert_eq!(store.revision(), 0); + store + .refresh_from_projection_view(projection.clone()) + .expect("initial projection refresh should succeed"); + assert_eq!(store.revision(), 1); + assert!( + !store + .refresh_from_projection_view_if_revision(projection, 0) + .expect("stale projection refresh should be checked without error") + ); + assert_eq!(store.revision(), 1); + } + #[tokio::test] async fn empty_projection_restore_does_not_block_phone_login() { let phone_service = build_phone_service(empty_projection_store()); @@ -4248,6 +4321,7 @@ mod tests { #[tokio::test] async fn bind_wechat_phone_merges_when_existing_phone_restored_from_projection() { let store = InMemoryAuthStore::from_projection_view(AuthStoreProjectionView { + base_updated_at_micros: 0, updated_at_micros: 1, users: vec![projection_user( "user_existing_phone_bind", diff --git a/server-rs/crates/spacetime-client/src/active/mapper/auth.rs b/server-rs/crates/spacetime-client/src/active/mapper/auth.rs index c6210fffd..fd32f8fdc 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/auth.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/auth.rs @@ -52,6 +52,7 @@ pub(crate) fn map_auth_store_projection_view_input( view: module_auth::AuthStoreProjectionView, ) -> crate::module_bindings::AuthStoreProjectionView { crate::module_bindings::AuthStoreProjectionView { + base_updated_at_micros: view.base_updated_at_micros, updated_at_micros: view.updated_at_micros, users: view .users @@ -111,6 +112,7 @@ fn map_auth_store_projection_view( view: crate::module_bindings::AuthStoreProjectionView, ) -> module_auth::AuthStoreProjectionView { module_auth::AuthStoreProjectionView { + base_updated_at_micros: view.base_updated_at_micros, updated_at_micros: view.updated_at_micros, users: view .users diff --git a/server-rs/crates/spacetime-client/src/mapper/auth.rs b/server-rs/crates/spacetime-client/src/mapper/auth.rs index e0ba264bf..b85eeb959 100644 --- a/server-rs/crates/spacetime-client/src/mapper/auth.rs +++ b/server-rs/crates/spacetime-client/src/mapper/auth.rs @@ -35,6 +35,7 @@ pub(crate) fn map_auth_store_projection_view_input( view: module_auth::AuthStoreProjectionView, ) -> crate::module_bindings::AuthStoreProjectionView { crate::module_bindings::AuthStoreProjectionView { + base_updated_at_micros: view.base_updated_at_micros, updated_at_micros: view.updated_at_micros, users: view .users @@ -94,6 +95,7 @@ fn map_auth_store_projection_view( view: crate::module_bindings::AuthStoreProjectionView, ) -> module_auth::AuthStoreProjectionView { module_auth::AuthStoreProjectionView { + base_updated_at_micros: view.base_updated_at_micros, updated_at_micros: view.updated_at_micros, users: view .users diff --git a/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_view_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_view_type.rs index fb99a128c..b771d4b3a 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_view_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_view_type.rs @@ -15,6 +15,7 @@ pub struct AuthStoreProjectionView { pub users: Vec, pub identities: Vec, pub refresh_sessions: Vec, + pub base_updated_at_micros: i64, } impl __sdk::InModule for AuthStoreProjectionView { diff --git a/server-rs/crates/spacetime-module/src/auth/procedures.rs b/server-rs/crates/spacetime-module/src/auth/procedures.rs index 8d5ff8eec..7bf0ded36 100644 --- a/server-rs/crates/spacetime-module/src/auth/procedures.rs +++ b/server-rs/crates/spacetime-module/src/auth/procedures.rs @@ -15,6 +15,7 @@ pub struct AuthStoreProjectionView { pub users: Vec, pub identities: Vec, pub refresh_sessions: Vec, + pub base_updated_at_micros: i64, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -97,7 +98,11 @@ pub fn validate_auth_session( ctx: &mut ProcedureContext, input: AuthSessionValidationInput, ) -> AuthSessionValidationProcedureResult { - match ctx.try_with_tx(|tx| validate_auth_session_tx(tx, input.clone())) { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + require_auth_service_identity(tx, caller)?; + validate_auth_session_tx(tx, input.clone()) + }) { Ok(active) => AuthSessionValidationProcedureResult { active, error_message: None, @@ -146,7 +151,11 @@ pub fn sync_auth_store_projection( ctx: &mut ProcedureContext, input: AuthStoreProjectionView, ) -> AuthStoreProjectionSyncProcedureResult { - match ctx.try_with_tx(|tx| sync_auth_store_projection_tx(tx, input.clone())) { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + require_auth_service_identity(tx, caller)?; + sync_auth_store_projection_tx(tx, input.clone()) + }) { Ok(record) => AuthStoreProjectionSyncProcedureResult { ok: true, record: Some(record), @@ -164,7 +173,11 @@ pub fn sync_auth_store_projection( pub fn export_auth_store_projection_from_tables( ctx: &mut ProcedureContext, ) -> AuthStoreProjectionProcedureResult { - match ctx.try_with_tx(|tx| export_auth_store_projection_from_tables_tx(tx)) { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + require_auth_service_identity(tx, caller)?; + export_auth_store_projection_from_tables_tx(tx) + }) { Ok(record) => AuthStoreProjectionProcedureResult { ok: true, record: Some(record), @@ -178,6 +191,14 @@ pub fn export_auth_store_projection_from_tables( } } +fn require_auth_service_identity( + ctx: &ReducerContext, + caller: crate::Identity, +) -> Result<(), String> { + crate::editor_project_storage::require_editor_generation_runtime_service_identity(ctx, caller) + .map_err(|_| "当前 identity 无权调用认证服务".to_string()) +} + fn sync_auth_store_projection_tx( ctx: &ReducerContext, input: AuthStoreProjectionView, @@ -188,6 +209,10 @@ fn sync_auth_store_projection_tx( .meta_id() .find(&AUTH_STORE_PROJECTION_META_ID.to_string()) .map(|row| row.updated_at.to_micros_since_unix_epoch()); + ensure_auth_projection_base_version( + input.base_updated_at_micros, + current_updated_at_micros.unwrap_or_default(), + )?; ensure_newer_auth_projection_version(current_updated_at_micros, input.updated_at_micros)?; let user_ids = input @@ -327,20 +352,31 @@ fn sync_auth_store_projection_tx( }) } -/// Full projections are emitted by API-local auth worksets. The metadata row -/// is the monotonic watermark that keeps a delayed snapshot from deleting or -/// replacing data written by a newer snapshot on another API instance. The -/// check and the subsequent writes run in the same procedure transaction, so -/// concurrent calls are serialized by SpacetimeDB. Replaying the same -/// version remains idempotent. +/// Full projections are emitted by API-local auth worksets. The base version +/// is read immediately before the write and checked in this same transaction, +/// so a stale API node cannot replace data written by another node meanwhile. +/// The timestamp remains a diagnostic/monotonic watermark for accepted writes. +fn ensure_auth_projection_base_version( + expected_updated_at_micros: i64, + current_updated_at_micros: i64, +) -> Result<(), String> { + if expected_updated_at_micros != current_updated_at_micros { + return Err(format!( + "认证投影基线版本冲突:请求基线 {expected_updated_at_micros} 不等于当前版本 {current_updated_at_micros}" + )); + } + + Ok(()) +} + fn ensure_newer_auth_projection_version( current_updated_at_micros: Option, incoming_updated_at_micros: i64, ) -> Result<(), String> { if let Some(current_updated_at_micros) = current_updated_at_micros { - if incoming_updated_at_micros < current_updated_at_micros { + if incoming_updated_at_micros <= current_updated_at_micros { return Err(format!( - "认证投影版本冲突:请求版本 {incoming_updated_at_micros} 早于当前版本 {current_updated_at_micros}" + "认证投影版本冲突:请求版本 {incoming_updated_at_micros} 不晚于当前版本 {current_updated_at_micros}" )); } } @@ -410,6 +446,7 @@ fn export_auth_store_projection_from_tables_tx( .collect(); Ok(AuthStoreProjectionView { + base_updated_at_micros: updated_at_micros, updated_at_micros, users, identities, @@ -563,7 +600,14 @@ mod tests { fn auth_projection_version_must_advance_monotonically() { assert!(ensure_newer_auth_projection_version(None, 1).is_ok()); assert!(ensure_newer_auth_projection_version(Some(10), 11).is_ok()); - assert!(ensure_newer_auth_projection_version(Some(10), 10).is_ok()); + assert!(ensure_newer_auth_projection_version(Some(10), 10).is_err()); assert!(ensure_newer_auth_projection_version(Some(10), 9).is_err()); } + + #[test] + fn auth_projection_base_version_must_match_current_version() { + assert!(ensure_auth_projection_base_version(0, 0).is_ok()); + assert!(ensure_auth_projection_base_version(10, 10).is_ok()); + assert!(ensure_auth_projection_base_version(9, 10).is_err()); + } } -- 2.52.0 From 426c20b476cafa191fbd1ba6f6b20bb3b69d8e2b Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 27 Aug 2026 16:15:46 +0800 Subject: [PATCH 3/9] =?UTF-8?q?=E5=B0=86=E9=80=80=E6=AC=BE=20outbox=20?= =?UTF-8?q?=E4=B8=BB=E8=B7=AF=E5=BE=84=E8=BF=81=E5=85=A5=20SpacetimeDB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 profile_wallet_refund_outbox 表与事务处理 procedure 失败任务事务写库内 pending 记录,API 节点跨实例处理并重试 SpacetimeDB 不可达时保留本机 emergency spool 与 attempt 追踪 同步 runtime bindings、架构文档、运维恢复和项目决策记录 --- .../shared-memory/decision-log.md | 8 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 9 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- .../crates/api-server/src/asset_billing.rs | 212 ++++++---- server-rs/crates/api-server/src/main.rs | 12 +- server-rs/crates/api-server/src/state.rs | 10 +- .../api-server/src/wallet_refund_outbox.rs | 106 ++++- .../crates/module-runtime/src/commands.rs | 53 +++ server-rs/crates/module-runtime/src/domain.rs | 32 ++ server-rs/crates/module-runtime/src/errors.rs | 4 + .../src/active/mapper/runtime_profile.rs | 47 +++ .../spacetime-client/src/active/runtime.rs | 60 +++ .../spacetime-client/src/module_bindings.rs | 38 ++ ...llet_refund_outbox_and_return_procedure.rs | 62 +++ ...llet_refund_outbox_and_return_procedure.rs | 62 +++ .../profile_wallet_refund_outbox_table.rs | 235 +++++++++++ .../profile_wallet_refund_outbox_type.rs | 103 +++++ ...wallet_refund_outbox_enqueue_input_type.rs | 23 ++ ...let_refund_outbox_procedure_result_type.rs | 20 + ...wallet_refund_outbox_process_input_type.rs | 16 + .../src/external_generation.rs | 4 + .../crates/spacetime-module/src/migration.rs | 1 + .../src/runtime/active/profile.rs | 381 +++++++++++++++++- 23 files changed, 1391 insertions(+), 109 deletions(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/enqueue_profile_wallet_refund_outbox_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/process_profile_wallet_refund_outbox_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/profile_wallet_refund_outbox_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/profile_wallet_refund_outbox_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_refund_outbox_enqueue_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_refund_outbox_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_refund_outbox_process_input_type.rs diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 731298ee0..bc69a4236 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -7752,4 +7752,10 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 生产 Bearer 中间件的会话有效性改由 SpacetimeDB typed `validate_auth_session` procedure 在事务内校验 `user_account.token_version`、会话归属、撤销时间和过期时间;`InMemoryAuthStore` 仅保留启动恢复工作集及测试夹具,不作为生产请求鉴权读取源。该切片不等于登录、刷新、验证码和微信 state 的全量迁移,后续写路径仍需继续收口。 - `validate_auth_session`、认证投影导出和同步 procedure 都从 `ctx.sender()` 取调用方并要求现役 runtime service identity。`sync_auth_store_projection` 使用 API 工作集启动恢复或上次成功同步绑定的 `base_updated_at_micros` 做事务内 CAS,并要求 `updated_at_micros` 严格递增;基线不一致或版本不晚于当前值的整包快照失败关闭,只有确认本次同步前没有遗留未确认本地变更且同步期间无其它本地认证变更时才从正式表恢复,否则保留旧基线继续失败关闭,避免自动恢复覆盖并发未提交变更。同步成功但期间出现新本地变更时最多连续补同步三轮,仍未稳定则失败关闭,并保留待重试 revision。正式认证表仍是权威源,这只是迁移期跨 API 实例的延迟快照保护。 -- tracking outbox 与 wallet refund outbox worker 启动即执行恢复;退款 outbox 会恢复崩溃遗留 `tmp-*` 文件并隔离损坏 / 冲突文件。两者仍是节点本地 durable fallback,不能用粘性会话替代共享持久化;生产节点必须保留对应目录并纳入恢复演练。 +- tracking outbox worker 与 wallet refund emergency spool worker 启动即执行恢复;退款 emergency spool 会恢复崩溃遗留 `tmp-*` 文件并隔离损坏 / 冲突文件。tracking 仍是节点本地 durable fallback,退款的正式队列已迁入 SpacetimeDB;两者都不能用粘性会话替代共享持久化,生产节点必须保留对应目录并纳入恢复演练。 + +## 2026-08-27 退款 outbox 主路径迁入 SpacetimeDB + +- 决策:`profile_wallet_refund_outbox` 是跨 API 节点退款的正式持久化队列。扣费失败、外部生成 attempt 失败或最终 lease 过期时,在同一个 SpacetimeDB 事务内按 `refund_ledger_id` 幂等写入 pending 行;worker 从库内 pending 行批量处理,退款账本写入与 outbox 成功删除保持在同一事务内,失败由 `available_at` / `attempts` 驱动重试。`asset_operation_wallet_settlement` 继续负责退款先于 consume 可见时的取消 intent,阻止迟到扣费。只有 SpacetimeDB 完全不可达时,api-server 才写本机 `wallet-refund-outbox` emergency spool;本机文件不能替代库内队列,必须持久挂载、告警、恢复演练并支持人工补偿。 +- 影响范围:`profile_wallet_refund_outbox` 表及 bindings、runtime enqueue/process procedure、外部生成失败事务、inline 资产退款、api-server 跨节点 worker 和 emergency spool、后端架构与开发运维文档。 +- 验证方式:运行 `npm run spacetime:generate`、`npm run check:spacetime-schema`、`npm run check:server-rs-ddd`、`cargo check -p spacetime-module -p spacetime-client -p api-server --manifest-path server-rs/Cargo.toml`、退款 outbox / asset billing / external generation 定向测试、`npm run check:encoding` 和 `git diff --check`。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index a7dc7d6dd..038ff0f32 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -232,7 +232,7 @@ npm run check:server-rs-ddd 3. 结果页单图重生成、发布、道具使用和其它独立资产操作仍按各自业务操作成本执行;不要把初始草稿成本误套到这些单次操作上。 4. 资产操作的预扣费必须 fail-closed:钱包或 SpacetimeDB 预扣费不可达、超时或返回业务错误时,`api-server` 直接返回错误,不允许继续调用图片、音频、GLB 等外部生成 provider。 5. 需要支持 HTTP retry 的计费 ledger id 必须包含当前请求的 `request_id`;前端 `fetchWithApiAuth` 同一次业务请求的静默刷新重试复用同一个 `x-request-id`,后端不得再使用 prompt 指纹或随机 asset id 作为扣费幂等键。 -6. 外部生成已预扣费但后续失败时必须先同步调用钱包退款;若 SpacetimeDB 暂不可用,退款请求写入 `wallet-refund-outbox` 本地文件并由后台 worker 重放。默认启用,配置项为 `GENARRATIVE_WALLET_REFUND_OUTBOX_ENABLED`、`GENARRATIVE_WALLET_REFUND_OUTBOX_DIR`、`GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE`、`GENARRATIVE_WALLET_REFUND_OUTBOX_FLUSH_INTERVAL_MS` 和 `GENARRATIVE_WALLET_REFUND_OUTBOX_MAX_BYTES`。outbox 文件按 refund ledger id 幂等落盘;成功重放后删除,坏文件隔离为 `corrupt-*`。外部生成任务触发的扣费和退款必须在 `profile_wallet_ledger.metadata_json` 中写入 `externalGenerationJobId`,outbox 重放也必须保留同一任务 ID,便于从退款记录追溯到正式生成任务。 +6. 外部生成已预扣费但后续失败时,失败/任务状态变更事务必须在 SpacetimeDB 内按 refund ledger id 幂等写入 `profile_wallet_refund_outbox` pending 行;跨节点 worker 从库内 pending 行批量处理并在库内事务执行退款,成功后删除 outbox 行,失败按 `available_at` 和 `attempts` 重试。当前 attempt 若 consume 尚不可见,事务仍必须先写 `asset_operation_wallet_settlement` 取消 intent,阻止迟到扣费。普通 inline 资产失败也先调用同一 DB outbox procedure;只有 SpacetimeDB 完全不可达时才写 `wallet-refund-outbox` 本机 emergency spool。默认启用,配置项为 `GENARRATIVE_WALLET_REFUND_OUTBOX_ENABLED`、`GENARRATIVE_WALLET_REFUND_OUTBOX_DIR`、`GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE`、`GENARRATIVE_WALLET_REFUND_OUTBOX_FLUSH_INTERVAL_MS` 和 `GENARRATIVE_WALLET_REFUND_OUTBOX_MAX_BYTES`。本机文件按 refund ledger id 幂等落盘;成功重放后删除,坏文件隔离为 `corrupt-*`,不能替代库内 outbox。外部生成任务触发的扣费和退款必须在 `profile_wallet_ledger.metadata_json` 与两类 outbox 中保留 `externalGenerationJobId` 和 `externalGenerationClaimAttempt`,便于从退款记录追溯到具体 attempt。 7. 拼图首图后台生成的跨实例互斥锁必须落在 SpacetimeDB `puzzle_background_compile_task` 表,claim id 由 `task_id + request_id` 构成,释放时必须校验 claim id,避免旧后台任务释放新请求抢到的租约。 ## 用户钱包与编辑器生成扣费契约 @@ -1057,6 +1057,13 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - 说明:资产操作 consume/refund 配对结算事实表,主键为 consume ledger ID,并保存配对 refund ledger、用户、金额和结算时间。退款先到且 consume 尚不可见时,该表作为持久化取消 intent;迟到 consume 必须检测该行并拒绝扣费,避免 worker 崩溃重领期间双扣。 - 索引:主键 `consume_ledger_id`。 +### `profile_wallet_refund_outbox` + +- Rust 结构体:`ProfileWalletRefundOutbox` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` +- 说明:跨节点资产退款的正式 pending 队列。主键为 refund ledger ID,保存 consume/refund 配对、用户、金额、资源、生成任务 attempt、失败原因和重试时间;失败事务先写入该表,worker 在 SpacetimeDB 事务内幂等执行钱包退款并删除成功行。只有数据库不可达时,api-server 才使用本机 `wallet-refund-outbox` emergency spool。 +- 索引:`(status, available_at)`。 + ### `profile_wallet_config` - Rust 结构体:`ProfileWalletConfig` diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index bfc5e04f8..ff5e6f725 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -855,7 +855,7 @@ GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS=5000 outbox 采用 NDJSON 文件保存原始事件。达到 `BATCH_SIZE` 时会立刻把当前 active 文件原子封存为 sealed 文件,并马上切到新的 active 继续写入;后台 worker 异步 flush sealed 文件,HTTP 请求线程不等待 SpacetimeDB。worker 启动时会先封存并 flush 已存在的 active / sealed 文件,恢复窗口内 SpacetimeDB 暂不可用则保留文件并按后续周期重试;`FLUSH_INTERVAL_MS` 只负责兜底封存长时间未满批的 active 文件。SpacetimeDB 批量 procedure 返回成功后删除 sealed 文件,失败则保留文件并重试。`MAX_BYTES` 是每个 outbox 实例的磁盘保护阈值,不是 flush 阈值;超过后低价值 route tracking 和 BgFilter provider 失败审计可以被丢弃并记录日志 / 指标,关键同步事件不进入该丢弃路径。api-server 使用配置目录本身,BgFilter worker 固定使用其 `bgfilter-worker/` 子目录,两个进程不得操作同一个 active 文件。sealed 文件若出现无法解析的坏行,会重命名为 `corrupt-*` 隔离并记录 `genarrative.tracking_outbox.files.corrupt` 指标,避免一个坏文件阻塞后续批量入库。进程收到退出信号后会在 `GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS` 窗口内封存各自 active 文件并尽力 flush sealed 文件,超时或 SpacetimeDB 暂不可用时保留本地文件给下次同角色启动继续投递。该机制对已 enqueue 记录提供至少一次投递语义,依赖 `tracking_event.event_id` 幂等跳过重复事件;BgFilter 尚未 enqueue 或因硬上限 / 保护阈值被丢弃的审计不在该保证内。 -钱包退款 outbox 同样在 worker 启动时立即扫描 pending 文件;如果进程在“临时文件写完但尚未改名”阶段崩溃,启动恢复会校验 `tmp-*` 内容并原子提升为按 ledger id 命名的 pending 文件,损坏或冲突文件移入 `corrupt-*` 隔离目录,不会静默丢弃关键退款。该目录仍是节点本地 durable fallback,不能替代 SpacetimeDB 钱包账本;发布和主机替换必须保留 `/var/lib/genarrative/wallet-refund-outbox` 并纳入节点恢复 / 备份演练。 +钱包退款正式 pending 队列在 SpacetimeDB 的 `profile_wallet_refund_outbox` 表中,由每个 API 节点的 worker 共同处理;worker 启动即扫描库内 pending 行,成功在同一事务内写钱包账本并删除 outbox 行,失败按库内 `available_at` / `attempts` 重试。只有 SpacetimeDB 完全不可达时才写本机 `wallet-refund-outbox` emergency spool;如果进程在“临时文件写完但尚未改名”阶段崩溃,启动恢复会校验 `tmp-*` 内容并原子提升为按 ledger id 命名的 pending 文件,损坏或冲突文件移入 `corrupt-*` 隔离目录,不会静默丢失关键退款。worker 连接失败、库内 retry、emergency spool 写入 / 容量失败和 `corrupt-*` 出现都必须接入告警;人工补偿先按 refund ledger id 对账 `profile_wallet_ledger`、`asset_operation_wallet_settlement` 与两类 outbox,再通过受控退款 procedure 幂等重放,禁止直接手写钱包表。该目录不能替代库内 outbox;发布和主机替换必须保留 `/var/lib/genarrative/wallet-refund-outbox` 并纳入节点恢复 / 备份演练。 release 机器如果日志每秒刷 `tracking outbox ... Permission denied (os error 13)`,先检查 `/etc/genarrative/api-server.env` 是否缺少 `GENARRATIVE_TRACKING_OUTBOX_DIR`。缺少时 `api-server` 会回退到本地开发默认相对路径 `server-rs/.data/tracking-outbox`,而 systemd 的工作目录是只读发布目录 `/opt/genarrative/releases/`,`genarrative` 用户无法在其中创建 `server-rs`。修复顺序: diff --git a/server-rs/crates/api-server/src/asset_billing.rs b/server-rs/crates/api-server/src/asset_billing.rs index 11e7369eb..83545d528 100644 --- a/server-rs/crates/api-server/src/asset_billing.rs +++ b/server-rs/crates/api-server/src/asset_billing.rs @@ -499,87 +499,127 @@ async fn refund_asset_operation_points_with_job_id( external_generation_claim_attempt: Option, ) -> Result<(), AppError> { let created_at_micros = current_utc_micros(); - let metadata_json = wallet_metadata_json( - external_generation_job_id.as_deref(), + let current_attempt_is_owned_by_failure_transaction = + external_generation_job_id.as_deref().is_some_and(|job_id| { + current_external_generation_billing_context().is_some_and(|context| { + context.job_id == job_id + && Some(context.claim_attempt) == external_generation_claim_attempt + }) + }); + if current_attempt_is_owned_by_failure_transaction { + // 队列当前 attempt 的 refund 由 fail_external_generation_job transaction 原子写入 + // SpacetimeDB outbox;这里不能先写另一笔独立退款,避免任务成功写回后被误退。 + return Ok(()); + } + let settlement_reason = if external_generation_job_id.is_some() { + "stale_attempt_recovery" + } else { + "asset_operation_failed" + }; + let enqueue_input = module_runtime::build_runtime_profile_wallet_refund_outbox_enqueue_input( + owner_user_id.clone(), + points_cost, + ledger_id.clone(), + created_at_micros, + asset_kind.clone(), + asset_id.clone(), + settlement_reason.to_string(), + external_generation_job_id.clone(), external_generation_claim_attempt, - ); - let result = state + ) + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "profile-wallet-refund-outbox", + "message": error.to_string(), + })) + })?; + let enqueue_result = state .spacetime_client() - .refund_profile_wallet_points_with_metadata( - owner_user_id.clone(), - points_cost, - ledger_id.clone(), - created_at_micros, - metadata_json, - ) + .enqueue_profile_wallet_refund_outbox(enqueue_input) .await; - if let Err(error) = result { - let refund_error = error.to_string(); - let app_error = map_asset_operation_wallet_error(error); - if let Some(outbox) = state.wallet_refund_outbox() { - match outbox - .enqueue(WalletRefundOutboxRecord { - owner_user_id: owner_user_id.clone(), - amount: points_cost, - ledger_id: ledger_id.clone(), - created_at_micros, - asset_kind: asset_kind.clone(), - asset_id: asset_id.clone(), - external_generation_job_id: external_generation_job_id.clone(), - }) - .await - { - Ok(WalletRefundOutboxEnqueueOutcome::Enqueued) => { - tracing::warn!( - owner_user_id, - asset_kind, - asset_id, - external_generation_job_id, - ledger_id, - error = %refund_error, - "资产操作失败后的泥点退款立即执行失败,已写入 wallet refund outbox" - ); - } - Ok(WalletRefundOutboxEnqueueOutcome::Dropped { reason }) => { - tracing::error!( - owner_user_id, - asset_kind, - asset_id, - external_generation_job_id, - ledger_id, - reason, - error = %refund_error, - "资产操作失败后的泥点退款立即执行失败,且 wallet refund outbox 因容量限制丢弃" - ); - } - Err(outbox_error) => { - tracing::error!( - owner_user_id, - asset_kind, - asset_id, - external_generation_job_id, - ledger_id, - refund_error = %refund_error, - outbox_error = %outbox_error, - "资产操作失败后的泥点退款立即执行失败,且写入 wallet refund outbox 失败" - ); - } - } - } else { - tracing::error!( + match enqueue_result { + Ok(_) => { + tracing::info!( owner_user_id, asset_kind, asset_id, external_generation_job_id, external_generation_claim_attempt, ledger_id, - error = %refund_error, - "资产操作失败后的泥点退款失败,且 wallet refund outbox 未启用" + "资产操作失败后的泥点退款已写入 SpacetimeDB refund outbox" ); + Ok(()) } - return Err(app_error); + Err(error) if should_use_wallet_refund_emergency_spool(&error) => { + let refund_error = error.to_string(); + let app_error = map_asset_operation_wallet_error(error); + if let Some(outbox) = state.wallet_refund_outbox() { + match outbox + .enqueue(WalletRefundOutboxRecord { + owner_user_id: owner_user_id.clone(), + amount: points_cost, + ledger_id: ledger_id.clone(), + created_at_micros, + asset_kind: asset_kind.clone(), + asset_id: asset_id.clone(), + settlement_reason: settlement_reason.to_string(), + external_generation_job_id: external_generation_job_id.clone(), + external_generation_claim_attempt, + }) + .await + { + Ok(WalletRefundOutboxEnqueueOutcome::Enqueued) => { + tracing::warn!( + owner_user_id, + asset_kind, + asset_id, + external_generation_job_id, + ledger_id, + error = %refund_error, + "SpacetimeDB refund outbox 不可达,已写入本机 emergency spool" + ); + } + Ok(WalletRefundOutboxEnqueueOutcome::Dropped { reason }) => { + tracing::error!( + owner_user_id, + asset_kind, + asset_id, + external_generation_job_id, + ledger_id, + reason, + error = %refund_error, + "SpacetimeDB refund outbox 不可达,且本机 emergency spool 因容量限制丢弃" + ); + } + Err(outbox_error) => { + tracing::error!( + owner_user_id, + asset_kind, + asset_id, + external_generation_job_id, + ledger_id, + refund_error = %refund_error, + outbox_error = %outbox_error, + "SpacetimeDB refund outbox 不可达,且写入本机 emergency spool 失败" + ); + } + } + } else { + tracing::error!( + owner_user_id, + asset_kind, + asset_id, + external_generation_job_id, + external_generation_claim_attempt, + ledger_id, + error = %refund_error, + "SpacetimeDB refund outbox 不可达,且本机 emergency spool 未启用" + ); + } + Err(app_error) + } + Err(error) => Err(map_asset_operation_wallet_error(error)), } - Ok(()) } fn current_external_generation_billing_context() -> Option { @@ -683,6 +723,22 @@ pub(crate) fn should_skip_asset_operation_billing_for_connectivity( } } +fn should_use_wallet_refund_emergency_spool(error: &SpacetimeClientError) -> bool { + match error { + SpacetimeClientError::ConnectDropped | SpacetimeClientError::Timeout(_) => true, + SpacetimeClientError::Build(message) + | SpacetimeClientError::Procedure(message) + | SpacetimeClientError::Runtime(message) => { + message.contains("503") + || message.contains("Service Unavailable") + || message.contains("Failed to connect") + || message.contains("WebSocket") + || message.contains("连接已断开") + || message.contains("连接在返回结果前已断开") + } + } +} + fn current_utc_micros() -> i64 { time::OffsetDateTime::now_utc().unix_timestamp_nanos() as i64 / 1_000 } @@ -838,6 +894,24 @@ mod tests { )); } + #[test] + fn wallet_refund_emergency_spool_requires_database_unavailability() { + assert!(should_use_wallet_refund_emergency_spool( + &SpacetimeClientError::ConnectDropped + )); + assert!(should_use_wallet_refund_emergency_spool( + &SpacetimeClientError::Runtime("503 Service Unavailable".to_string()) + )); + assert!(!should_use_wallet_refund_emergency_spool( + &SpacetimeClientError::Procedure( + "No such procedure: enqueue_profile_wallet_refund_outbox_and_return".to_string(), + ) + )); + assert!(!should_use_wallet_refund_emergency_spool( + &SpacetimeClientError::Procedure("泥点余额不足".to_string()) + )); + } + #[test] fn asset_operation_wallet_insufficient_balance_is_public_message() { for domain_message in [ diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 1d73da766..441936e1e 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -529,21 +529,24 @@ async fn finalize_shutdown(context: ShutdownContext) { } if let Some(outbox) = context.wallet_refund_outbox { - info!(timeout_ms, "api-server 退出前 flush wallet refund outbox"); + info!( + timeout_ms, + "api-server 退出前 flush wallet refund emergency spool" + ); match timeout(context.outbox_flush_timeout, outbox.flush_for_shutdown()).await { Ok(Ok(())) => { - info!("api-server 退出前 wallet refund outbox flush 完成"); + info!("api-server 退出前 wallet refund emergency spool flush 完成"); } Ok(Err(error)) => { warn!( error = %error, - "api-server 退出前 wallet refund outbox flush 未完成,已保留本地文件等待下次启动重试" + "api-server 退出前 wallet refund emergency spool flush 未完成,已保留本地文件等待下次启动重试" ); } Err(_) => { warn!( timeout_ms, - "api-server 退出前 wallet refund outbox flush 超时,已保留本地文件等待下次启动重试" + "api-server 退出前 wallet refund emergency spool flush 超时,已保留本地文件等待下次启动重试" ); } } @@ -557,6 +560,7 @@ fn spawn_common_app_state_background_workers(state: &AppState) { if let Some(outbox) = state.wallet_refund_outbox() { outbox.spawn_worker(); } + state.profile_wallet_refund_outbox_worker().spawn_worker(); } fn spawn_http_app_state_background_workers(state: &AppState, process_role: ProcessRole) { diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index fc82fab00..600a1d0e1 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -45,7 +45,7 @@ use crate::editor_generation_config::{ EditorGenerationPricingUnit, }; use crate::tracking_outbox::TrackingOutbox; -use crate::wallet_refund_outbox::WalletRefundOutbox; +use crate::wallet_refund_outbox::{ProfileWalletRefundOutboxWorker, WalletRefundOutbox}; use crate::wechat::pay::{build_wechat_pay_config, map_wechat_pay_init_error}; use crate::wechat::provider::build_wechat_provider; use crate::work_author::{ @@ -297,6 +297,7 @@ pub struct AppStateInner { puzzle_gallery_cache: PuzzleGalleryCache, tracking_outbox: Option>, wallet_refund_outbox: Option>, + profile_wallet_refund_outbox_worker: Arc, editor_generation_pricing_store: EditorGenerationPricingStore, llm_client: Option, vector_engine_llm_client: Option, @@ -586,6 +587,8 @@ impl AppState { let tracking_outbox = TrackingOutbox::from_config(&config, spacetime_client.clone()); let wallet_refund_outbox = WalletRefundOutbox::from_config(&config, spacetime_client.clone()); + let profile_wallet_refund_outbox_worker = + ProfileWalletRefundOutboxWorker::from_config(&config, spacetime_client.clone()); let editor_generation_pricing_store = EditorGenerationPricingStore::load( config.editor_generation_pricing_override_path.clone(), ) @@ -656,6 +659,7 @@ impl AppState { puzzle_gallery_cache: PuzzleGalleryCache::new(), tracking_outbox, wallet_refund_outbox, + profile_wallet_refund_outbox_worker, editor_generation_pricing_store, llm_client, vector_engine_llm_client, @@ -1508,6 +1512,10 @@ impl AppState { self.wallet_refund_outbox.clone() } + pub fn profile_wallet_refund_outbox_worker(&self) -> Arc { + self.profile_wallet_refund_outbox_worker.clone() + } + pub fn llm_client(&self) -> Option<&LlmClient> { self.llm_client.as_ref() } diff --git a/server-rs/crates/api-server/src/wallet_refund_outbox.rs b/server-rs/crates/api-server/src/wallet_refund_outbox.rs index 64ae5d884..b260ad1c3 100644 --- a/server-rs/crates/api-server/src/wallet_refund_outbox.rs +++ b/server-rs/crates/api-server/src/wallet_refund_outbox.rs @@ -34,6 +34,71 @@ pub struct WalletRefundOutbox { flush_notify: Arc, } +#[derive(Clone)] +pub struct ProfileWalletRefundOutboxWorker { + batch_size: u32, + flush_interval: Duration, + spacetime_client: SpacetimeClient, + worker_id: String, +} + +impl ProfileWalletRefundOutboxWorker { + pub fn from_config(config: &AppConfig, spacetime_client: SpacetimeClient) -> Arc { + Arc::new(Self { + batch_size: config + .wallet_refund_outbox_batch_size + .max(1) + .min(u32::MAX as usize) as u32, + flush_interval: config.wallet_refund_outbox_flush_interval, + spacetime_client, + worker_id: format!("api-server-refund-outbox-{}", std::process::id()), + }) + } + + pub fn spawn_worker(self: Arc) { + tokio::spawn(async move { + self.process_once().await; + loop { + sleep(self.flush_interval).await; + self.process_once().await; + } + }); + } + + async fn process_once(&self) { + match self + .spacetime_client + .process_profile_wallet_refund_outbox(self.worker_id.clone(), self.batch_size) + .await + { + Ok(result) if result.failed_count > 0 => { + warn!( + worker_id = %self.worker_id, + processed_count = result.processed_count, + retry_count = result.retry_count, + failed_count = result.failed_count, + "profile wallet refund outbox 处理部分失败,将按库内 available_at 重试" + ); + } + Ok(result) if result.processed_count > 0 => { + debug!( + worker_id = %self.worker_id, + processed_count = result.processed_count, + "profile wallet refund outbox 已完成库内退款" + ); + } + Ok(_) => {} + Err(error) => { + warn!( + worker_id = %self.worker_id, + error = %error, + "profile wallet refund outbox worker 暂时无法连接 SpacetimeDB" + ); + } + } + } +} + #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] pub(crate) struct WalletRefundOutboxRecord { pub owner_user_id: String, @@ -42,8 +107,12 @@ pub(crate) struct WalletRefundOutboxRecord { pub created_at_micros: i64, pub asset_kind: String, pub asset_id: String, + #[serde(default = "default_settlement_reason")] + pub settlement_reason: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub external_generation_job_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub external_generation_claim_attempt: Option, } #[derive(Debug)] @@ -250,15 +319,26 @@ impl WalletRefundOutbox { Err(error) => return Err(error), }; - match self - .spacetime_client - .refund_profile_wallet_points_with_metadata( + let enqueue_input = + module_runtime::build_runtime_profile_wallet_refund_outbox_enqueue_input( record.owner_user_id.clone(), record.amount, record.ledger_id.clone(), record.created_at_micros, - refund_metadata_json(record.external_generation_job_id.as_deref()), + record.asset_kind.clone(), + record.asset_id.clone(), + record.settlement_reason.clone(), + record.external_generation_job_id.clone(), + record.external_generation_claim_attempt, ) + .map_err(|error| { + WalletRefundOutboxError::Spacetime(SpacetimeClientError::Runtime( + error.to_string(), + )) + })?; + match self + .spacetime_client + .enqueue_profile_wallet_refund_outbox(enqueue_input) .await { Ok(_) => { @@ -275,7 +355,7 @@ impl WalletRefundOutbox { asset_id = %record.asset_id, external_generation_job_id = ?record.external_generation_job_id, path = %path.display(), - "wallet refund outbox 退款已重放并删除文件" + "wallet refund emergency spool 已恢复到 SpacetimeDB outbox 并删除文件" ); } Err(error) => return Err(WalletRefundOutboxError::Spacetime(error)), @@ -491,18 +571,8 @@ impl WalletRefundOutbox { } } -fn refund_metadata_json(external_generation_job_id: Option<&str>) -> String { - let Some(external_generation_job_id) = external_generation_job_id - .map(str::trim) - .filter(|value| !value.is_empty()) - else { - return module_runtime::PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string(); - }; - - serde_json::json!({ - "externalGenerationJobId": external_generation_job_id, - }) - .to_string() +fn default_settlement_reason() -> String { + "emergency_spool_replay".to_string() } impl fmt::Debug for WalletRefundOutbox { @@ -621,7 +691,9 @@ mod tests { created_at_micros: 1_713_680_000_000_000, asset_kind: "puzzle_initial_image".to_string(), asset_id: "asset-1".to_string(), + settlement_reason: "worker_attempt_failed".to_string(), external_generation_job_id: Some("extgen-test".to_string()), + external_generation_claim_attempt: Some(1), } } diff --git a/server-rs/crates/module-runtime/src/commands.rs b/server-rs/crates/module-runtime/src/commands.rs index d282cb10e..0fc648db8 100644 --- a/server-rs/crates/module-runtime/src/commands.rs +++ b/server-rs/crates/module-runtime/src/commands.rs @@ -285,6 +285,59 @@ pub fn build_runtime_profile_wallet_adjustment_input_with_metadata( }) } +pub fn build_runtime_profile_wallet_refund_outbox_enqueue_input( + owner_user_id: String, + amount: u64, + refund_ledger_id: String, + created_at_micros: i64, + asset_kind: String, + asset_id: String, + settlement_reason: String, + external_generation_job_id: Option, + external_generation_claim_attempt: Option, +) -> Result { + let adjustment = build_runtime_profile_wallet_adjustment_input( + owner_user_id, + amount, + refund_ledger_id, + created_at_micros, + )?; + let asset_kind = + normalize_required_string(asset_kind).ok_or(RuntimeProfileFieldError::MissingLedgerId)?; + let asset_id = + normalize_required_string(asset_id).ok_or(RuntimeProfileFieldError::MissingLedgerId)?; + let settlement_reason = normalize_required_string(settlement_reason) + .ok_or(RuntimeProfileFieldError::MissingLedgerId)?; + let external_generation_job_id = + external_generation_job_id.and_then(|value| normalize_required_string(value)); + if external_generation_job_id.is_some() != external_generation_claim_attempt.is_some() { + return Err(RuntimeProfileFieldError::InvalidExternalGenerationAttempt); + } + Ok(RuntimeProfileWalletRefundOutboxEnqueueInput { + owner_user_id: adjustment.user_id, + amount: adjustment.amount, + refund_ledger_id: adjustment.ledger_id, + created_at_micros: adjustment.created_at_micros, + asset_kind, + asset_id, + settlement_reason, + external_generation_job_id, + external_generation_claim_attempt, + }) +} + +pub fn build_runtime_profile_wallet_refund_outbox_process_input( + worker_id: String, + limit: u32, +) -> Result { + let worker_id = + normalize_required_string(worker_id).ok_or(RuntimeProfileFieldError::MissingLedgerId)?; + if limit == 0 { + return Err(RuntimeProfileFieldError::InvalidWalletAmount); + } + Ok(RuntimeProfileWalletRefundOutboxProcessInput { worker_id, limit }) +} + pub fn build_runtime_profile_recharge_center_get_input( user_id: String, ) -> Result { diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index d4334fc1f..7a4a8c928 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -2077,6 +2077,38 @@ pub struct RuntimeProfileWalletAdjustmentProcedureResult { pub error_message: Option, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileWalletRefundOutboxEnqueueInput { + pub owner_user_id: String, + pub amount: u64, + pub refund_ledger_id: String, + pub created_at_micros: i64, + pub asset_kind: String, + pub asset_id: String, + pub settlement_reason: String, + pub external_generation_job_id: Option, + pub external_generation_claim_attempt: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileWalletRefundOutboxProcessInput { + pub worker_id: String, + pub limit: u32, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileWalletRefundOutboxProcedureResult { + pub ok: bool, + pub enqueued_count: u32, + pub processed_count: u32, + pub retry_count: u32, + pub failed_count: u32, + pub error_message: Option, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfileWalletLedgerListInput { diff --git a/server-rs/crates/module-runtime/src/errors.rs b/server-rs/crates/module-runtime/src/errors.rs index 74e1924ed..e8f1f0457 100644 --- a/server-rs/crates/module-runtime/src/errors.rs +++ b/server-rs/crates/module-runtime/src/errors.rs @@ -82,6 +82,7 @@ pub enum RuntimeProfileFieldError { TaskNotClaimable, TaskAlreadyClaimed, MissingWorkerId, + InvalidExternalGenerationAttempt, MissingOrderId, MissingProductId, MissingProductTitle, @@ -172,6 +173,9 @@ impl std::fmt::Display for RuntimeProfileFieldError { Self::TaskNotClaimable => f.write_str("任务尚未达成"), Self::TaskAlreadyClaimed => f.write_str("任务奖励已领取"), Self::MissingWorkerId => f.write_str("worker_id 不能为空"), + Self::InvalidExternalGenerationAttempt => { + f.write_str("external_generation_job_id 与 claim_attempt 必须成对提供") + } Self::MissingOrderId => f.write_str("recharge.order_id 不能为空"), Self::MissingProductId => f.write_str("recharge.product_id 不能为空"), Self::MissingProductTitle => f.write_str("recharge.product_title 不能为空"), diff --git a/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs b/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs index 1077b5b0c..4573466b9 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs @@ -117,6 +117,35 @@ impl From } } +impl From + for RuntimeProfileWalletRefundOutboxEnqueueInput +{ + fn from(input: module_runtime::RuntimeProfileWalletRefundOutboxEnqueueInput) -> Self { + Self { + owner_user_id: input.owner_user_id, + amount: input.amount, + refund_ledger_id: input.refund_ledger_id, + created_at_micros: input.created_at_micros, + asset_kind: input.asset_kind, + asset_id: input.asset_id, + settlement_reason: input.settlement_reason, + external_generation_job_id: input.external_generation_job_id, + external_generation_claim_attempt: input.external_generation_claim_attempt, + } + } +} + +impl From + for RuntimeProfileWalletRefundOutboxProcessInput +{ + fn from(input: module_runtime::RuntimeProfileWalletRefundOutboxProcessInput) -> Self { + Self { + worker_id: input.worker_id, + limit: input.limit, + } + } +} + impl From for RuntimeProfileRechargeOrderGetInput { @@ -677,6 +706,24 @@ pub(crate) fn map_runtime_profile_wallet_adjustment_procedure_result( )) } +pub(crate) fn map_runtime_profile_wallet_refund_outbox_procedure_result( + result: RuntimeProfileWalletRefundOutboxProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + Ok( + module_runtime::RuntimeProfileWalletRefundOutboxProcedureResult { + ok: true, + enqueued_count: result.enqueued_count, + processed_count: result.processed_count, + retry_count: result.retry_count, + failed_count: result.failed_count, + error_message: result.error_message, + }, + ) +} + pub(crate) fn map_runtime_profile_recharge_center_procedure_result( result: RuntimeProfileRechargeCenterProcedureResult, ) -> Result { diff --git a/server-rs/crates/spacetime-client/src/active/runtime.rs b/server-rs/crates/spacetime-client/src/active/runtime.rs index f11c2cb89..119393fb1 100644 --- a/server-rs/crates/spacetime-client/src/active/runtime.rs +++ b/server-rs/crates/spacetime-client/src/active/runtime.rs @@ -312,6 +312,66 @@ impl SpacetimeClient { .await } + pub async fn enqueue_profile_wallet_refund_outbox( + &self, + input: module_runtime::RuntimeProfileWalletRefundOutboxEnqueueInput, + ) -> Result + { + let procedure_input: RuntimeProfileWalletRefundOutboxEnqueueInput = input.into(); + self.call_after_connect( + "enqueue_profile_wallet_refund_outbox_and_return", + move |connection, sender| { + connection + .procedures() + .enqueue_profile_wallet_refund_outbox_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then( + map_runtime_profile_wallet_refund_outbox_procedure_result, + ); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn process_profile_wallet_refund_outbox( + &self, + worker_id: String, + limit: u32, + ) -> Result + { + let procedure_input = + module_runtime::build_runtime_profile_wallet_refund_outbox_process_input( + worker_id, limit, + ) + .map_err(SpacetimeClientError::validation_failed)? + .into(); + self.call_after_connect( + "process_profile_wallet_refund_outbox_and_return", + move |connection, sender| { + connection + .procedures() + .process_profile_wallet_refund_outbox_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then( + map_runtime_profile_wallet_refund_outbox_procedure_result, + ); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + pub async fn get_profile_recharge_center( &self, user_id: String, diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index f808362ee..a8a42efb0 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -381,6 +381,7 @@ pub mod editor_spritesheet_slice_batch_persist_result_type; pub mod editor_spritesheet_slice_persist_item_input_type; pub mod editor_spritesheet_slice_persisted_item_type; pub mod enqueue_external_generation_job_and_return_procedure; +pub mod enqueue_profile_wallet_refund_outbox_and_return_procedure; pub mod ensure_analytics_date_dimension_for_date_reducer; pub mod expire_profile_recharge_order_timer_reducer; pub mod export_auth_store_projection_from_tables_procedure; @@ -518,6 +519,7 @@ pub mod preflight_editor_generation_target_and_return_procedure; pub mod preflight_editor_pixel_art_result_and_return_procedure; pub mod prepare_profile_recharge_refund_hold_and_return_procedure; pub mod preview_profile_recharge_refund_hold_and_return_procedure; +pub mod process_profile_wallet_refund_outbox_and_return_procedure; pub mod profile_code_operation_table; pub mod profile_code_operation_type; pub mod profile_daily_free_points_table; @@ -572,6 +574,8 @@ pub mod profile_wallet_ledger_table; pub mod profile_wallet_ledger_type; pub mod profile_wallet_manual_restriction_table; pub mod profile_wallet_manual_restriction_type; +pub mod profile_wallet_refund_outbox_table; +pub mod profile_wallet_refund_outbox_type; pub mod public_work_like_table; pub mod public_work_like_type; pub mod public_work_play_daily_stat_table; @@ -780,6 +784,9 @@ pub mod runtime_profile_wallet_ledger_procedure_result_type; pub mod runtime_profile_wallet_ledger_source_type_type; pub mod runtime_profile_wallet_manual_restriction_snapshot_type; pub mod runtime_profile_wallet_manual_restriction_upsert_input_type; +pub mod runtime_profile_wallet_refund_outbox_enqueue_input_type; +pub mod runtime_profile_wallet_refund_outbox_procedure_result_type; +pub mod runtime_profile_wallet_refund_outbox_process_input_type; pub mod runtime_referral_invite_center_get_input_type; pub mod runtime_referral_invite_center_procedure_result_type; pub mod runtime_referral_invite_center_snapshot_type; @@ -1243,6 +1250,7 @@ pub use editor_spritesheet_slice_batch_persist_result_type::EditorSpritesheetSli pub use editor_spritesheet_slice_persist_item_input_type::EditorSpritesheetSlicePersistItemInput; pub use editor_spritesheet_slice_persisted_item_type::EditorSpritesheetSlicePersistedItem; pub use enqueue_external_generation_job_and_return_procedure::enqueue_external_generation_job_and_return; +pub use enqueue_profile_wallet_refund_outbox_and_return_procedure::enqueue_profile_wallet_refund_outbox_and_return; pub use ensure_analytics_date_dimension_for_date_reducer::ensure_analytics_date_dimension_for_date; pub use expire_profile_recharge_order_timer_reducer::expire_profile_recharge_order_timer; pub use export_auth_store_projection_from_tables_procedure::export_auth_store_projection_from_tables; @@ -1380,6 +1388,7 @@ pub use preflight_editor_generation_target_and_return_procedure::preflight_edito pub use preflight_editor_pixel_art_result_and_return_procedure::preflight_editor_pixel_art_result_and_return; pub use prepare_profile_recharge_refund_hold_and_return_procedure::prepare_profile_recharge_refund_hold_and_return; pub use preview_profile_recharge_refund_hold_and_return_procedure::preview_profile_recharge_refund_hold_and_return; +pub use process_profile_wallet_refund_outbox_and_return_procedure::process_profile_wallet_refund_outbox_and_return; pub use profile_code_operation_table::*; pub use profile_code_operation_type::ProfileCodeOperation; pub use profile_daily_free_points_table::*; @@ -1434,6 +1443,8 @@ pub use profile_wallet_ledger_table::*; pub use profile_wallet_ledger_type::ProfileWalletLedger; pub use profile_wallet_manual_restriction_table::*; pub use profile_wallet_manual_restriction_type::ProfileWalletManualRestriction; +pub use profile_wallet_refund_outbox_table::*; +pub use profile_wallet_refund_outbox_type::ProfileWalletRefundOutbox; pub use public_work_like_table::*; pub use public_work_like_type::PublicWorkLike; pub use public_work_play_daily_stat_table::*; @@ -1642,6 +1653,9 @@ pub use runtime_profile_wallet_ledger_procedure_result_type::RuntimeProfileWalle pub use runtime_profile_wallet_ledger_source_type_type::RuntimeProfileWalletLedgerSourceType; pub use runtime_profile_wallet_manual_restriction_snapshot_type::RuntimeProfileWalletManualRestrictionSnapshot; pub use runtime_profile_wallet_manual_restriction_upsert_input_type::RuntimeProfileWalletManualRestrictionUpsertInput; +pub use runtime_profile_wallet_refund_outbox_enqueue_input_type::RuntimeProfileWalletRefundOutboxEnqueueInput; +pub use runtime_profile_wallet_refund_outbox_procedure_result_type::RuntimeProfileWalletRefundOutboxProcedureResult; +pub use runtime_profile_wallet_refund_outbox_process_input_type::RuntimeProfileWalletRefundOutboxProcessInput; pub use runtime_referral_invite_center_get_input_type::RuntimeReferralInviteCenterGetInput; pub use runtime_referral_invite_center_procedure_result_type::RuntimeReferralInviteCenterProcedureResult; pub use runtime_referral_invite_center_snapshot_type::RuntimeReferralInviteCenterSnapshot; @@ -1943,6 +1957,7 @@ pub struct DbUpdate { profile_wallet_consumption_total: __sdk::TableUpdate, profile_wallet_ledger: __sdk::TableUpdate, profile_wallet_manual_restriction: __sdk::TableUpdate, + profile_wallet_refund_outbox: __sdk::TableUpdate, public_work_like: __sdk::TableUpdate, public_work_play_daily_stat: __sdk::TableUpdate, puzzle_agent_message: __sdk::TableUpdate, @@ -2346,6 +2361,9 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { profile_wallet_manual_restriction_table::parse_table_update(table_update)?, ) } + "profile_wallet_refund_outbox" => db_update.profile_wallet_refund_outbox.append( + profile_wallet_refund_outbox_table::parse_table_update(table_update)?, + ), "public_work_like" => db_update .public_work_like .append(public_work_like_table::parse_table_update(table_update)?), @@ -3036,6 +3054,12 @@ impl __sdk::DbUpdate for DbUpdate { &self.profile_wallet_manual_restriction, ) .with_updates_by_pk(|row| &row.user_id); + diff.profile_wallet_refund_outbox = cache + .apply_diff_to_table::( + "profile_wallet_refund_outbox", + &self.profile_wallet_refund_outbox, + ) + .with_updates_by_pk(|row| &row.refund_ledger_id); diff.public_work_like = cache .apply_diff_to_table::("public_work_like", &self.public_work_like) .with_updates_by_pk(|row| &row.like_id); @@ -3531,6 +3555,9 @@ impl __sdk::DbUpdate for DbUpdate { "profile_wallet_manual_restriction" => db_update .profile_wallet_manual_restriction .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "profile_wallet_refund_outbox" => db_update + .profile_wallet_refund_outbox + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "public_work_like" => db_update .public_work_like .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -3961,6 +3988,9 @@ impl __sdk::DbUpdate for DbUpdate { "profile_wallet_manual_restriction" => db_update .profile_wallet_manual_restriction .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "profile_wallet_refund_outbox" => db_update + .profile_wallet_refund_outbox + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "public_work_like" => db_update .public_work_like .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -4202,6 +4232,7 @@ pub struct AppliedDiff<'r> { profile_wallet_consumption_total: __sdk::TableAppliedDiff<'r, ProfileWalletConsumptionTotal>, profile_wallet_ledger: __sdk::TableAppliedDiff<'r, ProfileWalletLedger>, profile_wallet_manual_restriction: __sdk::TableAppliedDiff<'r, ProfileWalletManualRestriction>, + profile_wallet_refund_outbox: __sdk::TableAppliedDiff<'r, ProfileWalletRefundOutbox>, public_work_like: __sdk::TableAppliedDiff<'r, PublicWorkLike>, public_work_play_daily_stat: __sdk::TableAppliedDiff<'r, PublicWorkPlayDailyStat>, puzzle_agent_message: __sdk::TableAppliedDiff<'r, PuzzleAgentMessageRow>, @@ -4743,6 +4774,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.profile_wallet_manual_restriction, event, ); + callbacks.invoke_table_row_callbacks::( + "profile_wallet_refund_outbox", + &self.profile_wallet_refund_outbox, + event, + ); callbacks.invoke_table_row_callbacks::( "public_work_like", &self.public_work_like, @@ -5690,6 +5726,7 @@ impl __sdk::SpacetimeModule for RemoteModule { profile_wallet_consumption_total_table::register_table(client_cache); profile_wallet_ledger_table::register_table(client_cache); profile_wallet_manual_restriction_table::register_table(client_cache); + profile_wallet_refund_outbox_table::register_table(client_cache); public_work_like_table::register_table(client_cache); public_work_play_daily_stat_table::register_table(client_cache); puzzle_agent_message_table::register_table(client_cache); @@ -5831,6 +5868,7 @@ impl __sdk::SpacetimeModule for RemoteModule { "profile_wallet_consumption_total", "profile_wallet_ledger", "profile_wallet_manual_restriction", + "profile_wallet_refund_outbox", "public_work_like", "public_work_play_daily_stat", "puzzle_agent_message", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/enqueue_profile_wallet_refund_outbox_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/enqueue_profile_wallet_refund_outbox_and_return_procedure.rs new file mode 100644 index 000000000..4f5f28c68 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/enqueue_profile_wallet_refund_outbox_and_return_procedure.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_wallet_refund_outbox_enqueue_input_type::RuntimeProfileWalletRefundOutboxEnqueueInput; +use super::runtime_profile_wallet_refund_outbox_procedure_result_type::RuntimeProfileWalletRefundOutboxProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct EnqueueProfileWalletRefundOutboxAndReturnArgs { + pub input: RuntimeProfileWalletRefundOutboxEnqueueInput, +} + +impl __sdk::InModule for EnqueueProfileWalletRefundOutboxAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `enqueue_profile_wallet_refund_outbox_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait enqueue_profile_wallet_refund_outbox_and_return { + fn enqueue_profile_wallet_refund_outbox_and_return( + &self, + input: RuntimeProfileWalletRefundOutboxEnqueueInput, + ) { + self.enqueue_profile_wallet_refund_outbox_and_return_then(input, |_, _| {}); + } + + fn enqueue_profile_wallet_refund_outbox_and_return_then( + &self, + input: RuntimeProfileWalletRefundOutboxEnqueueInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl enqueue_profile_wallet_refund_outbox_and_return for super::RemoteProcedures { + fn enqueue_profile_wallet_refund_outbox_and_return_then( + &self, + input: RuntimeProfileWalletRefundOutboxEnqueueInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeProfileWalletRefundOutboxProcedureResult>( + "enqueue_profile_wallet_refund_outbox_and_return", + EnqueueProfileWalletRefundOutboxAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/process_profile_wallet_refund_outbox_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/process_profile_wallet_refund_outbox_and_return_procedure.rs new file mode 100644 index 000000000..8b6f64ccd --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/process_profile_wallet_refund_outbox_and_return_procedure.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_wallet_refund_outbox_procedure_result_type::RuntimeProfileWalletRefundOutboxProcedureResult; +use super::runtime_profile_wallet_refund_outbox_process_input_type::RuntimeProfileWalletRefundOutboxProcessInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ProcessProfileWalletRefundOutboxAndReturnArgs { + pub input: RuntimeProfileWalletRefundOutboxProcessInput, +} + +impl __sdk::InModule for ProcessProfileWalletRefundOutboxAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `process_profile_wallet_refund_outbox_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait process_profile_wallet_refund_outbox_and_return { + fn process_profile_wallet_refund_outbox_and_return( + &self, + input: RuntimeProfileWalletRefundOutboxProcessInput, + ) { + self.process_profile_wallet_refund_outbox_and_return_then(input, |_, _| {}); + } + + fn process_profile_wallet_refund_outbox_and_return_then( + &self, + input: RuntimeProfileWalletRefundOutboxProcessInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl process_profile_wallet_refund_outbox_and_return for super::RemoteProcedures { + fn process_profile_wallet_refund_outbox_and_return_then( + &self, + input: RuntimeProfileWalletRefundOutboxProcessInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeProfileWalletRefundOutboxProcedureResult>( + "process_profile_wallet_refund_outbox_and_return", + ProcessProfileWalletRefundOutboxAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_wallet_refund_outbox_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_wallet_refund_outbox_table.rs new file mode 100644 index 000000000..6f6233437 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_wallet_refund_outbox_table.rs @@ -0,0 +1,235 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::profile_wallet_refund_outbox_type::ProfileWalletRefundOutbox; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `profile_wallet_refund_outbox`. +/// +/// Obtain a handle from the [`ProfileWalletRefundOutboxTableAccess::profile_wallet_refund_outbox`] method on [`super::RemoteTables`], +/// like `ctx.db.profile_wallet_refund_outbox()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.profile_wallet_refund_outbox().on_insert(...)`. +pub struct ProfileWalletRefundOutboxTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +/// Lifetime-aware accessor marker for the table `profile_wallet_refund_outbox`. +pub struct ProfileWalletRefundOutboxTableAccessor; + +impl __sdk::TableAccessor for ProfileWalletRefundOutboxTableAccessor { + type Row = ProfileWalletRefundOutbox; + type Handle<'db> = ProfileWalletRefundOutboxTableHandle<'db>; + + fn get<'db>(db: &'db super::RemoteTables) -> Self::Handle<'db> { + db.profile_wallet_refund_outbox() + } +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `profile_wallet_refund_outbox`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait ProfileWalletRefundOutboxTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`ProfileWalletRefundOutboxTableHandle`], which mediates access to the table `profile_wallet_refund_outbox`. + fn profile_wallet_refund_outbox(&self) -> ProfileWalletRefundOutboxTableHandle<'_>; +} + +impl ProfileWalletRefundOutboxTableAccess for super::RemoteTables { + fn profile_wallet_refund_outbox(&self) -> ProfileWalletRefundOutboxTableHandle<'_> { + ProfileWalletRefundOutboxTableHandle { + imp: self + .imp + .get_table::("profile_wallet_refund_outbox"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct ProfileWalletRefundOutboxInsertCallbackId(__sdk::CallbackId); +pub struct ProfileWalletRefundOutboxDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableLike for ProfileWalletRefundOutboxTableHandle<'ctx> { + type Row = ProfileWalletRefundOutbox; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } +} + +impl<'ctx> __sdk::Table for ProfileWalletRefundOutboxTableHandle<'ctx> { + type Row = ProfileWalletRefundOutbox; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = ProfileWalletRefundOutboxInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ProfileWalletRefundOutboxInsertCallbackId { + ProfileWalletRefundOutboxInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: ProfileWalletRefundOutboxInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = ProfileWalletRefundOutboxDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ProfileWalletRefundOutboxDeleteCallbackId { + ProfileWalletRefundOutboxDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: ProfileWalletRefundOutboxDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +impl<'ctx> __sdk::WithInsert for ProfileWalletRefundOutboxTableHandle<'ctx> { + type InsertCallbackId = ProfileWalletRefundOutboxInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ProfileWalletRefundOutboxInsertCallbackId { + ProfileWalletRefundOutboxInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: ProfileWalletRefundOutboxInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } +} + +impl<'ctx> __sdk::WithDelete for ProfileWalletRefundOutboxTableHandle<'ctx> { + type DeleteCallbackId = ProfileWalletRefundOutboxDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ProfileWalletRefundOutboxDeleteCallbackId { + ProfileWalletRefundOutboxDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: ProfileWalletRefundOutboxDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct ProfileWalletRefundOutboxUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for ProfileWalletRefundOutboxTableHandle<'ctx> { + type UpdateCallbackId = ProfileWalletRefundOutboxUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> ProfileWalletRefundOutboxUpdateCallbackId { + ProfileWalletRefundOutboxUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: ProfileWalletRefundOutboxUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +impl<'ctx> __sdk::WithUpdate for ProfileWalletRefundOutboxTableHandle<'ctx> { + type UpdateCallbackId = ProfileWalletRefundOutboxUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> ProfileWalletRefundOutboxUpdateCallbackId { + ProfileWalletRefundOutboxUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: ProfileWalletRefundOutboxUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `refund_ledger_id` unique index on the table `profile_wallet_refund_outbox`, +/// which allows point queries on the field of the same name +/// via the [`ProfileWalletRefundOutboxRefundLedgerIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.profile_wallet_refund_outbox().refund_ledger_id().find(...)`. +pub struct ProfileWalletRefundOutboxRefundLedgerIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> ProfileWalletRefundOutboxTableHandle<'ctx> { + /// Get a handle on the `refund_ledger_id` unique index on the table `profile_wallet_refund_outbox`. + pub fn refund_ledger_id(&self) -> ProfileWalletRefundOutboxRefundLedgerIdUnique<'ctx> { + ProfileWalletRefundOutboxRefundLedgerIdUnique { + imp: self.imp.get_unique_constraint::("refund_ledger_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> ProfileWalletRefundOutboxRefundLedgerIdUnique<'ctx> { + /// Find the subscribed row whose `refund_ledger_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = + client_cache.get_or_make_table::("profile_wallet_refund_outbox"); + _table.add_unique_constraint::("refund_ledger_id", |row| &row.refund_ledger_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `ProfileWalletRefundOutbox`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait profile_wallet_refund_outboxQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `ProfileWalletRefundOutbox`. + fn profile_wallet_refund_outbox( + &self, + ) -> __sdk::__query_builder::Table; +} + +impl profile_wallet_refund_outboxQueryTableAccess for __sdk::QueryTableAccessor { + fn profile_wallet_refund_outbox( + &self, + ) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("profile_wallet_refund_outbox") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_wallet_refund_outbox_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_wallet_refund_outbox_type.rs new file mode 100644 index 000000000..f2c38e984 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_wallet_refund_outbox_type.rs @@ -0,0 +1,103 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ProfileWalletRefundOutbox { + pub refund_ledger_id: String, + pub consume_ledger_id: String, + pub owner_user_id: String, + pub amount: u64, + pub created_at: __sdk::Timestamp, + pub asset_kind: String, + pub asset_id: String, + pub settlement_reason: String, + pub external_generation_job_id: Option, + pub external_generation_claim_attempt: Option, + pub status: String, + pub available_at: __sdk::Timestamp, + pub attempts: u32, + pub last_error: Option, + pub last_attempted_at: Option<__sdk::Timestamp>, + pub last_worker_id: Option, +} + +impl __sdk::InModule for ProfileWalletRefundOutbox { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `ProfileWalletRefundOutbox`. +/// +/// Provides typed access to columns for query building. +pub struct ProfileWalletRefundOutboxCols { + pub refund_ledger_id: __sdk::__query_builder::Col, + pub consume_ledger_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub amount: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, + pub asset_kind: __sdk::__query_builder::Col, + pub asset_id: __sdk::__query_builder::Col, + pub settlement_reason: __sdk::__query_builder::Col, + pub external_generation_job_id: + __sdk::__query_builder::Col>, + pub external_generation_claim_attempt: + __sdk::__query_builder::Col>, + pub status: __sdk::__query_builder::Col, + pub available_at: __sdk::__query_builder::Col, + pub attempts: __sdk::__query_builder::Col, + pub last_error: __sdk::__query_builder::Col>, + pub last_attempted_at: + __sdk::__query_builder::Col>, + pub last_worker_id: __sdk::__query_builder::Col>, +} + +impl __sdk::__query_builder::HasCols for ProfileWalletRefundOutbox { + type Cols = ProfileWalletRefundOutboxCols; + fn cols(table_name: &'static str) -> Self::Cols { + ProfileWalletRefundOutboxCols { + refund_ledger_id: __sdk::__query_builder::Col::new(table_name, "refund_ledger_id"), + consume_ledger_id: __sdk::__query_builder::Col::new(table_name, "consume_ledger_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + amount: __sdk::__query_builder::Col::new(table_name, "amount"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + asset_kind: __sdk::__query_builder::Col::new(table_name, "asset_kind"), + asset_id: __sdk::__query_builder::Col::new(table_name, "asset_id"), + settlement_reason: __sdk::__query_builder::Col::new(table_name, "settlement_reason"), + external_generation_job_id: __sdk::__query_builder::Col::new( + table_name, + "external_generation_job_id", + ), + external_generation_claim_attempt: __sdk::__query_builder::Col::new( + table_name, + "external_generation_claim_attempt", + ), + status: __sdk::__query_builder::Col::new(table_name, "status"), + available_at: __sdk::__query_builder::Col::new(table_name, "available_at"), + attempts: __sdk::__query_builder::Col::new(table_name, "attempts"), + last_error: __sdk::__query_builder::Col::new(table_name, "last_error"), + last_attempted_at: __sdk::__query_builder::Col::new(table_name, "last_attempted_at"), + last_worker_id: __sdk::__query_builder::Col::new(table_name, "last_worker_id"), + } + } +} + +/// Indexed column accessor struct for the table `ProfileWalletRefundOutbox`. +/// +/// Provides typed access to indexed columns for query building. +pub struct ProfileWalletRefundOutboxIxCols { + pub refund_ledger_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for ProfileWalletRefundOutbox { + type IxCols = ProfileWalletRefundOutboxIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + ProfileWalletRefundOutboxIxCols { + refund_ledger_id: __sdk::__query_builder::IxCol::new(table_name, "refund_ledger_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for ProfileWalletRefundOutbox {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_refund_outbox_enqueue_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_refund_outbox_enqueue_input_type.rs new file mode 100644 index 000000000..14e090000 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_refund_outbox_enqueue_input_type.rs @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileWalletRefundOutboxEnqueueInput { + pub owner_user_id: String, + pub amount: u64, + pub refund_ledger_id: String, + pub created_at_micros: i64, + pub asset_kind: String, + pub asset_id: String, + pub settlement_reason: String, + pub external_generation_job_id: Option, + pub external_generation_claim_attempt: Option, +} + +impl __sdk::InModule for RuntimeProfileWalletRefundOutboxEnqueueInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_refund_outbox_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_refund_outbox_procedure_result_type.rs new file mode 100644 index 000000000..dae26bc0d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_refund_outbox_procedure_result_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileWalletRefundOutboxProcedureResult { + pub ok: bool, + pub enqueued_count: u32, + pub processed_count: u32, + pub retry_count: u32, + pub failed_count: u32, + pub error_message: Option, +} + +impl __sdk::InModule for RuntimeProfileWalletRefundOutboxProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_refund_outbox_process_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_refund_outbox_process_input_type.rs new file mode 100644 index 000000000..1c3f25e7c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_refund_outbox_process_input_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileWalletRefundOutboxProcessInput { + pub worker_id: String, + pub limit: u32, +} + +impl __sdk::InModule for RuntimeProfileWalletRefundOutboxProcessInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-module/src/external_generation.rs b/server-rs/crates/spacetime-module/src/external_generation.rs index 9cfa4fa13..547c4282c 100644 --- a/server-rs/crates/spacetime-module/src/external_generation.rs +++ b/server-rs/crates/spacetime-module/src/external_generation.rs @@ -888,6 +888,8 @@ fn finalize_external_generation_job_after_lease_exhaustion( row.price_mud_points, failed_at, "final_attempt_lease_expired", + &row.job_kind, + &row.source_entity_id, )?; let row = mark_external_generation_job_lease_exhausted(row, failed_at, refund_ledger_id); persist_external_generation_job_row(ctx, row.clone()); @@ -1437,6 +1439,8 @@ fn fail_external_generation_job_tx( row.price_mud_points, failed_at, "worker_attempt_failed", + &row.job_kind, + &row.source_entity_id, )?; let requested_refund_ledger_id = input .refund_ledger_id diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 4b6993bf7..32e02063c 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -199,6 +199,7 @@ macro_rules! migration_tables { profile_wallet_ledger, profile_wallet_consumption_total, asset_operation_wallet_settlement, + profile_wallet_refund_outbox, profile_wallet_config, analytics_date_dimension, tracking_event, diff --git a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs index e53d8e6b9..e3610969e 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -19,6 +19,8 @@ const PROFILE_RECHARGE_ORDER_EXPIRATION_CHECK_LIMIT_DEFAULT: u32 = 50; const PROFILE_RECHARGE_ORDER_EXPIRATION_CHECK_LIMIT_MAX: u32 = 200; const ASSET_OPERATION_CONSUME_LEDGER_PREFIX: &str = "asset_operation_consume:"; const ASSET_OPERATION_REFUND_LEDGER_PREFIX: &str = "asset_operation_refund:"; +const PROFILE_WALLET_REFUND_OUTBOX_STATUS_PENDING: &str = "pending"; +const PROFILE_WALLET_REFUND_OUTBOX_MAX_BATCH_SIZE: u32 = 100; #[spacetimedb::table(accessor = profile_dashboard_state)] pub struct ProfileDashboardState { @@ -82,6 +84,34 @@ pub struct AssetOperationWalletSettlement { pub(crate) settled_at: Timestamp, } +#[spacetimedb::table( + accessor = profile_wallet_refund_outbox, + index( + accessor = by_profile_wallet_refund_outbox_status_available, + btree(columns = [status, available_at]) + ) +)] +#[derive(Clone)] +pub struct ProfileWalletRefundOutbox { + #[primary_key] + pub(crate) refund_ledger_id: String, + pub(crate) consume_ledger_id: String, + pub(crate) owner_user_id: String, + pub(crate) amount: u64, + pub(crate) created_at: Timestamp, + pub(crate) asset_kind: String, + pub(crate) asset_id: String, + pub(crate) settlement_reason: String, + pub(crate) external_generation_job_id: Option, + pub(crate) external_generation_claim_attempt: Option, + pub(crate) status: String, + pub(crate) available_at: Timestamp, + pub(crate) attempts: u32, + pub(crate) last_error: Option, + pub(crate) last_attempted_at: Option, + pub(crate) last_worker_id: Option, +} + #[spacetimedb::table(accessor = profile_wallet_config)] #[derive(Clone)] pub struct ProfileWalletConfig { @@ -1159,6 +1189,82 @@ pub fn refund_profile_wallet_points_and_return( } } +#[spacetimedb::procedure] +pub fn enqueue_profile_wallet_refund_outbox_and_return( + ctx: &mut ProcedureContext, + input: RuntimeProfileWalletRefundOutboxEnqueueInput, +) -> RuntimeProfileWalletRefundOutboxProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + enqueue_profile_wallet_refund_outbox_tx( + tx, + input.owner_user_id.clone(), + input.amount, + input.refund_ledger_id.clone(), + input.created_at_micros, + input.asset_kind.clone(), + input.asset_id.clone(), + input.settlement_reason.clone(), + input.external_generation_job_id.clone(), + input.external_generation_claim_attempt, + ) + .map(|enqueued| u32::from(enqueued)) + }) { + Ok(enqueued_count) => RuntimeProfileWalletRefundOutboxProcedureResult { + ok: true, + enqueued_count, + processed_count: 0, + retry_count: 0, + failed_count: 0, + error_message: None, + }, + Err(message) => RuntimeProfileWalletRefundOutboxProcedureResult { + ok: false, + enqueued_count: 0, + processed_count: 0, + retry_count: 0, + failed_count: 0, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn process_profile_wallet_refund_outbox_and_return( + ctx: &mut ProcedureContext, + input: RuntimeProfileWalletRefundOutboxProcessInput, +) -> RuntimeProfileWalletRefundOutboxProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + process_profile_wallet_refund_outbox_tx(tx, input.clone()) + }) { + Ok((processed_count, retry_count, failed_count)) => { + RuntimeProfileWalletRefundOutboxProcedureResult { + ok: true, + enqueued_count: 0, + processed_count, + retry_count, + failed_count, + error_message: None, + } + } + Err(message) => RuntimeProfileWalletRefundOutboxProcedureResult { + ok: false, + enqueued_count: 0, + processed_count: 0, + retry_count: 0, + failed_count: 0, + error_message: Some(message), + }, + } +} + // play stats 与 dashboard 共用 dashboard projection 的 total_play_time / updated_at,避免 Axum 侧拼装。 #[cfg(any())] #[spacetimedb::procedure] @@ -9125,6 +9231,254 @@ pub(crate) fn grant_profile_wallet_points_with_metadata( ) } +pub(crate) fn enqueue_profile_wallet_refund_outbox_tx( + ctx: &ReducerContext, + owner_user_id: String, + amount: u64, + refund_ledger_id: String, + created_at_micros: i64, + asset_kind: String, + asset_id: String, + settlement_reason: String, + external_generation_job_id: Option, + external_generation_claim_attempt: Option, +) -> Result { + let owner_user_id = owner_user_id.trim().to_string(); + if owner_user_id.is_empty() { + return Err("资产操作退款用户不能为空".to_string()); + } + if amount == 0 { + return Err("资产操作退款金额必须大于 0".to_string()); + } + let refund_ledger_id = refund_ledger_id.trim().to_string(); + let consume_ledger_id = asset_operation_consume_ledger_id(&refund_ledger_id)?; + let asset_kind = asset_kind.trim().to_string(); + if asset_kind.is_empty() { + return Err("资产操作退款 asset_kind 不能为空".to_string()); + } + let asset_id = asset_id.trim().to_string(); + if asset_id.is_empty() { + return Err("资产操作退款 asset_id 不能为空".to_string()); + } + let settlement_reason = settlement_reason.trim().to_string(); + if settlement_reason.is_empty() { + return Err("资产操作退款 settlement_reason 不能为空".to_string()); + } + let external_generation_job_id = normalize_optional_text(external_generation_job_id); + if external_generation_job_id.is_some() != external_generation_claim_attempt.is_some() { + return Err("资产操作退款 outbox 任务与 attempt 必须成对提供".to_string()); + } + let created_at = Timestamp::from_micros_since_unix_epoch(created_at_micros); + let disposition = resolve_asset_operation_refund_disposition_from_ledger( + ctx, + owner_user_id.as_str(), + i64::try_from(amount).map_err(|_| "资产操作退款金额超出范围".to_string())?, + refund_ledger_id.as_str(), + )?; + + if matches!(disposition, AssetOperationRefundDisposition::Noop) { + if let Some(existing) = ctx + .db + .profile_wallet_refund_outbox() + .refund_ledger_id() + .find(&refund_ledger_id) + { + validate_profile_wallet_refund_outbox_fact( + &existing, + &refund_ledger_id, + &consume_ledger_id, + &owner_user_id, + amount, + &asset_kind, + &asset_id, + &settlement_reason, + external_generation_job_id.as_deref(), + external_generation_claim_attempt, + )?; + ctx.db + .profile_wallet_refund_outbox() + .refund_ledger_id() + .delete(&refund_ledger_id); + } + return Ok(false); + } + if matches!(disposition, AssetOperationRefundDisposition::RecordIntent) { + record_asset_operation_wallet_settlement( + ctx, + owner_user_id.as_str(), + i64::try_from(amount).map_err(|_| "资产操作退款金额超出范围".to_string())?, + refund_ledger_id.as_str(), + created_at, + )?; + // consume 尚不可见时,settlement 是取消 intent;不应再创建会被 worker + // 误认为可退款的 outbox 行。迟到 consume 会在同一 resolver 中被拒绝。 + return Ok(false); + } + + let existing = ctx + .db + .profile_wallet_refund_outbox() + .refund_ledger_id() + .find(&refund_ledger_id); + if let Some(existing) = existing { + validate_profile_wallet_refund_outbox_fact( + &existing, + &refund_ledger_id, + &consume_ledger_id, + &owner_user_id, + amount, + &asset_kind, + &asset_id, + &settlement_reason, + external_generation_job_id.as_deref(), + external_generation_claim_attempt, + )?; + let mut existing = existing; + existing.status = PROFILE_WALLET_REFUND_OUTBOX_STATUS_PENDING.to_string(); + existing.available_at = ctx.timestamp; + existing.last_error = None; + ctx.db + .profile_wallet_refund_outbox() + .refund_ledger_id() + .update(existing); + return Ok(false); + } + + ctx.db + .profile_wallet_refund_outbox() + .insert(ProfileWalletRefundOutbox { + refund_ledger_id, + consume_ledger_id, + owner_user_id, + amount, + created_at, + asset_kind, + asset_id, + settlement_reason, + external_generation_job_id, + external_generation_claim_attempt, + status: PROFILE_WALLET_REFUND_OUTBOX_STATUS_PENDING.to_string(), + available_at: ctx.timestamp, + attempts: 0, + last_error: None, + last_attempted_at: None, + last_worker_id: None, + }); + Ok(true) +} + +fn validate_profile_wallet_refund_outbox_fact( + row: &ProfileWalletRefundOutbox, + expected_refund_ledger_id: &str, + expected_consume_ledger_id: &str, + expected_owner_user_id: &str, + expected_amount: u64, + expected_asset_kind: &str, + expected_asset_id: &str, + expected_settlement_reason: &str, + expected_external_generation_job_id: Option<&str>, + expected_external_generation_claim_attempt: Option, +) -> Result<(), String> { + if row.refund_ledger_id != expected_refund_ledger_id + || row.consume_ledger_id != expected_consume_ledger_id + || row.owner_user_id != expected_owner_user_id + || row.amount != expected_amount + { + return Err("资产操作退款 outbox 金融事实不匹配".to_string()); + } + if row.asset_kind != expected_asset_kind || row.asset_id != expected_asset_id { + return Err("资产操作退款 outbox 资源事实不匹配".to_string()); + } + if row.settlement_reason != expected_settlement_reason { + return Err("资产操作退款 outbox settlement_reason 不匹配".to_string()); + } + if row.external_generation_job_id.as_deref() != expected_external_generation_job_id + || row.external_generation_claim_attempt != expected_external_generation_claim_attempt + { + return Err("资产操作退款 outbox 任务 attempt 不匹配".to_string()); + } + Ok(()) +} + +fn process_profile_wallet_refund_outbox_tx( + ctx: &ReducerContext, + input: RuntimeProfileWalletRefundOutboxProcessInput, +) -> Result<(u32, u32, u32), String> { + let worker_id = input.worker_id.trim(); + if worker_id.is_empty() { + return Err("退款 outbox worker_id 不能为空".to_string()); + } + let limit = input + .limit + .clamp(1, PROFILE_WALLET_REFUND_OUTBOX_MAX_BATCH_SIZE); + let now = ctx.timestamp; + let mut rows = ctx + .db + .profile_wallet_refund_outbox() + .iter() + .filter(|row| { + row.status == PROFILE_WALLET_REFUND_OUTBOX_STATUS_PENDING && row.available_at <= now + }) + .collect::>(); + rows.sort_by(|left, right| { + left.available_at + .cmp(&right.available_at) + .then_with(|| left.created_at.cmp(&right.created_at)) + .then_with(|| left.refund_ledger_id.cmp(&right.refund_ledger_id)) + }); + + let mut processed_count: u32 = 0; + let mut retry_count: u32 = 0; + let mut failed_count: u32 = 0; + for mut row in rows.into_iter().take(limit as usize) { + row.attempts = row.attempts.saturating_add(1); + row.last_attempted_at = Some(now); + row.last_worker_id = Some(worker_id.to_string()); + let metadata_json = serde_json::json!({ + "externalGenerationJobId": &row.external_generation_job_id, + "externalGenerationClaimAttempt": row.external_generation_claim_attempt, + "assetKind": &row.asset_kind, + "assetId": &row.asset_id, + "settlementReason": &row.settlement_reason, + "refundOutboxWorkerId": worker_id, + }) + .to_string(); + let result = apply_profile_wallet_adjustment( + ctx, + RuntimeProfileWalletAdjustmentInput { + user_id: row.owner_user_id.clone(), + amount: row.amount, + ledger_id: row.refund_ledger_id.clone(), + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + metadata_json, + }, + RuntimeProfileWalletLedgerSourceType::AssetOperationRefund, + false, + ); + match result { + Ok(_) => { + ctx.db + .profile_wallet_refund_outbox() + .refund_ledger_id() + .delete(&row.refund_ledger_id); + processed_count = processed_count.saturating_add(1); + } + Err(error) => { + row.available_at = + now + std::time::Duration::from_secs(2u64.saturating_pow(row.attempts.min(10))); + row.last_error = Some(error); + ctx.db + .profile_wallet_refund_outbox() + .refund_ledger_id() + .update(row); + retry_count = retry_count.saturating_add(1); + failed_count = failed_count.saturating_add(1); + } + } + } + Ok((processed_count, retry_count, failed_count)) +} + fn apply_profile_wallet_adjustment( ctx: &ReducerContext, input: RuntimeProfileWalletAdjustmentInput, @@ -9242,6 +9596,8 @@ pub(crate) fn settle_external_generation_attempt_refund( amount: u64, settled_at: Timestamp, settlement_reason: &str, + asset_kind: &str, + asset_id: &str, ) -> Result, String> { if amount == 0 { return Ok(None); @@ -9250,22 +9606,17 @@ pub(crate) fn settle_external_generation_attempt_refund( "{ASSET_OPERATION_REFUND_LEDGER_PREFIX}external_generation_job:{}:attempt:{attempt}", job_id.trim() ); - apply_profile_wallet_adjustment( + enqueue_profile_wallet_refund_outbox_tx( ctx, - RuntimeProfileWalletAdjustmentInput { - user_id: user_id.to_string(), - amount, - ledger_id: refund_ledger_id.clone(), - created_at_micros: settled_at.to_micros_since_unix_epoch(), - metadata_json: serde_json::json!({ - "externalGenerationJobId": job_id.trim(), - "claimAttempt": attempt, - "settlementReason": settlement_reason, - }) - .to_string(), - }, - RuntimeProfileWalletLedgerSourceType::AssetOperationRefund, - false, + user_id.to_string(), + amount, + refund_ledger_id.clone(), + settled_at.to_micros_since_unix_epoch(), + asset_kind.to_string(), + asset_id.to_string(), + settlement_reason.to_string(), + Some(job_id.trim().to_string()), + Some(attempt), )?; Ok(Some(refund_ledger_id)) } -- 2.52.0 From a0bf2ba3746b13a2a0add6c666eaf03512cdb608 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 27 Aug 2026 16:58:21 +0800 Subject: [PATCH 4/9] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=80=80=E6=AC=BE=20outb?= =?UTF-8?q?ox=20=E5=B9=82=E7=AD=89=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 允许同一退款 ledger 在不同恢复路径使用不同诊断 reason 兼容旧本机 spool 文件推断缺失的外部生成 attempt 补充跨路径幂等与历史文件恢复测试 --- .../api-server/src/wallet_refund_outbox.rs | 22 ++++++++- .../src/runtime/active/profile.rs | 48 ++++++++++++++++--- 2 files changed, 63 insertions(+), 7 deletions(-) diff --git a/server-rs/crates/api-server/src/wallet_refund_outbox.rs b/server-rs/crates/api-server/src/wallet_refund_outbox.rs index b260ad1c3..bb2232a52 100644 --- a/server-rs/crates/api-server/src/wallet_refund_outbox.rs +++ b/server-rs/crates/api-server/src/wallet_refund_outbox.rs @@ -329,7 +329,9 @@ impl WalletRefundOutbox { record.asset_id.clone(), record.settlement_reason.clone(), record.external_generation_job_id.clone(), - record.external_generation_claim_attempt, + record + .external_generation_claim_attempt + .or_else(|| infer_external_generation_claim_attempt(&record)), ) .map_err(|error| { WalletRefundOutboxError::Spacetime(SpacetimeClientError::Runtime( @@ -575,6 +577,15 @@ fn default_settlement_reason() -> String { "emergency_spool_replay".to_string() } +fn infer_external_generation_claim_attempt(record: &WalletRefundOutboxRecord) -> Option { + let job_id = record.external_generation_job_id.as_deref()?.trim(); + let prefix = format!("asset_operation_refund:external_generation_job:{job_id}:attempt:"); + record + .ledger_id + .strip_prefix(&prefix) + .and_then(|value| value.parse::().ok()) +} + impl fmt::Debug for WalletRefundOutbox { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("WalletRefundOutbox") @@ -765,6 +776,15 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[test] + fn legacy_external_refund_record_infers_missing_claim_attempt() { + let mut record = + sample_record("asset_operation_refund:external_generation_job:extgen-test:attempt:7"); + record.external_generation_claim_attempt = None; + + assert_eq!(infer_external_generation_claim_attempt(&record), Some(7)); + } + #[tokio::test] async fn enqueue_drops_when_outbox_exceeds_max_bytes() { let dir = test_dir("max-bytes"); diff --git a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs index e3610969e..e553a58e4 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -2933,6 +2933,45 @@ mod tests { ); } + #[test] + fn refund_outbox_idempotency_allows_recovery_reason_to_change() { + let row = ProfileWalletRefundOutbox { + refund_ledger_id: "asset_operation_refund:external_generation_job:job-1:attempt:1" + .to_string(), + consume_ledger_id: "asset_operation_consume:external_generation_job:job-1:attempt:1" + .to_string(), + owner_user_id: "user-1".to_string(), + amount: 37, + created_at: Timestamp::from_micros_since_unix_epoch(1), + asset_kind: "editor-image".to_string(), + asset_id: "asset-1".to_string(), + settlement_reason: "worker_attempt_failed".to_string(), + external_generation_job_id: Some("job-1".to_string()), + external_generation_claim_attempt: Some(1), + status: PROFILE_WALLET_REFUND_OUTBOX_STATUS_PENDING.to_string(), + available_at: Timestamp::from_micros_since_unix_epoch(1), + attempts: 0, + last_error: None, + last_attempted_at: None, + last_worker_id: None, + }; + + assert!( + validate_profile_wallet_refund_outbox_fact( + &row, + &row.refund_ledger_id, + &row.consume_ledger_id, + &row.owner_user_id, + row.amount, + &row.asset_kind, + &row.asset_id, + Some("job-1"), + Some(1), + ) + .is_ok() + ); + } + #[test] fn wallet_idempotent_replay_requires_matching_user_amount_and_source() { let existing = asset_operation_wallet_ledger( @@ -9291,7 +9330,6 @@ pub(crate) fn enqueue_profile_wallet_refund_outbox_tx( amount, &asset_kind, &asset_id, - &settlement_reason, external_generation_job_id.as_deref(), external_generation_claim_attempt, )?; @@ -9329,7 +9367,6 @@ pub(crate) fn enqueue_profile_wallet_refund_outbox_tx( amount, &asset_kind, &asset_id, - &settlement_reason, external_generation_job_id.as_deref(), external_generation_claim_attempt, )?; @@ -9375,7 +9412,6 @@ fn validate_profile_wallet_refund_outbox_fact( expected_amount: u64, expected_asset_kind: &str, expected_asset_id: &str, - expected_settlement_reason: &str, expected_external_generation_job_id: Option<&str>, expected_external_generation_claim_attempt: Option, ) -> Result<(), String> { @@ -9389,9 +9425,9 @@ fn validate_profile_wallet_refund_outbox_fact( if row.asset_kind != expected_asset_kind || row.asset_id != expected_asset_id { return Err("资产操作退款 outbox 资源事实不匹配".to_string()); } - if row.settlement_reason != expected_settlement_reason { - return Err("资产操作退款 outbox settlement_reason 不匹配".to_string()); - } + // settlement_reason is diagnostic context, not an idempotency fact. A failed + // attempt can be observed first by its failure transaction and later by stale + // attempt recovery, which legitimately use different reason labels. if row.external_generation_job_id.as_deref() != expected_external_generation_job_id || row.external_generation_claim_attempt != expected_external_generation_claim_attempt { -- 2.52.0 From 6e9948ac9e9521dd3b871693610694202ee244b4 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 27 Aug 2026 19:11:23 +0800 Subject: [PATCH 5/9] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=AE=A4=E8=AF=81?= =?UTF-8?q?=E7=9F=AD=E7=8A=B6=E6=80=81=E4=B8=8E=E9=80=80=E6=AC=BE=20outbox?= =?UTF-8?q?=20=E8=B7=A8=E8=8A=82=E7=82=B9=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将短信验证码与微信 OAuth state 纳入 SpacetimeDB typed projection 并支持 CAS 只读刷新 为退款 emergency spool 增加 overflow 文件与崩溃恢复,避免容量上限静默丢失 为 API 与 external worker 配置独立 tracking/refund 持久卷 使用 profile refund outbox 的 status/available_at 索引扫描并同步架构运维文档 --- deploy/container/docker-compose.loadtest.yml | 7 + .../shared-memory/decision-log.md | 16 ++ ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 7 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 4 +- .../crates/api-server/src/asset_billing.rs | 4 +- .../api-server/src/password_management.rs | 15 +- server-rs/crates/api-server/src/phone_auth.rs | 58 ++++- server-rs/crates/api-server/src/state.rs | 45 +++- .../api-server/src/wallet_refund_outbox.rs | 205 +++++++++------ .../crates/api-server/src/wechat/auth.rs | 85 ++++++- server-rs/crates/module-auth/src/domain.rs | 33 ++- server-rs/crates/module-auth/src/lib.rs | 238 ++++++++++++++++-- .../src/active/mapper/auth.rs | 62 +++++ .../spacetime-client/src/mapper/auth.rs | 58 +++++ .../spacetime-client/src/module_bindings.rs | 4 + .../auth_store_projection_meta_type.rs | 6 + .../auth_store_projection_phone_code_type.rs | 21 ++ .../auth_store_projection_view_type.rs | 4 + ...auth_store_projection_wechat_state_type.rs | 24 ++ .../spacetime-module/src/auth/procedures.rs | 72 +++++- .../spacetime-module/src/auth/tables.rs | 4 + .../src/runtime/active/profile.rs | 7 +- 22 files changed, 850 insertions(+), 129 deletions(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_phone_code_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_wechat_state_type.rs diff --git a/deploy/container/docker-compose.loadtest.yml b/deploy/container/docker-compose.loadtest.yml index c2b78c73b..cdb7aed22 100644 --- a/deploy/container/docker-compose.loadtest.yml +++ b/deploy/container/docker-compose.loadtest.yml @@ -53,6 +53,7 @@ services: - "host.docker.internal:host-gateway" volumes: - api-tracking-outbox:/var/lib/genarrative/tracking-outbox + - api-wallet-refund-outbox:/var/lib/genarrative/wallet-refund-outbox ulimits: nofile: soft: 4096 @@ -85,6 +86,9 @@ services: OTEL_SERVICE_NAME: genarrative-external-generation-worker extra_hosts: - "host.docker.internal:host-gateway" + volumes: + - external-generation-tracking-outbox:/var/lib/genarrative/tracking-outbox-worker + - external-generation-wallet-refund-outbox:/var/lib/genarrative/wallet-refund-outbox ulimits: nofile: soft: 4096 @@ -142,4 +146,7 @@ services: volumes: spacetime-data: api-tracking-outbox: + api-wallet-refund-outbox: + external-generation-tracking-outbox: + external-generation-wallet-refund-outbox: nginx-logs: diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index a155d0931..a2e9ad552 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,22 @@ --- +## 2026-08-27 退款 emergency spool 容量溢出保持可恢复 + +- 背景:本机 emergency spool 仅作为 SpacetimeDB 完全不可达时的最后恢复路径,原有 `MAX_BYTES` 分支会直接返回 `Dropped`,导致扣费已经完成但没有可重放记录。 +- 决策:达到普通 outbox `MAX_BYTES` 时,将退款记录写入同一持久目录的 `refund-overflow-*` 文件;该文件与普通 pending 文件一样由启动恢复和后台 worker 重放到 SpacetimeDB,且按 refund ledger id 保持幂等。溢出文件不计入普通阈值,但必须触发容量告警;底层磁盘写入失败仍进入关键退款人工补偿流程。 +- 影响范围:api-server wallet refund emergency spool、资产失败退款日志、loadtest / 预览 Compose 持久卷、后端架构与开发运维文档。 +- 验证方式:运行 api-server `wallet_refund_outbox` 定向测试,确认超限写入并保留 overflow 文件;运行 SpacetimeDB profile 测试、Compose 配置校验、编码和 diff 门禁。 + +## 2026-08-27 短期认证状态进入共享 typed projection + +- 背景:短信验证码和微信 OAuth state 仍只存在 API 进程内 HashMap,多节点请求或 API 重启会直接丢失,无法满足无粘性会话的鉴权恢复要求。 +- 决策:`AuthStoreProjectionView` 增加 `phone_codes` 与 `wechat_states` typed 字段,由 `auth_store_projection_meta` 以 JSON 投影持久化;启动恢复、CAS 同步和失败后的权威刷新都覆盖这两类短期状态。验证码哈希使用部署级稳定盐(当前复用 `GENARRATIVE_JWT_SECRET`),各 API 节点必须一致;认证 handler 在发码、消费验证码、创建/消费微信 state 后都要完成 projection sync,失败即返回服务错误;短期状态首次未命中时允许从正式投影做一次受 CAS 保护的只读刷新,再重试读取,不能依赖粘性会话。短期状态仍由 `module-auth` 内存工作集执行领域校验,但不再把本机 HashMap 当作持久化或跨节点真相。 +- 影响范围:`module-auth` projection、`spacetime-module` auth schema/procedure、`spacetime-client` bindings/facade、api-server 手机号 / 微信 handler、认证架构与运维文档。 +- 验证方式:运行 module-auth projection roundtrip(验证码可跨恢复校验、微信 state 可跨恢复消费)、SpacetimeDB schema/runtime/DDD 门禁、api-server 定向测试、编码和 diff 检查。 + +--- + ## 2026-08-27 SpacetimeDB 工具链统一升级到 2.8.3 - 背景:SpacetimeDB 2.8.0 引入 TypeScript submodule 与调度延迟观测,2.8.1 修复 v1 WebSocket 订阅移除死锁、TypeScript SDK `array` 读缓存别名和 Rust string 默认值支持,2.8.2 修复 table accessor 改名自动迁移,2.8.3 修复 scheduled function 从实际执行时间重排导致的长期漂移。仓库若继续锁定 2.7.0,会保留这些已知运行时与 SDK 问题。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 707e834c8..b3086ed82 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -412,10 +412,11 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - Rust 结构体:`AuthStoreProjectionMeta` - 源码:`server-rs/crates/spacetime-module/src/auth/tables.rs` +- 职责:保存 typed 认证投影的单调版本,以及短期手机号验证码和微信 OAuth state 的序列化投影;`phone_codes_json` / `wechat_states_json` 只承载短期认证状态,不替代 `user_account`、`auth_identity` 或 `refresh_session` 的正式表语义。 -认证恢复策略:`api-server` 启动时只从 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)导出 typed `AuthStoreProjectionView`,再恢复 `module-auth` 的进程内认证工作集;生产 Bearer 中间件不再从 `InMemoryAuthStore` 读取用户或会话,而是每次通过 typed `validate_auth_session` procedure 在 SpacetimeDB 事务内校验 `token_version`、会话归属、撤销时间和过期时间,SpacetimeDB 不可用时 fail closed 返回服务错误。`validate_auth_session`、投影导出和投影同步均从 `ctx.sender()` 派生调用方,并复用现役 runtime service identity 白名单;启动恢复先完成该服务身份初始化,普通 SpacetimeDB identity 不能读取或改写私有认证表。测试构建仍可使用显式的内存测试夹具。运行中 refresh cookie 在本进程工作集内未命中时直接按失效处理,不再从 SpacetimeDB 导出整包认证状态刷新内存,避免旧投影把重复手机号或旧会话重新灌回进程。`module-auth` 只保留内存工作集和 projection 导入 / 导出能力,不再保留 JSON 快照导入 / 导出能力,也不写本地持久化文件;`auth-store.json` / `GENARRATIVE_AUTH_STORE_PATH` 不再是兼容恢复源。认证创建、登录会话、刷新、退出、改密、重置密码、绑定和资料变更等写操作仍必须在返回客户端前通过 `sync_auth_store_projection` 成功同步 SpacetimeDB 正式认证表;同步失败时接口返回错误,不允许把只存在于当前进程内存的账号或会话当成成功结果。每个 API 工作集绑定启动恢复或上次成功同步得到的 `auth_store_projection_meta.updated_at` 版本作为 `base_updated_at_micros`,SpacetimeDB 在同一事务内执行基线 CAS,并要求新的 `updated_at_micros` 严格递增;基线不一致或版本不晚于当前值时整包写入失败,冲突节点在确认本次同步前没有遗留未确认本地变更且同步期间没有其它本地认证变更后,才可丢弃本地工作集并从正式表恢复,不能用陈旧工作集删除、恢复或覆盖另一节点的新状态;若此前已有待重试 revision 或同期还有本地变更则保留旧基线并继续失败关闭,不用自动恢复覆盖未提交变更;同步成功但期间又出现新本地变更时最多连续补同步三轮,仍未稳定则失败关闭。这只是迁移期并发保护,不改变正式认证表的权威地位。新用户注册奖励、邀请码绑定和登录埋点必须排在认证同步成功之后,避免认证没落库时先写出钱包或邀请关系。若启动恢复阶段 SpacetimeDB 不可连接或超时,`api-server` 会按固定间隔持续重试认证工作集恢复,恢复成功后才开始监听 HTTP,避免一次短超时让进程永久停留在依赖不可用状态。 +认证恢复策略:`api-server` 启动时从 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)以及 `auth_store_projection_meta` 中的短期状态投影导出 typed `AuthStoreProjectionView`,再恢复 `module-auth` 的进程内认证工作集;生产 Bearer 中间件不再从 `InMemoryAuthStore` 读取用户或会话,而是每次通过 typed `validate_auth_session` procedure 在 SpacetimeDB 事务内校验 `token_version`、会话归属、撤销时间和过期时间,SpacetimeDB 不可用时 fail closed 返回服务错误。`validate_auth_session`、投影导出和投影同步均从 `ctx.sender()` 派生调用方,并复用现役 runtime service identity 白名单;启动恢复先完成该服务身份初始化,普通 SpacetimeDB identity 不能读取或改写私有认证表。测试构建仍可使用显式的内存测试夹具。短期认证请求在本进程工作集内未命中且工作集没有待同步变更时,先对正式投影做一次受 CAS 保护的只读刷新,再决定验证码 / OAuth state 是否失效,不依赖粘性会话;refresh cookie 仍只按正式 `refresh_session` 校验,其他认证数据也不得绕过正式同步。`module-auth` 只保留内存工作集和 projection 导入 / 导出能力,不再保留 JSON 快照导入 / 导出能力,也不写本地持久化文件;`auth-store.json` / `GENARRATIVE_AUTH_STORE_PATH` 不再是兼容恢复源。认证创建、登录会话、刷新、退出、改密、重置密码、绑定和资料变更等写操作仍必须在返回客户端前通过 `sync_auth_store_projection` 成功同步 SpacetimeDB 正式认证表;同步失败时接口返回错误,不允许把只存在于当前进程内存的账号、会话、短信验证码或微信 state 当成成功结果。每个 API 工作集绑定启动恢复或上次成功同步得到的 `auth_store_projection_meta.updated_at` 版本作为 `base_updated_at_micros`,SpacetimeDB 在同一事务内执行基线 CAS,并要求新的 `updated_at_micros` 严格递增;基线不一致或版本不晚于当前值时整包写入失败,冲突节点在确认本次同步前没有遗留未确认本地变更且同步期间没有其它本地认证变更后,才可丢弃本地工作集并从正式表恢复,不能用陈旧工作集删除、恢复或覆盖另一节点的新状态;若此前已有待重试 revision 或同期还有本地变更则保留旧基线并继续失败关闭,不用自动恢复覆盖未提交变更;同步成功但期间又出现新本地变更时最多连续补同步三轮,仍未稳定则失败关闭。这只是迁移期并发保护,不改变正式认证表的权威地位。新用户注册奖励、邀请码绑定和登录埋点必须排在认证同步成功之后,避免认证没落库时先写出钱包或邀请关系。若启动恢复阶段 SpacetimeDB 不可连接或超时,`api-server` 会按固定间隔持续重试认证工作集恢复,恢复成功后才开始监听 HTTP,避免一次短超时让进程永久停留在依赖不可用状态。 -`auth_store_snapshot` 表和旧 `import_auth_store_snapshot_json` / `export_auth_store_snapshot_from_tables` procedure 已删除。认证投影同步只读写 `user_account`、`auth_identity`、`refresh_session` 和 `auth_store_projection_meta`;`auth_identity` 不再写 `phone_e164`、`display_name`、`avatar_url`,这些账号资料只以 `user_account` 为准。 +`auth_store_snapshot` 表和旧 `import_auth_store_snapshot_json` / `export_auth_store_snapshot_from_tables` procedure 已删除。认证投影同步只读写 `user_account`、`auth_identity`、`refresh_session` 和 `auth_store_projection_meta`;`auth_identity` 不再写 `phone_e164`、`display_name`、`avatar_url`,这些账号资料只以 `user_account` 为准。`api-server` 多节点必须使用相同的部署级验证码哈希盐(当前复用 `GENARRATIVE_JWT_SECRET`);轮换该 secret 会使尚未消费的短信验证码失效,但不会改变已持久化账号或 session。 ### `bark_battle_draft_config` @@ -1061,7 +1062,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - Rust 结构体:`ProfileWalletRefundOutbox` - 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` -- 说明:跨节点资产退款的正式 pending 队列。主键为 refund ledger ID,保存 consume/refund 配对、用户、金额、资源、生成任务 attempt、失败原因和重试时间;失败事务先写入该表,worker 在 SpacetimeDB 事务内幂等执行钱包退款并删除成功行。只有数据库不可达时,api-server 才使用本机 `wallet-refund-outbox` emergency spool。 +- 说明:跨节点资产退款的正式 pending 队列。主键为 refund ledger ID,保存 consume/refund 配对、用户、金额、资源、生成任务 attempt、失败原因和重试时间;失败事务先写入该表,worker 在 SpacetimeDB 事务内幂等执行钱包退款并删除成功行。只有数据库不可达时,api-server 才使用本机 `wallet-refund-outbox` emergency spool;本机 `MAX_BYTES` 达到阈值时改写入同目录 `refund-overflow-*` 溢出文件,保持可恢复而不静默丢弃。 - 索引:`(status, available_at)`。 ### `profile_wallet_config` diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 9691ad80d..cb42ae393 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -855,7 +855,7 @@ GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS=5000 outbox 采用 NDJSON 文件保存原始事件。达到 `BATCH_SIZE` 时会立刻把当前 active 文件原子封存为 sealed 文件,并马上切到新的 active 继续写入;后台 worker 异步 flush sealed 文件,HTTP 请求线程不等待 SpacetimeDB。worker 启动时会先封存并 flush 已存在的 active / sealed 文件,恢复窗口内 SpacetimeDB 暂不可用则保留文件并按后续周期重试;`FLUSH_INTERVAL_MS` 只负责兜底封存长时间未满批的 active 文件。SpacetimeDB 批量 procedure 返回成功后删除 sealed 文件,失败则保留文件并重试。`MAX_BYTES` 是每个 outbox 实例的磁盘保护阈值,不是 flush 阈值;超过后低价值 route tracking 和 BgFilter provider 失败审计可以被丢弃并记录日志 / 指标,关键同步事件不进入该丢弃路径。api-server 使用配置目录本身,BgFilter worker 固定使用其 `bgfilter-worker/` 子目录,两个进程不得操作同一个 active 文件。sealed 文件若出现无法解析的坏行,会重命名为 `corrupt-*` 隔离并记录 `genarrative.tracking_outbox.files.corrupt` 指标,避免一个坏文件阻塞后续批量入库。进程收到退出信号后会在 `GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS` 窗口内封存各自 active 文件并尽力 flush sealed 文件,超时或 SpacetimeDB 暂不可用时保留本地文件给下次同角色启动继续投递。该机制对已 enqueue 记录提供至少一次投递语义,依赖 `tracking_event.event_id` 幂等跳过重复事件;BgFilter 尚未 enqueue 或因硬上限 / 保护阈值被丢弃的审计不在该保证内。 -钱包退款正式 pending 队列在 SpacetimeDB 的 `profile_wallet_refund_outbox` 表中,由每个 API 节点的 worker 共同处理;worker 启动即扫描库内 pending 行,成功在同一事务内写钱包账本并删除 outbox 行,失败按库内 `available_at` / `attempts` 重试。只有 SpacetimeDB 完全不可达时才写本机 `wallet-refund-outbox` emergency spool;如果进程在“临时文件写完但尚未改名”阶段崩溃,启动恢复会校验 `tmp-*` 内容并原子提升为按 ledger id 命名的 pending 文件,损坏或冲突文件移入 `corrupt-*` 隔离目录,不会静默丢失关键退款。worker 连接失败、库内 retry、emergency spool 写入 / 容量失败和 `corrupt-*` 出现都必须接入告警;人工补偿先按 refund ledger id 对账 `profile_wallet_ledger`、`asset_operation_wallet_settlement` 与两类 outbox,再通过受控退款 procedure 幂等重放,禁止直接手写钱包表。该目录不能替代库内 outbox;发布和主机替换必须保留 `/var/lib/genarrative/wallet-refund-outbox` 并纳入节点恢复 / 备份演练。 +钱包退款正式 pending 队列在 SpacetimeDB 的 `profile_wallet_refund_outbox` 表中,由每个 API 节点的 worker 共同处理;worker 启动即扫描库内 pending 行,成功在同一事务内写钱包账本并删除 outbox 行,失败按库内 `available_at` / `attempts` 重试。只有 SpacetimeDB 完全不可达时才写本机 `wallet-refund-outbox` emergency spool;如果进程在“临时文件写完但尚未改名”阶段崩溃,启动恢复会校验 `tmp-*` 内容并原子提升为按 ledger id 命名的 pending 文件,损坏或冲突文件移入 `corrupt-*` 隔离目录。达到 `MAX_BYTES` 时不再静默丢弃退款,而是写入同一持久目录下的 `refund-overflow-*` 溢出文件并继续重放;溢出文件不计入普通容量阈值,但必须接入容量告警和人工补偿预案,底层磁盘写入失败仍按关键退款告警处理。worker 连接失败、库内 retry、emergency spool 写入 / 容量失败和 `corrupt-*` 出现都必须接入告警;人工补偿先按 refund ledger id 对账 `profile_wallet_ledger`、`asset_operation_wallet_settlement` 与两类 outbox,再通过受控退款 procedure 幂等重放,禁止直接手写钱包表。该目录不能替代库内 outbox;发布和主机替换必须保留 `/var/lib/genarrative/wallet-refund-outbox` 并纳入节点恢复 / 备份演练。容器 loadtest / 预览环境必须分别为 `api-server` 与 `external-generation-worker` 挂载各自的 tracking 与 wallet refund 命名卷,不能让节点重建清空本机恢复队列。 release 机器如果日志每秒刷 `tracking outbox ... Permission denied (os error 13)`,先检查 `/etc/genarrative/api-server.env` 是否缺少 `GENARRATIVE_TRACKING_OUTBOX_DIR`。缺少时 `api-server` 会回退到本地开发默认相对路径 `server-rs/.data/tracking-outbox`,而 systemd 的工作目录是只读发布目录 `/opt/genarrative/releases/`,`genarrative` 用户无法在其中创建 `server-rs`。修复顺序: @@ -866,7 +866,7 @@ systemctl restart genarrative-api.service journalctl -u genarrative-api.service --since '30 seconds ago' --no-pager | grep -E 'tracking outbox|Permission denied|os error 13' ``` -`Genarrative-Server-Provision` 和 `Genarrative-Api-Deploy` 会在保留旧 `/etc/genarrative/api-server.env` 的前提下补齐缺失的 tracking outbox 运行态路径,并确保 `/var/lib/genarrative/tracking-outbox` 归属 `genarrative:genarrative`。用户认证真相源只允许在 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)恢复;不要再配置或依赖 `GENARRATIVE_AUTH_STORE_PATH` / `auth-store.json`,`module-auth` 也不再维护本地文件持久化;`auth_store_snapshot` 不再作为备查或运行期恢复源,只在正式认证表为空时一次性转移最新旧快照并清空,且旧 `get_auth_store_snapshot` / `upsert_auth_store_snapshot` / `import_auth_store_snapshot` 入口已经删除。如果 `api-server` 启动时连不上 SpacetimeDB,会持续重试启动恢复,直到认证工作集从 SpacetimeDB 正式表恢复成功后才开始监听 HTTP,以避免用空本地状态或旧快照覆盖认证表。 +`Genarrative-Server-Provision` 和 `Genarrative-Api-Deploy` 会在保留旧 `/etc/genarrative/api-server.env` 的前提下补齐缺失的 tracking outbox 运行态路径,并确保 `/var/lib/genarrative/tracking-outbox` 归属 `genarrative:genarrative`。用户认证真相源只允许从 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)和 `auth_store_projection_meta` 中的短期验证码 / 微信 state 投影恢复;请求首次未命中短期状态时会从正式投影做一次只读刷新,不依赖粘性会话。不要再配置或依赖 `GENARRATIVE_AUTH_STORE_PATH` / `auth-store.json`,`module-auth` 也不再维护本地文件持久化;`auth_store_snapshot` 不再作为备查或运行期恢复源,只在正式认证表为空时一次性转移最新旧快照并清空,且旧 `get_auth_store_snapshot` / `upsert_auth_store_snapshot` / `import_auth_store_snapshot` 入口已经删除。所有 API 节点必须使用相同的 `GENARRATIVE_JWT_SECRET`,它也作为验证码哈希盐;轮换后尚未消费的验证码会失效。如果 `api-server` 启动时连不上 SpacetimeDB,会持续重试启动恢复,直到认证工作集从 SpacetimeDB 正式表和短期投影恢复成功后才开始监听 HTTP,以避免用空本地状态或旧快照覆盖认证表。 前端登录态恢复只把 `/api/auth/refresh` 的 `401` / `403` 当成权威失效信号;服务器重启窗口里的 `502` / `503` / `504`、浏览器 `Failed to fetch` 或 refresh 响应契约异常都必须保留已有本地 access token,不触发全局 auth 变化。refresh 成功响应以共享契约 `RefreshSessionResponse { token }` 为准,前端不要额外要求业务 `ok` 字段。排查“重启后用户都掉线”时,先区分前端是否被暂时不可用清掉本地 token,再检查 SpacetimeDB 正式认证表是否缺 `user_account` / `refresh_session` 数据。 diff --git a/server-rs/crates/api-server/src/asset_billing.rs b/server-rs/crates/api-server/src/asset_billing.rs index 83545d528..e0ce18d6d 100644 --- a/server-rs/crates/api-server/src/asset_billing.rs +++ b/server-rs/crates/api-server/src/asset_billing.rs @@ -579,7 +579,7 @@ async fn refund_asset_operation_points_with_job_id( "SpacetimeDB refund outbox 不可达,已写入本机 emergency spool" ); } - Ok(WalletRefundOutboxEnqueueOutcome::Dropped { reason }) => { + Ok(WalletRefundOutboxEnqueueOutcome::OverflowEnqueued { reason }) => { tracing::error!( owner_user_id, asset_kind, @@ -588,7 +588,7 @@ async fn refund_asset_operation_points_with_job_id( ledger_id, reason, error = %refund_error, - "SpacetimeDB refund outbox 不可达,且本机 emergency spool 因容量限制丢弃" + "SpacetimeDB refund outbox 不可达,退款已写入本机 emergency spool overflow 文件;需监控并尽快恢复库内队列" ); } Err(outbox_error) => { diff --git a/server-rs/crates/api-server/src/password_management.rs b/server-rs/crates/api-server/src/password_management.rs index 70f620791..38a0e40ab 100644 --- a/server-rs/crates/api-server/src/password_management.rs +++ b/server-rs/crates/api-server/src/password_management.rs @@ -9,6 +9,7 @@ use shared_contracts::auth::{ PasswordChangeRequest, PasswordChangeResponse, PasswordResetRequest, PasswordResetResponse, }; use time::OffsetDateTime; +use tracing::warn; use crate::{ api_response::json_success_body, @@ -81,7 +82,7 @@ pub async fn reset_password( ); } - let result = state + let result = match state .phone_auth_service() .reset_password( ResetPasswordInput { @@ -93,7 +94,17 @@ pub async fn reset_password( OffsetDateTime::now_utc(), ) .await - .map_err(map_phone_auth_error)?; + { + Ok(result) => result, + Err(error) => { + if let Err(sync_error) = state.sync_auth_store_tables_to_spacetime().await { + warn!(error = %sync_error, "重置密码失败后的短信验证码状态同步失败"); + return Err(AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message("同步短信验证码状态失败")); + } + return Err(map_phone_auth_error(error)); + } + }; let session_client = resolve_session_client_context(&headers); let signed_session = create_auth_session( &state, diff --git a/server-rs/crates/api-server/src/phone_auth.rs b/server-rs/crates/api-server/src/phone_auth.rs index a0e9cd753..02237d372 100644 --- a/server-rs/crates/api-server/src/phone_auth.rs +++ b/server-rs/crates/api-server/src/phone_auth.rs @@ -91,6 +91,14 @@ pub async fn send_phone_code( } }; + state + .sync_auth_store_tables_to_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("同步短信验证码状态失败:{error}")) + })?; + Ok(json_success_body( Some(&request_context), PhoneSendCodeResponse { @@ -115,18 +123,36 @@ pub async fn phone_login( ); } let invite_code = payload.invite_code.clone(); - let result = match state + let login_input = PhoneLoginInput { + country_code: payload.country_code, + pure_phone_number: payload.pure_phone_number, + verify_code: payload.code, + }; + let login_now = OffsetDateTime::now_utc(); + let login_result = state .phone_auth_service() - .login( - PhoneLoginInput { - country_code: payload.country_code, - pure_phone_number: payload.pure_phone_number, - verify_code: payload.code, - }, - OffsetDateTime::now_utc(), - ) - .await - { + .login(login_input.clone(), login_now) + .await; + let login_result = match login_result { + Err(PhoneAuthError::VerifyCodeNotFound) => { + if let Err(sync_error) = state.refresh_auth_store_from_spacetime().await { + warn!( + request_id = request_context.request_id(), + operation = request_context.operation(), + error = %sync_error, + "手机号验证码未命中后的认证投影刷新失败" + ); + return Err(AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message("刷新短信验证码状态失败")); + } + state + .phone_auth_service() + .login(login_input, login_now) + .await + } + result => result, + }; + let result = match login_result { Ok(result) => { info!( request_id = request_context.request_id(), @@ -142,6 +168,16 @@ pub async fn phone_login( result } Err(error) => { + if let Err(sync_error) = state.sync_auth_store_tables_to_spacetime().await { + warn!( + request_id = request_context.request_id(), + operation = request_context.operation(), + error = %sync_error, + "手机号验证码登录失败后的状态同步失败" + ); + return Err(AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message("同步短信验证码状态失败")); + } warn!( request_id = request_context.request_id(), operation = request_context.operation(), diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 600a1d0e1..cf9792f80 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -571,7 +571,11 @@ impl AppState { ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE, ) .map_err(|error| AppStateInitError::AuthStore(error.to_string()))?; - let phone_auth_service = PhoneAuthService::new(auth_store.clone(), sms_provider); + let phone_auth_service = PhoneAuthService::new_with_verify_code_salt( + auth_store.clone(), + sms_provider, + config.jwt_secret.clone(), + ); let wechat_auth_state_service = WechatAuthStateService::new(auth_store.clone(), config.wechat_state_ttl_minutes); let wechat_auth_service = WechatAuthService::new(auth_store.clone()); @@ -1402,6 +1406,43 @@ impl AppState { )) } + /// 在只读短期认证状态未命中时,从正式投影刷新一次本地工作集,避免 OAuth / 短信 + /// 请求落到另一节点后必须依赖粘性会话才能成功。 + pub async fn refresh_auth_store_from_spacetime(&self) -> Result<(), SpacetimeClientError> { + #[cfg(test)] + return Ok(()); + + #[cfg(not(test))] + { + let _sync_guard = self.auth_projection_sync_lock.lock().await; + let expected_revision = self.auth_store.revision(); + if self.auth_projection_synced_revision.load(Ordering::Acquire) != expected_revision { + return Err(SpacetimeClientError::Runtime( + "认证工作集存在待同步变更,跳过只读刷新".to_string(), + )); + } + let projection = self + .spacetime_client + .export_auth_store_projection_from_tables() + .await?; + let updated_at_micros = projection.updated_at_micros; + let refreshed = self + .auth_store + .refresh_from_projection_view_if_revision(projection, expected_revision) + .map_err(SpacetimeClientError::Runtime)?; + if !refreshed { + return Err(SpacetimeClientError::Runtime( + "认证工作集刷新期间发生并发变更".to_string(), + )); + } + self.auth_projection_version + .store(updated_at_micros, Ordering::Release); + self.auth_projection_synced_revision + .store(self.auth_store.revision(), Ordering::Release); + Ok(()) + } + } + pub async fn try_restore_auth_store_from_spacetime( config: AppConfig, ) -> Result { @@ -1946,6 +1987,8 @@ fn auth_store_candidate_from_projection_view( if projection.users.is_empty() && projection.identities.is_empty() && projection.refresh_sessions.is_empty() + && projection.phone_codes.is_empty() + && projection.wechat_states.is_empty() && projection.updated_at_micros == 0 { return Ok(None); diff --git a/server-rs/crates/api-server/src/wallet_refund_outbox.rs b/server-rs/crates/api-server/src/wallet_refund_outbox.rs index bb2232a52..6d79051ff 100644 --- a/server-rs/crates/api-server/src/wallet_refund_outbox.rs +++ b/server-rs/crates/api-server/src/wallet_refund_outbox.rs @@ -19,6 +19,7 @@ use tracing::{debug, warn}; use crate::config::AppConfig; const PENDING_FILE_PREFIX: &str = "refund-"; +const OVERFLOW_FILE_PREFIX: &str = "refund-overflow-"; const CORRUPT_FILE_PREFIX: &str = "corrupt-"; const TEMP_FILE_PREFIX: &str = "tmp-"; const OUTBOX_FILE_EXTENSION: &str = ".json"; @@ -118,7 +119,7 @@ pub(crate) struct WalletRefundOutboxRecord { #[derive(Debug)] pub enum WalletRefundOutboxEnqueueOutcome { Enqueued, - Dropped { reason: &'static str }, + OverflowEnqueued { reason: &'static str }, } #[derive(Debug)] @@ -153,9 +154,13 @@ impl WalletRefundOutbox { fs::create_dir_all(&self.dir).await?; let pending_path = self.pending_path_for_ledger(&record.ledger_id); + let overflow_path = self.overflow_path_for_ledger(&record.ledger_id); if self .reuse_existing_pending_file(&pending_path, &record) .await? + || self + .reuse_existing_pending_file(&overflow_path, &record) + .await? { self.flush_notify.notify_one(); return Ok(WalletRefundOutboxEnqueueOutcome::Enqueued); @@ -164,11 +169,12 @@ impl WalletRefundOutbox { let bytes = serde_json::to_vec(&record)?; let line_bytes = bytes.len().min(u64::MAX as usize) as u64; let current_bytes = directory_size_if_exists(&self.dir).unwrap_or(0); - if current_bytes.saturating_add(line_bytes) > self.max_bytes { - return Ok(WalletRefundOutboxEnqueueOutcome::Dropped { - reason: "max_bytes", - }); - } + let overflow = current_bytes.saturating_add(line_bytes) > self.max_bytes; + let target_path = if overflow { + &overflow_path + } else { + &pending_path + }; let temp_path = self.temp_path(); let mut file = OpenOptions::new() @@ -183,22 +189,31 @@ impl WalletRefundOutbox { if self .reuse_existing_pending_file(&pending_path, &record) .await? + || self + .reuse_existing_pending_file(&overflow_path, &record) + .await? { let _ = fs::remove_file(&temp_path).await; self.flush_notify.notify_one(); return Ok(WalletRefundOutboxEnqueueOutcome::Enqueued); } for _ in 0..2 { - match fs::hard_link(&temp_path, &pending_path).await { + match fs::hard_link(&temp_path, target_path).await { Ok(()) => { sync_directory_metadata(&self.dir).await?; remove_file_and_sync(&temp_path, &self.dir).await?; self.flush_notify.notify_one(); - return Ok(WalletRefundOutboxEnqueueOutcome::Enqueued); + return Ok(if overflow { + WalletRefundOutboxEnqueueOutcome::OverflowEnqueued { + reason: "max_bytes", + } + } else { + WalletRefundOutboxEnqueueOutcome::Enqueued + }); } Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { if self - .reuse_existing_pending_file(&pending_path, &record) + .reuse_existing_pending_file(target_path, &record) .await? { remove_file_and_sync(&temp_path, &self.dir).await?; @@ -213,7 +228,7 @@ impl WalletRefundOutbox { std::io::ErrorKind::AlreadyExists, format!( "refund pending path could not be installed: {}", - pending_path.display() + target_path.display() ), ) .into()) @@ -369,7 +384,7 @@ impl WalletRefundOutbox { async fn recover_temporary_files(&self) -> Result<(), WalletRefundOutboxError> { let _guard = self.enqueue_lock.lock().await; let temporary_files = self.list_temporary_files().await?; - for path in temporary_files { + 'temporary_files: for path in temporary_files { let record = match read_refund_record(&path).await { Ok(record) => record, Err(error) if error.is_data_corruption() => { @@ -385,82 +400,72 @@ impl WalletRefundOutbox { }; let pending_path = self.pending_path_for_ledger(&record.ledger_id); - match fs::metadata(&pending_path).await { - Ok(metadata) if metadata.is_file() => { - match read_refund_record(&pending_path).await { - Ok(existing) if existing == record => { - remove_file_and_sync(&path, &self.dir).await?; - debug!( - ledger_id = %record.ledger_id, - source = %path.display(), - target = %pending_path.display(), - "wallet refund outbox 临时文件与已有幂等文件重复,已删除临时副本" - ); - continue; - } - Ok(existing) if existing.ledger_id == record.ledger_id => { - self.quarantine_file(&path).await?; - warn!( - ledger_id = %record.ledger_id, - source = %path.display(), - target = %pending_path.display(), - "wallet refund outbox 临时文件与已有幂等文件事实不一致,已隔离临时文件" - ); - continue; - } - Err(error) if error.is_data_corruption() => { - self.quarantine_file(&pending_path).await?; - } - Ok(_) => { - self.quarantine_file(&path).await?; - warn!( - ledger_id = %record.ledger_id, - source = %path.display(), - target = %pending_path.display(), - "wallet refund outbox 临时文件命名冲突,已隔离临时文件" - ); - continue; - } - Err(error) => return Err(error), + let overflow_path = self.overflow_path_for_ledger(&record.ledger_id); + for existing_path in [&pending_path, &overflow_path] { + match self + .reuse_existing_pending_file(existing_path, &record) + .await + { + Ok(true) => { + remove_file_and_sync(&path, &self.dir).await?; + debug!( + ledger_id = %record.ledger_id, + source = %path.display(), + target = %existing_path.display(), + "wallet refund outbox 临时文件与已有幂等文件重复,已删除临时副本" + ); + continue 'temporary_files; } + Ok(false) => {} + Err(WalletRefundOutboxError::Io(error)) + if error.kind() == std::io::ErrorKind::AlreadyExists => + { + self.quarantine_file(&path).await?; + warn!( + ledger_id = %record.ledger_id, + source = %path.display(), + target = %existing_path.display(), + error = %error, + "wallet refund outbox 临时文件与现有幂等文件事实冲突,已隔离临时文件" + ); + continue 'temporary_files; + } + Err(error) => return Err(error), } - Ok(_) => { - return Err(std::io::Error::new( - std::io::ErrorKind::AlreadyExists, - format!( - "refund pending path is not a regular file: {}", - pending_path.display() - ), - ) - .into()); - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => return Err(error.into()), } - match fs::hard_link(&path, &pending_path).await { + let temp_bytes = fs::metadata(&path).await?.len(); + let record_bytes = serde_json::to_vec(&record)?; + let current_bytes = directory_size_if_exists(&self.dir) + .unwrap_or(0) + .saturating_sub(temp_bytes); + let target_path = + if current_bytes.saturating_add(record_bytes.len() as u64) > self.max_bytes { + &overflow_path + } else { + &pending_path + }; + + match fs::hard_link(&path, target_path).await { Ok(()) => { sync_directory_metadata(&self.dir).await?; remove_file_and_sync(&path, &self.dir).await?; debug!( ledger_id = %record.ledger_id, source = %path.display(), - target = %pending_path.display(), + target = %target_path.display(), "wallet refund outbox 崩溃遗留临时文件已恢复为幂等退款文件" ); } Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { - match self - .reuse_existing_pending_file(&pending_path, &record) - .await - { + match self.reuse_existing_pending_file(target_path, &record).await { Ok(true) => { // Another writer won the same ledger id with identical facts. Keep // the first durable file and remove only this duplicate temporary // link. remove_file_and_sync(&path, &self.dir).await?; } - Ok(false) => match fs::hard_link(&path, &pending_path).await { + Ok(false) => match fs::hard_link(&path, target_path).await { Ok(()) => { sync_directory_metadata(&self.dir).await?; remove_file_and_sync(&path, &self.dir).await?; @@ -475,7 +480,7 @@ impl WalletRefundOutbox { warn!( ledger_id = %record.ledger_id, source = %path.display(), - target = %pending_path.display(), + target = %target_path.display(), "wallet refund outbox 临时文件无法与现有幂等文件合并,已隔离临时文件" ); } @@ -488,7 +493,7 @@ impl WalletRefundOutbox { warn!( ledger_id = %record.ledger_id, source = %path.display(), - target = %pending_path.display(), + target = %target_path.display(), error = %error, "wallet refund outbox 临时文件与现有幂等文件事实冲突,已隔离临时文件" ); @@ -537,7 +542,9 @@ impl WalletRefundOutbox { let Some(name) = path.file_name().and_then(|value| value.to_str()) else { continue; }; - if name.starts_with(PENDING_FILE_PREFIX) && name.ends_with(OUTBOX_FILE_EXTENSION) { + if (name.starts_with(PENDING_FILE_PREFIX) || name.starts_with(OVERFLOW_FILE_PREFIX)) + && name.ends_with(OUTBOX_FILE_EXTENSION) + { files.push(path); } } @@ -552,6 +559,13 @@ impl WalletRefundOutbox { )) } + fn overflow_path_for_ledger(&self, ledger_id: &str) -> PathBuf { + self.dir.join(format!( + "{OVERFLOW_FILE_PREFIX}{}{OUTBOX_FILE_EXTENSION}", + ledger_id_hash(ledger_id) + )) + } + fn temp_path(&self) -> PathBuf { self.dir.join(format!( "{TEMP_FILE_PREFIX}{}-{uuid}{OUTBOX_FILE_EXTENSION}", @@ -642,7 +656,7 @@ fn directory_size_if_exists(path: &Path) -> Result { let mut total = 0u64; for entry in std::fs::read_dir(path)? { let entry = entry?; - if !is_pending_outbox_file_name(&entry.file_name()) { + if !is_capped_outbox_file_name(&entry.file_name()) { continue; } let metadata = entry.metadata()?; @@ -666,7 +680,17 @@ fn ledger_id_hash(ledger_id: &str) -> String { fn is_pending_outbox_file_name(name: &std::ffi::OsStr) -> bool { name.to_str().is_some_and(|value| { - (value.starts_with(PENDING_FILE_PREFIX) || value.starts_with(TEMP_FILE_PREFIX)) + (value.starts_with(PENDING_FILE_PREFIX) + || value.starts_with(OVERFLOW_FILE_PREFIX) + || value.starts_with(TEMP_FILE_PREFIX)) + && value.ends_with(OUTBOX_FILE_EXTENSION) + }) +} + +fn is_capped_outbox_file_name(name: &std::ffi::OsStr) -> bool { + name.to_str().is_some_and(|value| { + ((value.starts_with(PENDING_FILE_PREFIX) && !value.starts_with(OVERFLOW_FILE_PREFIX)) + || value.starts_with(TEMP_FILE_PREFIX)) && value.ends_with(OUTBOX_FILE_EXTENSION) }) } @@ -786,7 +810,7 @@ mod tests { } #[tokio::test] - async fn enqueue_drops_when_outbox_exceeds_max_bytes() { + async fn enqueue_uses_durable_overflow_file_when_outbox_exceeds_max_bytes() { let dir = test_dir("max-bytes"); let outbox = test_outbox(dir.clone(), 1); @@ -794,11 +818,18 @@ mod tests { assert!(matches!( outcome, - WalletRefundOutboxEnqueueOutcome::Dropped { + WalletRefundOutboxEnqueueOutcome::OverflowEnqueued { reason: "max_bytes" } )); - assert!(!dir.exists() || std::fs::read_dir(&dir).unwrap().next().is_none()); + assert!(outbox.overflow_path_for_ledger("ledger-1").is_file()); + assert_eq!(directory_size_if_exists(&dir).unwrap(), 0); + assert_eq!( + read_refund_record(&outbox.overflow_path_for_ledger("ledger-1")) + .await + .unwrap(), + sample_record("ledger-1") + ); let _ = std::fs::remove_dir_all(dir); } @@ -899,6 +930,30 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[tokio::test] + async fn flush_recovers_crash_left_temp_file_into_overflow_when_capped() { + let dir = test_dir("recover-temp-overflow"); + std::fs::create_dir_all(&dir).unwrap(); + let outbox = test_outbox(dir.clone(), 1); + let existing = sample_record("ledger-existing"); + std::fs::write( + outbox.pending_path_for_ledger(&existing.ledger_id), + serde_json::to_vec(&existing).unwrap(), + ) + .unwrap(); + let record = sample_record("ledger-temp-overflow"); + let temp_path = outbox.temp_path(); + std::fs::write(&temp_path, serde_json::to_vec(&record).unwrap()).unwrap(); + + let result = outbox.flush_pending_files_once().await; + + assert!(matches!(result, Err(WalletRefundOutboxError::Spacetime(_)))); + assert!(!temp_path.exists()); + assert!(outbox.overflow_path_for_ledger(&record.ledger_id).exists()); + + let _ = std::fs::remove_dir_all(dir); + } + #[tokio::test] async fn flush_quarantines_conflicting_crash_left_temp_file() { let dir = test_dir("recover-conflicting-temp"); diff --git a/server-rs/crates/api-server/src/wechat/auth.rs b/server-rs/crates/api-server/src/wechat/auth.rs index 420438146..d4f26dcd2 100644 --- a/server-rs/crates/api-server/src/wechat/auth.rs +++ b/server-rs/crates/api-server/src/wechat/auth.rs @@ -16,6 +16,7 @@ use shared_contracts::auth::{ }; use shared_kernel::normalize_optional_string; use time::OffsetDateTime; +use tracing::warn; use url::Url; use crate::{ @@ -62,6 +63,13 @@ pub async fn start_wechat_login( OffsetDateTime::now_utc(), ) .map_err(map_wechat_auth_error)?; + state + .sync_auth_store_tables_to_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("同步微信登录状态失败:{error}")) + })?; let authorization_url = state .wechat_provider() .build_authorization_url( @@ -107,6 +115,13 @@ pub async fn start_wechat_bind( OffsetDateTime::now_utc(), ) .map_err(map_wechat_auth_error)?; + state + .sync_auth_store_tables_to_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("同步微信绑定状态失败:{error}")) + })?; let authorization_url = state .wechat_provider() .build_authorization_url( @@ -149,11 +164,45 @@ pub async fn handle_wechat_callback( .into_response()); } - let consumed = match state + let consume_result = state .wechat_auth_state_service() - .consume_state(&state_token, OffsetDateTime::now_utc()) - { + .consume_state(&state_token, OffsetDateTime::now_utc()); + let consumed = match consume_result { Ok(value) => value, + Err(WechatAuthError::StateNotFound) => { + if let Err(error) = state.refresh_auth_store_from_spacetime().await { + warn!( + request_id = request_context.request_id(), + operation = request_context.operation(), + error = %error, + "微信 state 未命中后的认证投影刷新失败" + ); + return Ok(Redirect::to(&build_auth_result_redirect_url( + &fallback_redirect, + &[ + ("auth_provider", "wechat"), + ("auth_error", "微信登录状态已失效,请重新发起登录。"), + ], + )) + .into_response()); + } + match state + .wechat_auth_state_service() + .consume_state(&state_token, OffsetDateTime::now_utc()) + { + Ok(value) => value, + Err(_) => { + return Ok(Redirect::to(&build_auth_result_redirect_url( + &fallback_redirect, + &[ + ("auth_provider", "wechat"), + ("auth_error", "微信登录状态已失效,请重新发起登录。"), + ], + )) + .into_response()); + } + } + } Err(_) => { return Ok(Redirect::to(&build_auth_result_redirect_url( &fallback_redirect, @@ -165,6 +214,22 @@ pub async fn handle_wechat_callback( .into_response()); } }; + if let Err(error) = state.sync_auth_store_tables_to_spacetime().await { + warn!( + request_id = request_context.request_id(), + operation = request_context.operation(), + error = %error, + "微信回调消费 state 后同步失败" + ); + return Ok(Redirect::to(&build_auth_result_redirect_url( + &fallback_redirect, + &[ + ("auth_provider", "wechat"), + ("auth_error", "微信登录服务暂时不可用,请稍后重试。"), + ], + )) + .into_response()); + } let redirect_path = consumed.state.redirect_path.clone(); let session_client = resolve_session_client_context(&headers); @@ -293,7 +358,7 @@ pub async fn bind_wechat_phone( .ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST).with_message("缺少短信验证码") })?; - state + match state .phone_auth_service() .bind_wechat_phone( BindWechatPhoneInput { @@ -306,7 +371,17 @@ pub async fn bind_wechat_phone( OffsetDateTime::now_utc(), ) .await - .map_err(map_wechat_bind_phone_error)? + { + Ok(result) => result, + Err(error) => { + if let Err(sync_error) = state.sync_auth_store_tables_to_spacetime().await { + warn!(error = %sync_error, "微信绑定手机号失败后的短信验证码状态同步失败"); + return Err(AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message("同步短信验证码状态失败")); + } + return Err(map_wechat_bind_phone_error(error)); + } + } }; let session_client = resolve_session_client_context(&headers); let signed_session = create_auth_session( diff --git a/server-rs/crates/module-auth/src/domain.rs b/server-rs/crates/module-auth/src/domain.rs index 90c12cd90..4a29af646 100644 --- a/server-rs/crates/module-auth/src/domain.rs +++ b/server-rs/crates/module-auth/src/domain.rs @@ -81,7 +81,7 @@ pub struct PhoneNumberSnapshot { } /// 手机验证码使用场景。 -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum PhoneAuthScene { Login, BindPhone, @@ -101,7 +101,7 @@ impl PhoneAuthScene { } /// 微信授权入口场景。 -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum WechatAuthScene { Desktop, WechatInApp, @@ -187,6 +187,10 @@ pub struct AuthStoreProjectionView { pub users: Vec, pub identities: Vec, pub refresh_sessions: Vec, + #[serde(default)] + pub phone_codes: Vec, + #[serde(default)] + pub wechat_states: Vec, /// 当前进程工作集所基于的正式投影版本,用于事务内 CAS。 #[serde(default)] pub base_updated_at_micros: i64, @@ -233,6 +237,31 @@ pub struct AuthStoreProjectionRefreshSession { pub last_seen_at: String, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuthStoreProjectionPhoneCode { + pub phone_number: String, + pub scene: String, + pub verify_code_hash: String, + pub expires_at: String, + pub last_sent_at: String, + pub failed_attempts: u32, + pub provider_out_id: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuthStoreProjectionWechatState { + pub wechat_state_id: String, + pub state_token: String, + pub redirect_path: String, + pub scene: String, + pub request_user_agent: Option, + pub bind_user_id: Option, + pub expires_at: String, + pub consumed_at: Option, + pub created_at: String, + pub updated_at: String, +} + pub fn validate_password(password: &str) -> Result<(), PasswordEntryError> { let length = password.chars().count(); if !(PASSWORD_MIN_LENGTH..=PASSWORD_MAX_LENGTH).contains(&length) { diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index a35682b04..586e3eec3 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -31,6 +31,8 @@ use shared_kernel::{ use time::{Duration, OffsetDateTime}; use tracing::{info, warn}; +const DEFAULT_PHONE_VERIFY_CODE_SALT: &str = "genarrative-phone-verify-code-v1"; + #[derive(Clone, Debug)] pub struct InMemoryAuthStore { inner: Arc>, @@ -135,6 +137,24 @@ fn parse_auth_binding_status(value: &str) -> AuthBindingStatus { } } +fn parse_phone_auth_scene(value: &str) -> Option { + match value.trim() { + "login" => Some(PhoneAuthScene::Login), + "bind_phone" => Some(PhoneAuthScene::BindPhone), + "change_phone" => Some(PhoneAuthScene::ChangePhone), + "reset_password" => Some(PhoneAuthScene::ResetPassword), + _ => None, + } +} + +fn parse_wechat_auth_scene(value: &str) -> Option { + match value.trim() { + "desktop" => Some(WechatAuthScene::Desktop), + "wechat_in_app" => Some(WechatAuthScene::WechatInApp), + _ => None, + } +} + fn next_sequence_from_public_user_code(public_user_code: &str) -> u64 { public_user_code .trim() @@ -503,10 +523,19 @@ impl RefreshSessionService { impl PhoneAuthService { pub fn new(store: InMemoryAuthStore, sms_provider: SmsAuthProvider) -> Self { + Self::new_with_verify_code_salt(store, sms_provider, DEFAULT_PHONE_VERIFY_CODE_SALT) + } + + /// 使用部署级稳定盐值构造服务,确保验证码投影恢复到另一节点后仍可校验。 + pub fn new_with_verify_code_salt( + store: InMemoryAuthStore, + sms_provider: SmsAuthProvider, + verify_code_salt: impl Into, + ) -> Self { Self { store, sms_provider, - verify_code_salt: new_uuid_simple_string(), + verify_code_salt: verify_code_salt.into(), } } @@ -1026,6 +1055,8 @@ impl InMemoryAuthStoreState { let mut wechat_identity_by_provider_uid = HashMap::new(); let mut user_id_by_provider_union_id = HashMap::new(); let mut phone_number_by_user_id = HashMap::new(); + let mut phone_codes_by_key = HashMap::new(); + let mut wechat_states_by_token = HashMap::new(); for user in &view.users { if let Some(phone_number) = normalize_optional_string(user.phone_number_e164.clone()) { @@ -1098,6 +1129,46 @@ impl InMemoryAuthStoreState { ); } + for phone_code in view.phone_codes { + let scene = parse_phone_auth_scene(&phone_code.scene) + .ok_or_else(|| format!("未知短信验证码场景:{}", phone_code.scene))?; + let key = build_phone_code_key(&phone_code.phone_number, &scene); + phone_codes_by_key.insert( + key, + StoredPhoneCode { + phone_number: phone_code.phone_number, + scene, + verify_code_hash: phone_code.verify_code_hash, + expires_at: phone_code.expires_at, + last_sent_at: phone_code.last_sent_at, + failed_attempts: phone_code.failed_attempts, + provider_out_id: phone_code.provider_out_id, + }, + ); + } + + for wechat_state in view.wechat_states { + let scene = parse_wechat_auth_scene(&wechat_state.scene) + .ok_or_else(|| format!("未知微信授权 state 场景:{}", wechat_state.scene))?; + wechat_states_by_token.insert( + wechat_state.state_token.clone(), + StoredWechatAuthState { + state: WechatAuthStateRecord { + wechat_state_id: wechat_state.wechat_state_id, + state_token: wechat_state.state_token, + redirect_path: wechat_state.redirect_path, + scene, + request_user_agent: wechat_state.request_user_agent, + bind_user_id: wechat_state.bind_user_id, + expires_at: wechat_state.expires_at, + consumed_at: wechat_state.consumed_at, + created_at: wechat_state.created_at, + updated_at: wechat_state.updated_at, + }, + }, + ); + } + for user in view.users { let wechat_identity = wechat_identity_by_provider_uid .values() @@ -1145,8 +1216,8 @@ impl InMemoryAuthStoreState { phone_to_user_id, sessions_by_id, session_id_by_refresh_token_hash, - phone_codes_by_key: HashMap::new(), - wechat_states_by_token: HashMap::new(), + phone_codes_by_key, + wechat_states_by_token, wechat_identity_by_provider_uid, user_id_by_provider_union_id, }) @@ -1158,11 +1229,33 @@ impl InMemoryAuthStoreState { self.phone_to_user_id = next_state.phone_to_user_id; self.sessions_by_id = next_state.sessions_by_id; self.session_id_by_refresh_token_hash = next_state.session_id_by_refresh_token_hash; + self.phone_codes_by_key = next_state.phone_codes_by_key; + self.wechat_states_by_token = next_state.wechat_states_by_token; self.wechat_identity_by_provider_uid = next_state.wechat_identity_by_provider_uid; self.user_id_by_provider_union_id = next_state.user_id_by_provider_union_id; } } +fn prune_expired_short_lived_state( + state: &mut InMemoryAuthStoreState, + now: OffsetDateTime, +) -> bool { + let phone_code_count = state.phone_codes_by_key.len(); + state.phone_codes_by_key.retain(|_, code| { + parse_rfc3339(&code.expires_at) + .map(|expires_at| expires_at > now) + .unwrap_or(true) + }); + let wechat_state_count = state.wechat_states_by_token.len(); + state.wechat_states_by_token.retain(|_, stored| { + parse_rfc3339(&stored.state.expires_at) + .map(|expires_at| expires_at > now) + .unwrap_or(true) + }); + phone_code_count != state.phone_codes_by_key.len() + || wechat_state_count != state.wechat_states_by_token.len() +} + impl InMemoryAuthStore { pub fn from_projection_view(view: AuthStoreProjectionView) -> Result { Ok(Self { @@ -1215,10 +1308,14 @@ impl InMemoryAuthStore { &self, updated_at_micros: i64, ) -> Result { - let state = self + let mut state = self .inner .lock() .map_err(|_| "认证仓储锁已中毒".to_string())?; + let pruned = prune_expired_short_lived_state(&mut state, OffsetDateTime::now_utc()); + if pruned { + self.revision.fetch_add(1, Ordering::Release); + } let users = state .users_by_username .values() @@ -1283,6 +1380,35 @@ impl InMemoryAuthStore { }) }) .collect::, String>>()?; + let phone_codes = state + .phone_codes_by_key + .values() + .map(|stored| AuthStoreProjectionPhoneCode { + phone_number: stored.phone_number.clone(), + scene: stored.scene.as_str().to_string(), + verify_code_hash: stored.verify_code_hash.clone(), + expires_at: stored.expires_at.clone(), + last_sent_at: stored.last_sent_at.clone(), + failed_attempts: stored.failed_attempts, + provider_out_id: stored.provider_out_id.clone(), + }) + .collect(); + let wechat_states = state + .wechat_states_by_token + .values() + .map(|stored| AuthStoreProjectionWechatState { + wechat_state_id: stored.state.wechat_state_id.clone(), + state_token: stored.state.state_token.clone(), + redirect_path: stored.state.redirect_path.clone(), + scene: stored.state.scene.as_str().to_string(), + request_user_agent: stored.state.request_user_agent.clone(), + bind_user_id: stored.state.bind_user_id.clone(), + expires_at: stored.state.expires_at.clone(), + consumed_at: stored.state.consumed_at.clone(), + created_at: stored.state.created_at.clone(), + updated_at: stored.state.updated_at.clone(), + }) + .collect(); Ok(AuthStoreProjectionView { base_updated_at_micros: 0, @@ -1290,6 +1416,8 @@ impl InMemoryAuthStore { users, identities, refresh_sessions, + phone_codes, + wechat_states, }) } @@ -1966,6 +2094,7 @@ impl InMemoryAuthStore { // 手机号和业务场景共同决定同一份验证码快照,重复发送时直接覆盖旧值。 let key = build_phone_code_key(&code.phone_number, &code.scene); state.phone_codes_by_key.insert(key, code); + self.persist_phone_state(&state)?; Ok(()) } @@ -2047,6 +2176,7 @@ impl InMemoryAuthStore { .map_err(|_| PhoneAuthError::Store("短信验证码仓储锁已中毒".to_string()))?; let key = build_phone_code_key(phone_number, scene); state.phone_codes_by_key.remove(&key); + self.persist_phone_state(&state)?; Ok(()) } @@ -2066,11 +2196,13 @@ impl InMemoryAuthStore { let next_failed_attempts = stored.failed_attempts.saturating_add(1); if next_failed_attempts >= SMS_CODE_MAX_FAILED_ATTEMPTS { state.phone_codes_by_key.remove(&key); + self.persist_phone_state(&state)?; return Err(PhoneAuthError::VerifyAttemptsExceeded); } if let Some(current) = state.phone_codes_by_key.get_mut(&key) { current.failed_attempts = next_failed_attempts; } + self.persist_phone_state(&state)?; Err(PhoneAuthError::InvalidVerifyCode) } @@ -2094,6 +2226,7 @@ impl InMemoryAuthStore { state: state_record, }, ); + self.persist_wechat_state(&state)?; Ok(()) } @@ -2131,7 +2264,10 @@ impl InMemoryAuthStore { .ok_or(WechatAuthError::StateNotFound)?; current.state.consumed_at = Some(now_iso.clone()); current.state.updated_at = now_iso; - Ok(current.clone()) + let consumed = current.clone(); + state.wechat_states_by_token.remove(state_token.trim()); + self.persist_wechat_state(&state)?; + Ok(consumed) } fn bind_wechat_phone_to_user( @@ -2835,6 +2971,8 @@ mod tests { users: vec![], identities: vec![], refresh_sessions: vec![], + phone_codes: vec![], + wechat_states: vec![], }) .expect("projection should restore") } @@ -3265,6 +3403,8 @@ mod tests { )], identities: vec![], refresh_sessions: vec![], + phone_codes: vec![], + wechat_states: vec![], }) .expect("projection should restore"), ); @@ -3347,6 +3487,64 @@ mod tests { assert_eq!(rotated.user.id, user.id); } + #[tokio::test] + async fn projection_roundtrip_preserves_phone_code_and_wechat_state() { + let store = InMemoryAuthStore::default(); + let phone_service = build_phone_service(store.clone()); + let wechat_state_service = WechatAuthStateService::new(store.clone(), 5); + let now = OffsetDateTime::now_utc(); + + phone_service + .send_code( + SendPhoneCodeInput { + country_code: None, + pure_phone_number: "13800138040".to_string(), + scene: PhoneAuthScene::Login, + }, + now, + ) + .await + .expect("phone code should send before projection export"); + let created_state = wechat_state_service + .create_state( + CreateWechatAuthStateInput { + redirect_path: "/studio".to_string(), + scene: WechatAuthScene::Desktop, + request_user_agent: Some("test-agent".to_string()), + bind_user_id: None, + }, + now, + ) + .expect("wechat state should be created before projection export"); + + let projection = store + .export_projection_view(1) + .expect("projection export should include short-lived auth state"); + assert_eq!(projection.phone_codes.len(), 1); + assert_eq!(projection.wechat_states.len(), 1); + + let restored_store = InMemoryAuthStore::from_projection_view(projection) + .expect("projection should restore short-lived auth state"); + let restored_phone_service = build_phone_service(restored_store.clone()); + let login = restored_phone_service + .login( + PhoneLoginInput { + country_code: None, + pure_phone_number: "13800138040".to_string(), + verify_code: DEFAULT_SMS_MOCK_VERIFY_CODE.to_string(), + }, + now + Duration::seconds(1), + ) + .await + .expect("restored phone code should verify"); + assert!(login.created); + + let consumed_state = WechatAuthStateService::new(restored_store, 5) + .consume_state(&created_state.state.state_token, now + Duration::seconds(1)) + .expect("restored wechat state should be consumable"); + assert_eq!(consumed_state.state.redirect_path, "/studio"); + } + #[tokio::test] async fn refresh_from_projection_view_merges_session_created_by_another_process() { let source_store = InMemoryAuthStore::default(); @@ -3393,19 +3591,19 @@ mod tests { ) .expect("refreshed session active check should succeed") ); - assert!(matches!( - local_phone_service - .send_code( - SendPhoneCodeInput { - country_code: None, - pure_phone_number: "13800138034".to_string(), - scene: PhoneAuthScene::Login, - }, - local_now + Duration::seconds(5), - ) - .await, - Err(PhoneAuthError::SendCoolingDown { .. }) - )); + // 刷新到数据库正式投影后,短期认证状态也以数据库快照为准;本地未同步的验证码 + // 不得继续留在工作集里,避免消费已被其他节点清理的验证码。 + local_phone_service + .send_code( + SendPhoneCodeInput { + country_code: None, + pure_phone_number: "13800138034".to_string(), + scene: PhoneAuthScene::Login, + }, + local_now + Duration::seconds(5), + ) + .await + .expect("phone code should be resendable after authoritative refresh"); } #[test] @@ -3417,6 +3615,8 @@ mod tests { users: vec![], identities: vec![], refresh_sessions: vec![], + phone_codes: vec![], + wechat_states: vec![], }; assert_eq!(store.revision(), 0); @@ -4330,6 +4530,8 @@ mod tests { )], identities: vec![], refresh_sessions: vec![], + phone_codes: vec![], + wechat_states: vec![], }) .expect("projection should restore"); let phone_service = build_phone_service(store.clone()); diff --git a/server-rs/crates/spacetime-client/src/active/mapper/auth.rs b/server-rs/crates/spacetime-client/src/active/mapper/auth.rs index fd32f8fdc..3195f4514 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/auth.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/auth.rs @@ -105,6 +105,39 @@ pub(crate) fn map_auth_store_projection_view_input( }, ) .collect(), + phone_codes: view + .phone_codes + .into_iter() + .map( + |code| crate::module_bindings::AuthStoreProjectionPhoneCode { + phone_number: code.phone_number, + scene: code.scene, + verify_code_hash: code.verify_code_hash, + expires_at: code.expires_at, + last_sent_at: code.last_sent_at, + failed_attempts: code.failed_attempts, + provider_out_id: code.provider_out_id, + }, + ) + .collect(), + wechat_states: view + .wechat_states + .into_iter() + .map( + |state| crate::module_bindings::AuthStoreProjectionWechatState { + wechat_state_id: state.wechat_state_id, + state_token: state.state_token, + redirect_path: state.redirect_path, + scene: state.scene, + request_user_agent: state.request_user_agent, + bind_user_id: state.bind_user_id, + expires_at: state.expires_at, + consumed_at: state.consumed_at, + created_at: state.created_at, + updated_at: state.updated_at, + }, + ) + .collect(), } } @@ -161,5 +194,34 @@ fn map_auth_store_projection_view( last_seen_at: session.last_seen_at, }) .collect(), + phone_codes: view + .phone_codes + .into_iter() + .map(|code| module_auth::AuthStoreProjectionPhoneCode { + phone_number: code.phone_number, + scene: code.scene, + verify_code_hash: code.verify_code_hash, + expires_at: code.expires_at, + last_sent_at: code.last_sent_at, + failed_attempts: code.failed_attempts, + provider_out_id: code.provider_out_id, + }) + .collect(), + wechat_states: view + .wechat_states + .into_iter() + .map(|state| module_auth::AuthStoreProjectionWechatState { + wechat_state_id: state.wechat_state_id, + state_token: state.state_token, + redirect_path: state.redirect_path, + scene: state.scene, + request_user_agent: state.request_user_agent, + bind_user_id: state.bind_user_id, + expires_at: state.expires_at, + consumed_at: state.consumed_at, + created_at: state.created_at, + updated_at: state.updated_at, + }) + .collect(), } } diff --git a/server-rs/crates/spacetime-client/src/mapper/auth.rs b/server-rs/crates/spacetime-client/src/mapper/auth.rs index b85eeb959..0e0c7b5ac 100644 --- a/server-rs/crates/spacetime-client/src/mapper/auth.rs +++ b/server-rs/crates/spacetime-client/src/mapper/auth.rs @@ -88,6 +88,35 @@ pub(crate) fn map_auth_store_projection_view_input( }, ) .collect(), + phone_codes: view + .phone_codes + .into_iter() + .map(|code| crate::module_bindings::AuthStoreProjectionPhoneCode { + phone_number: code.phone_number, + scene: code.scene, + verify_code_hash: code.verify_code_hash, + expires_at: code.expires_at, + last_sent_at: code.last_sent_at, + failed_attempts: code.failed_attempts, + provider_out_id: code.provider_out_id, + }) + .collect(), + wechat_states: view + .wechat_states + .into_iter() + .map(|state| crate::module_bindings::AuthStoreProjectionWechatState { + wechat_state_id: state.wechat_state_id, + state_token: state.state_token, + redirect_path: state.redirect_path, + scene: state.scene, + request_user_agent: state.request_user_agent, + bind_user_id: state.bind_user_id, + expires_at: state.expires_at, + consumed_at: state.consumed_at, + created_at: state.created_at, + updated_at: state.updated_at, + }) + .collect(), } } @@ -144,5 +173,34 @@ fn map_auth_store_projection_view( last_seen_at: session.last_seen_at, }) .collect(), + phone_codes: view + .phone_codes + .into_iter() + .map(|code| module_auth::AuthStoreProjectionPhoneCode { + phone_number: code.phone_number, + scene: code.scene, + verify_code_hash: code.verify_code_hash, + expires_at: code.expires_at, + last_sent_at: code.last_sent_at, + failed_attempts: code.failed_attempts, + provider_out_id: code.provider_out_id, + }) + .collect(), + wechat_states: view + .wechat_states + .into_iter() + .map(|state| module_auth::AuthStoreProjectionWechatState { + wechat_state_id: state.wechat_state_id, + state_token: state.state_token, + redirect_path: state.redirect_path, + scene: state.scene, + request_user_agent: state.request_user_agent, + bind_user_id: state.bind_user_id, + expires_at: state.expires_at, + consumed_at: state.consumed_at, + created_at: state.created_at, + updated_at: state.updated_at, + }) + .collect(), } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 64674f251..b1fc29d7b 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -120,12 +120,14 @@ pub mod auth_session_validation_procedure_result_type; pub mod auth_store_projection_identity_type; pub mod auth_store_projection_meta_table; pub mod auth_store_projection_meta_type; +pub mod auth_store_projection_phone_code_type; pub mod auth_store_projection_procedure_result_type; pub mod auth_store_projection_refresh_session_type; pub mod auth_store_projection_sync_procedure_result_type; pub mod auth_store_projection_sync_record_type; pub mod auth_store_projection_user_type; pub mod auth_store_projection_view_type; +pub mod auth_store_projection_wechat_state_type; pub mod authenticate_external_api_key_and_return_procedure; pub mod authorize_database_migration_operator_procedure; pub mod backfill_editor_canvas_layout_and_return_procedure; @@ -989,12 +991,14 @@ pub use auth_session_validation_procedure_result_type::AuthSessionValidationProc pub use auth_store_projection_identity_type::AuthStoreProjectionIdentity; pub use auth_store_projection_meta_table::*; pub use auth_store_projection_meta_type::AuthStoreProjectionMeta; +pub use auth_store_projection_phone_code_type::AuthStoreProjectionPhoneCode; pub use auth_store_projection_procedure_result_type::AuthStoreProjectionProcedureResult; pub use auth_store_projection_refresh_session_type::AuthStoreProjectionRefreshSession; pub use auth_store_projection_sync_procedure_result_type::AuthStoreProjectionSyncProcedureResult; pub use auth_store_projection_sync_record_type::AuthStoreProjectionSyncRecord; pub use auth_store_projection_user_type::AuthStoreProjectionUser; pub use auth_store_projection_view_type::AuthStoreProjectionView; +pub use auth_store_projection_wechat_state_type::AuthStoreProjectionWechatState; pub use authenticate_external_api_key_and_return_procedure::authenticate_external_api_key_and_return; pub use authorize_database_migration_operator_procedure::authorize_database_migration_operator; pub use backfill_editor_canvas_layout_and_return_procedure::backfill_editor_canvas_layout_and_return; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_meta_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_meta_type.rs index 309dceff7..2e7b2d303 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_meta_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_meta_type.rs @@ -9,6 +9,8 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; pub struct AuthStoreProjectionMeta { pub meta_id: String, pub updated_at: __sdk::Timestamp, + pub phone_codes_json: Option, + pub wechat_states_json: Option, } impl __sdk::InModule for AuthStoreProjectionMeta { @@ -21,6 +23,8 @@ impl __sdk::InModule for AuthStoreProjectionMeta { pub struct AuthStoreProjectionMetaCols { pub meta_id: __sdk::__query_builder::Col, pub updated_at: __sdk::__query_builder::Col, + pub phone_codes_json: __sdk::__query_builder::Col>, + pub wechat_states_json: __sdk::__query_builder::Col>, } impl __sdk::__query_builder::HasCols for AuthStoreProjectionMeta { @@ -29,6 +33,8 @@ impl __sdk::__query_builder::HasCols for AuthStoreProjectionMeta { AuthStoreProjectionMetaCols { meta_id: __sdk::__query_builder::Col::new(table_name, "meta_id"), updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + phone_codes_json: __sdk::__query_builder::Col::new(table_name, "phone_codes_json"), + wechat_states_json: __sdk::__query_builder::Col::new(table_name, "wechat_states_json"), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_phone_code_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_phone_code_type.rs new file mode 100644 index 000000000..10c7bffc2 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_phone_code_type.rs @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AuthStoreProjectionPhoneCode { + pub phone_number: String, + pub scene: String, + pub verify_code_hash: String, + pub expires_at: String, + pub last_sent_at: String, + pub failed_attempts: u32, + pub provider_out_id: Option, +} + +impl __sdk::InModule for AuthStoreProjectionPhoneCode { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_view_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_view_type.rs index b771d4b3a..fa743157a 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_view_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_view_type.rs @@ -5,8 +5,10 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; use super::auth_store_projection_identity_type::AuthStoreProjectionIdentity; +use super::auth_store_projection_phone_code_type::AuthStoreProjectionPhoneCode; use super::auth_store_projection_refresh_session_type::AuthStoreProjectionRefreshSession; use super::auth_store_projection_user_type::AuthStoreProjectionUser; +use super::auth_store_projection_wechat_state_type::AuthStoreProjectionWechatState; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] @@ -15,6 +17,8 @@ pub struct AuthStoreProjectionView { pub users: Vec, pub identities: Vec, pub refresh_sessions: Vec, + pub phone_codes: Vec, + pub wechat_states: Vec, pub base_updated_at_micros: i64, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_wechat_state_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_wechat_state_type.rs new file mode 100644 index 000000000..7de462fa8 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_wechat_state_type.rs @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AuthStoreProjectionWechatState { + pub wechat_state_id: String, + pub state_token: String, + pub redirect_path: String, + pub scene: String, + pub request_user_agent: Option, + pub bind_user_id: Option, + pub expires_at: String, + pub consumed_at: Option, + pub created_at: String, + pub updated_at: String, +} + +impl __sdk::InModule for AuthStoreProjectionWechatState { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-module/src/auth/procedures.rs b/server-rs/crates/spacetime-module/src/auth/procedures.rs index 7bf0ded36..9f181c018 100644 --- a/server-rs/crates/spacetime-module/src/auth/procedures.rs +++ b/server-rs/crates/spacetime-module/src/auth/procedures.rs @@ -1,4 +1,5 @@ use crate::{ProcedureContext, ReducerContext, SpacetimeType, Table, Timestamp}; +use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use time::format_description::well_known::Rfc3339; @@ -15,6 +16,8 @@ pub struct AuthStoreProjectionView { pub users: Vec, pub identities: Vec, pub refresh_sessions: Vec, + pub phone_codes: Vec, + pub wechat_states: Vec, pub base_updated_at_micros: i64, } @@ -59,6 +62,31 @@ pub struct AuthStoreProjectionRefreshSession { pub last_seen_at: String, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)] +pub struct AuthStoreProjectionPhoneCode { + pub phone_number: String, + pub scene: String, + pub verify_code_hash: String, + pub expires_at: String, + pub last_sent_at: String, + pub failed_attempts: u32, + pub provider_out_id: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)] +pub struct AuthStoreProjectionWechatState { + pub wechat_state_id: String, + pub state_token: String, + pub redirect_path: String, + pub scene: String, + pub request_user_agent: Option, + pub bind_user_id: Option, + pub expires_at: String, + pub consumed_at: Option, + pub created_at: String, + pub updated_at: String, +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct AuthStoreProjectionSyncRecord { pub imported_user_count: u32, @@ -214,6 +242,10 @@ fn sync_auth_store_projection_tx( current_updated_at_micros.unwrap_or_default(), )?; ensure_newer_auth_projection_version(current_updated_at_micros, input.updated_at_micros)?; + let phone_codes_json = serde_json::to_string(&input.phone_codes) + .map_err(|error| format!("序列化短信验证码投影失败:{error}"))?; + let wechat_states_json = serde_json::to_string(&input.wechat_states) + .map_err(|error| format!("序列化微信授权 state 投影失败:{error}"))?; let user_ids = input .users @@ -343,7 +375,12 @@ fn sync_auth_store_projection_tx( imported_refresh_session_count += 1; } - upsert_auth_projection_meta(ctx, input.updated_at_micros); + upsert_auth_projection_meta( + ctx, + input.updated_at_micros, + phone_codes_json, + wechat_states_json, + ); Ok(AuthStoreProjectionSyncRecord { imported_user_count, @@ -387,13 +424,31 @@ fn ensure_newer_auth_projection_version( fn export_auth_store_projection_from_tables_tx( ctx: &ReducerContext, ) -> Result { - let updated_at_micros = ctx + let meta = ctx .db .auth_store_projection_meta() .meta_id() - .find(&AUTH_STORE_PROJECTION_META_ID.to_string()) + .find(&AUTH_STORE_PROJECTION_META_ID.to_string()); + let updated_at_micros = meta + .as_ref() .map(|row| row.updated_at.to_micros_since_unix_epoch()) .unwrap_or(0); + let phone_codes = meta + .as_ref() + .and_then(|row| row.phone_codes_json.as_deref()) + .filter(|value| !value.trim().is_empty()) + .map(serde_json::from_str) + .transpose() + .map_err(|error| format!("解析短信验证码投影失败:{error}"))? + .unwrap_or_default(); + let wechat_states = meta + .as_ref() + .and_then(|row| row.wechat_states_json.as_deref()) + .filter(|value| !value.trim().is_empty()) + .map(serde_json::from_str) + .transpose() + .map_err(|error| format!("解析微信授权 state 投影失败:{error}"))? + .unwrap_or_default(); let users = ctx .db .user_account() @@ -451,6 +506,8 @@ fn export_auth_store_projection_from_tables_tx( users, identities, refresh_sessions, + phone_codes, + wechat_states, }) } @@ -493,7 +550,12 @@ fn delete_missing_refresh_sessions( } } -fn upsert_auth_projection_meta(ctx: &ReducerContext, updated_at_micros: i64) { +fn upsert_auth_projection_meta( + ctx: &ReducerContext, + updated_at_micros: i64, + phone_codes_json: String, + wechat_states_json: String, +) { let meta_id = AUTH_STORE_PROJECTION_META_ID.to_string(); if ctx .db @@ -512,6 +574,8 @@ fn upsert_auth_projection_meta(ctx: &ReducerContext, updated_at_micros: i64) { .insert(AuthStoreProjectionMeta { meta_id, updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), + phone_codes_json: Some(phone_codes_json), + wechat_states_json: Some(wechat_states_json), }); } diff --git a/server-rs/crates/spacetime-module/src/auth/tables.rs b/server-rs/crates/spacetime-module/src/auth/tables.rs index e9f6e6c11..49251e549 100644 --- a/server-rs/crates/spacetime-module/src/auth/tables.rs +++ b/server-rs/crates/spacetime-module/src/auth/tables.rs @@ -5,6 +5,10 @@ pub struct AuthStoreProjectionMeta { #[primary_key] pub(crate) meta_id: String, pub(crate) updated_at: Timestamp, + #[default(None::)] + pub(crate) phone_codes_json: Option, + #[default(None::)] + pub(crate) wechat_states_json: Option, } #[spacetimedb::table( diff --git a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs index e553a58e4..4fd3e8818 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -9451,10 +9451,9 @@ fn process_profile_wallet_refund_outbox_tx( let mut rows = ctx .db .profile_wallet_refund_outbox() - .iter() - .filter(|row| { - row.status == PROFILE_WALLET_REFUND_OUTBOX_STATUS_PENDING && row.available_at <= now - }) + .by_profile_wallet_refund_outbox_status_available() + .filter(&PROFILE_WALLET_REFUND_OUTBOX_STATUS_PENDING.to_string()) + .filter(|row| row.available_at <= now) .collect::>(); rows.sort_by(|left, right| { left.available_at -- 2.52.0 From 874aa09b9a1683e5498b433034359e2c4a0ec183 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 27 Aug 2026 19:34:14 +0800 Subject: [PATCH 6/9] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=B7=A8=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E7=9F=AD=E4=BF=A1=E5=86=B7=E5=8D=B4=E4=B8=8E=E5=BE=AE?= =?UTF-8?q?=E4=BF=A1=E7=8A=B6=E6=80=81=E8=86=A8=E8=83=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 发码前通过占位验证码与投影 CAS 原子占用冷却窗口 限制活动微信 OAuth state 数量并在创建前清理过期状态 补充认证架构、运维决策和回归测试 --- .../shared-memory/decision-log.md | 2 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 + ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 + server-rs/crates/api-server/src/phone_auth.rs | 33 +++-- server-rs/crates/module-auth/src/lib.rs | 134 +++++++++++++++++- 5 files changed, 161 insertions(+), 12 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index a2e9ad552..5a692a20c 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -26,7 +26,7 @@ ## 2026-08-27 短期认证状态进入共享 typed projection - 背景:短信验证码和微信 OAuth state 仍只存在 API 进程内 HashMap,多节点请求或 API 重启会直接丢失,无法满足无粘性会话的鉴权恢复要求。 -- 决策:`AuthStoreProjectionView` 增加 `phone_codes` 与 `wechat_states` typed 字段,由 `auth_store_projection_meta` 以 JSON 投影持久化;启动恢复、CAS 同步和失败后的权威刷新都覆盖这两类短期状态。验证码哈希使用部署级稳定盐(当前复用 `GENARRATIVE_JWT_SECRET`),各 API 节点必须一致;认证 handler 在发码、消费验证码、创建/消费微信 state 后都要完成 projection sync,失败即返回服务错误;短期状态首次未命中时允许从正式投影做一次受 CAS 保护的只读刷新,再重试读取,不能依赖粘性会话。短期状态仍由 `module-auth` 内存工作集执行领域校验,但不再把本机 HashMap 当作持久化或跨节点真相。 +- 决策:`AuthStoreProjectionView` 增加 `phone_codes` 与 `wechat_states` typed 字段,由 `auth_store_projection_meta` 以 JSON 投影持久化;启动恢复、CAS 同步和失败后的权威刷新都覆盖这两类短期状态。验证码哈希使用部署级稳定盐(当前复用 `GENARRATIVE_JWT_SECRET`),各 API 节点必须一致;发码前先刷新权威投影并用占位验证码记录做一次 projection CAS,只有占用成功才调用短信 provider,避免跨节点冷却竞态;认证 handler 在发码、消费验证码、创建/消费微信 state 后都要完成 projection sync,失败即返回服务错误;短期状态首次未命中时允许从正式投影做一次受 CAS 保护的只读刷新,再重试读取,不能依赖粘性会话。微信 OAuth state 设置有界活动数量,避免单个 JSON 投影无界膨胀。短期状态仍由 `module-auth` 内存工作集执行领域校验,但不再把本机 HashMap 当作持久化或跨节点真相。 - 影响范围:`module-auth` projection、`spacetime-module` auth schema/procedure、`spacetime-client` bindings/facade、api-server 手机号 / 微信 handler、认证架构与运维文档。 - 验证方式:运行 module-auth projection roundtrip(验证码可跨恢复校验、微信 state 可跨恢复消费)、SpacetimeDB schema/runtime/DDD 门禁、api-server 定向测试、编码和 diff 检查。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index b3086ed82..fff89e2ce 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -418,6 +418,8 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 `auth_store_snapshot` 表和旧 `import_auth_store_snapshot_json` / `export_auth_store_snapshot_from_tables` procedure 已删除。认证投影同步只读写 `user_account`、`auth_identity`、`refresh_session` 和 `auth_store_projection_meta`;`auth_identity` 不再写 `phone_e164`、`display_name`、`avatar_url`,这些账号资料只以 `user_account` 为准。`api-server` 多节点必须使用相同的部署级验证码哈希盐(当前复用 `GENARRATIVE_JWT_SECRET`);轮换该 secret 会使尚未消费的短信验证码失效,但不会改变已持久化账号或 session。 +短期状态的并发保护:发短信前先从正式投影刷新工作集,再写入不可消费的占位验证码并通过 `sync_auth_store_projection` 的基线 CAS 占用手机号 / 场景冷却窗口;只有占用成功后才调用外部短信 provider,provider 成功后再同步真实验证码哈希。微信 OAuth state 在 `module-auth` 工作集内限制活动数量,超过上限直接拒绝创建,避免单行 JSON 投影无界增长;过期 state 仍由投影导出时清理。 + ### `bark_battle_draft_config` - Rust 结构体:`BarkBattleDraftConfigRow` diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index cb42ae393..0641a1ddc 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -868,6 +868,8 @@ journalctl -u genarrative-api.service --since '30 seconds ago' --no-pager | grep `Genarrative-Server-Provision` 和 `Genarrative-Api-Deploy` 会在保留旧 `/etc/genarrative/api-server.env` 的前提下补齐缺失的 tracking outbox 运行态路径,并确保 `/var/lib/genarrative/tracking-outbox` 归属 `genarrative:genarrative`。用户认证真相源只允许从 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)和 `auth_store_projection_meta` 中的短期验证码 / 微信 state 投影恢复;请求首次未命中短期状态时会从正式投影做一次只读刷新,不依赖粘性会话。不要再配置或依赖 `GENARRATIVE_AUTH_STORE_PATH` / `auth-store.json`,`module-auth` 也不再维护本地文件持久化;`auth_store_snapshot` 不再作为备查或运行期恢复源,只在正式认证表为空时一次性转移最新旧快照并清空,且旧 `get_auth_store_snapshot` / `upsert_auth_store_snapshot` / `import_auth_store_snapshot` 入口已经删除。所有 API 节点必须使用相同的 `GENARRATIVE_JWT_SECRET`,它也作为验证码哈希盐;轮换后尚未消费的验证码会失效。如果 `api-server` 启动时连不上 SpacetimeDB,会持续重试启动恢复,直到认证工作集从 SpacetimeDB 正式表和短期投影恢复成功后才开始监听 HTTP,以避免用空本地状态或旧快照覆盖认证表。 +发短信运维门禁:handler 会先刷新正式认证投影,再通过 projection CAS 写入不可消费的占位验证码来占用跨节点冷却窗口;占用失败时不得调用短信 provider。微信 OAuth state 活动数量有上限,命中上限应返回服务错误并触发限流 / 入口告警;不要通过调大单个 `auth_store_projection_meta` JSON 字段来绕过该保护。 + 前端登录态恢复只把 `/api/auth/refresh` 的 `401` / `403` 当成权威失效信号;服务器重启窗口里的 `502` / `503` / `504`、浏览器 `Failed to fetch` 或 refresh 响应契约异常都必须保留已有本地 access token,不触发全局 auth 变化。refresh 成功响应以共享契约 `RefreshSessionResponse { token }` 为准,前端不要额外要求业务 `ok` 字段。排查“重启后用户都掉线”时,先区分前端是否被暂时不可用清掉本地 token,再检查 SpacetimeDB 正式认证表是否缺 `user_account` / `refresh_session` 数据。 常用检查思路: diff --git a/server-rs/crates/api-server/src/phone_auth.rs b/server-rs/crates/api-server/src/phone_auth.rs index 02237d372..1614d0c04 100644 --- a/server-rs/crates/api-server/src/phone_auth.rs +++ b/server-rs/crates/api-server/src/phone_auth.rs @@ -50,16 +50,33 @@ pub async fn send_phone_code( phone_input_masked = phone_input_masked.as_str(), "收到手机号验证码发送请求" ); + let send_input = SendPhoneCodeInput { + country_code: payload.country_code, + pure_phone_number: payload.pure_phone_number, + scene: scene.clone(), + }; + let send_now = OffsetDateTime::now_utc(); + state + .refresh_auth_store_from_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("刷新短信验证码状态失败:{error}")) + })?; + state + .phone_auth_service() + .reserve_code_send(&send_input, send_now) + .map_err(map_phone_auth_error)?; + state + .sync_auth_store_tables_to_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("占用短信验证码发送窗口失败:{error}")) + })?; let result = match state .phone_auth_service() - .send_code( - SendPhoneCodeInput { - country_code: payload.country_code, - pure_phone_number: payload.pure_phone_number, - scene: scene.clone(), - }, - OffsetDateTime::now_utc(), - ) + .send_code_after_authoritative_reservation(send_input, send_now) .await { Ok(result) => { diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index 586e3eec3..92d254e25 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -32,6 +32,8 @@ use time::{Duration, OffsetDateTime}; use tracing::{info, warn}; const DEFAULT_PHONE_VERIFY_CODE_SALT: &str = "genarrative-phone-verify-code-v1"; +const PHONE_CODE_RESERVATION_MARKER: &str = "__genarrative_phone_code_reservation__"; +const MAX_ACTIVE_WECHAT_AUTH_STATES: usize = 1024; #[derive(Clone, Debug)] pub struct InMemoryAuthStore { @@ -543,6 +545,65 @@ impl PhoneAuthService { &self, input: SendPhoneCodeInput, now: OffsetDateTime, + ) -> Result { + self.send_code_inner(input, now, true).await + } + + /// 在 provider 调用前由 api-server 先同步该占位记录,以便 SpacetimeDB 的 + /// projection CAS 原子占用跨节点冷却窗口。 + pub fn reserve_code_send( + &self, + input: &SendPhoneCodeInput, + now: OffsetDateTime, + ) -> Result<(), PhoneAuthError> { + let scene = input.scene.clone(); + validate_mainland_china_country_code(input.country_code.as_deref())?; + let normalized_phone = normalize_mainland_china_phone_number(&input.pure_phone_number)?; + self.store + .ensure_phone_code_not_cooling_down(&normalized_phone.e164, &scene, now)?; + let expires_at = now + .checked_add(Duration::minutes(SMS_CODE_TTL_MINUTES)) + .ok_or_else(|| PhoneAuthError::Store("短信验证码过期时间计算溢出".to_string()))?; + let expires_at = format_rfc3339(expires_at).map_err(|message| { + PhoneAuthError::Store(format!("短信验证码过期时间格式化失败:{message}")) + })?; + let last_sent_at = format_rfc3339(now).map_err(|message| { + PhoneAuthError::Store(format!("短信验证码发送时间格式化失败:{message}")) + })?; + let verify_code_hash = hash_phone_verify_code( + &self.verify_code_salt, + &normalized_phone.e164, + &scene, + PHONE_CODE_RESERVATION_MARKER, + ); + self.store.upsert_phone_code( + StoredPhoneCode { + phone_number: normalized_phone.e164, + scene, + verify_code_hash, + expires_at, + last_sent_at, + failed_attempts: 0, + provider_out_id: None, + }, + now, + ) + } + + /// 仅供完成权威占用后的 provider 调用使用;占用已由 projection CAS 校验。 + pub async fn send_code_after_authoritative_reservation( + &self, + input: SendPhoneCodeInput, + now: OffsetDateTime, + ) -> Result { + self.send_code_inner(input, now, false).await + } + + async fn send_code_inner( + &self, + input: SendPhoneCodeInput, + now: OffsetDateTime, + check_local_cooldown: bool, ) -> Result { let scene = input.scene.clone(); validate_mainland_china_country_code(input.country_code.as_deref())?; @@ -556,8 +617,10 @@ impl PhoneAuthService { phone_national_masked = normalized_phone.masked_national_number.as_str(), "手机号验证码发送准备调用 provider" ); - self.store - .ensure_phone_code_not_cooling_down(&normalized_phone.e164, &scene, now)?; + if check_local_cooldown { + self.store + .ensure_phone_code_not_cooling_down(&normalized_phone.e164, &scene, now)?; + } let expires_at = now .checked_add(Duration::minutes(SMS_CODE_TTL_MINUTES)) .ok_or_else(|| PhoneAuthError::Store("短信验证码过期时间计算溢出".to_string()))?; @@ -841,7 +904,7 @@ impl WechatAuthStateService { created_at: created_at.clone(), updated_at: created_at, }; - self.store.insert_wechat_state(state.clone())?; + self.store.insert_wechat_state(state.clone(), now)?; Ok(CreateWechatAuthStateResult { state }) } @@ -2209,17 +2272,24 @@ impl InMemoryAuthStore { fn insert_wechat_state( &self, state_record: WechatAuthStateRecord, + now: OffsetDateTime, ) -> Result<(), WechatAuthError> { let mut state = self .inner .lock() .map_err(|_| WechatAuthError::Store("微信 state 仓储锁已中毒".to_string()))?; + prune_expired_short_lived_state(&mut state, now); if state .wechat_states_by_token .contains_key(&state_record.state_token) { return Err(WechatAuthError::Store("微信 state 已存在".to_string())); } + if state.wechat_states_by_token.len() >= MAX_ACTIVE_WECHAT_AUTH_STATES { + return Err(WechatAuthError::Store( + "微信登录请求过多,请稍后重试".to_string(), + )); + } state.wechat_states_by_token.insert( state_record.state_token.clone(), StoredWechatAuthState { @@ -3545,6 +3615,40 @@ mod tests { assert_eq!(consumed_state.state.redirect_path, "/studio"); } + #[test] + fn wechat_state_creation_is_bounded_before_projection_sync() { + let store = InMemoryAuthStore::default(); + let service = WechatAuthStateService::new(store, 5); + let now = OffsetDateTime::now_utc(); + + for index in 0..MAX_ACTIVE_WECHAT_AUTH_STATES { + service + .create_state( + CreateWechatAuthStateInput { + redirect_path: format!("/studio?attempt={index}"), + scene: WechatAuthScene::Desktop, + request_user_agent: None, + bind_user_id: None, + }, + now, + ) + .expect("active wechat state should fit within the projection budget"); + } + + let error = service + .create_state( + CreateWechatAuthStateInput { + redirect_path: "/studio".to_string(), + scene: WechatAuthScene::Desktop, + request_user_agent: None, + bind_user_id: None, + }, + now, + ) + .expect_err("wechat state creation must reject an unbounded projection"); + assert!(matches!(error, WechatAuthError::Store(message) if message.contains("请求过多"))); + } + #[tokio::test] async fn refresh_from_projection_view_merges_session_created_by_another_process() { let source_store = InMemoryAuthStore::default(); @@ -3736,6 +3840,30 @@ mod tests { } } + #[tokio::test] + async fn authoritative_phone_code_reservation_blocks_duplicate_provider_send() { + let service = build_phone_service(build_store()); + let input = SendPhoneCodeInput { + country_code: None, + pure_phone_number: "13800138001".to_string(), + scene: PhoneAuthScene::Login, + }; + let now = OffsetDateTime::now_utc(); + + service + .reserve_code_send(&input, now) + .expect("authoritative reservation should be representable locally"); + let duplicate = service + .reserve_code_send(&input, now + Duration::seconds(1)) + .expect_err("a second reservation must observe the local cooldown"); + assert!(matches!(duplicate, PhoneAuthError::SendCoolingDown { .. })); + + service + .send_code_after_authoritative_reservation(input, now) + .await + .expect("provider send should replace the reservation with the real code"); + } + #[tokio::test] async fn phone_send_code_keeps_different_scenes_isolated() { let service = build_phone_service(build_store()); -- 2.52.0 From 2b8d44b369f877a59277aa119bca0270d9636a4b Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 27 Aug 2026 20:48:49 +0800 Subject: [PATCH 7/9] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=89=B4=E6=9D=83?= =?UTF-8?q?=E8=B7=A8=E8=8A=82=E7=82=B9=E6=8A=95=E5=BD=B1=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 刷新登录、会话、密码与微信认证主链路的正式投影 补齐支付查单、过期补偿和订阅消息的跨节点身份读取 修复同步失败后的 pending 状态恢复并更新鉴权运维文档 --- .../shared-memory/decision-log.md | 2 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 4 +- server-rs/crates/api-server/src/auth_me.rs | 9 ++++ .../crates/api-server/src/auth_public_user.rs | 15 +++++++ .../crates/api-server/src/auth_sessions.rs | 15 +++++++ server-rs/crates/api-server/src/logout.rs | 7 ++++ server-rs/crates/api-server/src/logout_all.rs | 7 ++++ .../crates/api-server/src/password_entry.rs | 7 ++++ .../api-server/src/password_management.rs | 17 ++++++++ server-rs/crates/api-server/src/phone_auth.rs | 7 ++++ .../crates/api-server/src/profile_identity.rs | 7 ++++ .../profile_recharge_expiration_listener.rs | 8 ++++ .../crates/api-server/src/refresh_session.rs | 10 +++++ .../crates/api-server/src/runtime_profile.rs | 7 ++++ server-rs/crates/api-server/src/state.rs | 31 +++++++------- .../crates/api-server/src/wechat/auth.rs | 42 +++++++++++++++++++ server-rs/crates/api-server/src/wechat/pay.rs | 4 ++ .../src/wechat/subscribe_message.rs | 7 ++++ 19 files changed, 190 insertions(+), 18 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 5a692a20c..d502bc2af 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -26,7 +26,7 @@ ## 2026-08-27 短期认证状态进入共享 typed projection - 背景:短信验证码和微信 OAuth state 仍只存在 API 进程内 HashMap,多节点请求或 API 重启会直接丢失,无法满足无粘性会话的鉴权恢复要求。 -- 决策:`AuthStoreProjectionView` 增加 `phone_codes` 与 `wechat_states` typed 字段,由 `auth_store_projection_meta` 以 JSON 投影持久化;启动恢复、CAS 同步和失败后的权威刷新都覆盖这两类短期状态。验证码哈希使用部署级稳定盐(当前复用 `GENARRATIVE_JWT_SECRET`),各 API 节点必须一致;发码前先刷新权威投影并用占位验证码记录做一次 projection CAS,只有占用成功才调用短信 provider,避免跨节点冷却竞态;认证 handler 在发码、消费验证码、创建/消费微信 state 后都要完成 projection sync,失败即返回服务错误;短期状态首次未命中时允许从正式投影做一次受 CAS 保护的只读刷新,再重试读取,不能依赖粘性会话。微信 OAuth state 设置有界活动数量,避免单个 JSON 投影无界膨胀。短期状态仍由 `module-auth` 内存工作集执行领域校验,但不再把本机 HashMap 当作持久化或跨节点真相。 +- 决策:`AuthStoreProjectionView` 增加 `phone_codes` 与 `wechat_states` typed 字段,由 `auth_store_projection_meta` 以 JSON 投影持久化;启动恢复、CAS 同步和失败后的权威刷新都覆盖这两类短期状态。验证码哈希使用部署级稳定盐(当前复用 `GENARRATIVE_JWT_SECRET`),各 API 节点必须一致;发码前先刷新权威投影并用占位验证码记录做一次 projection CAS,只有占用成功才调用短信 provider,避免跨节点冷却竞态;认证 handler 在发码、消费验证码、创建/消费微信 state 后都要完成 projection sync,失败即返回服务错误;所有会读取或变更本机认证工作集的认证主链路(登录、刷新、`/me`、会话管理、密码、绑定和微信 state)在领域操作前先从正式投影做一次受 CAS 保护的只读刷新,刷新失败时 fail closed,不能依赖粘性会话;同步遇到 CAS 冲突时,若本次尝试期间没有新的本地变更则恢复正式快照,若仍有待同步 revision 则由后续认证请求重试,避免节点永久卡在 pending。微信 OAuth state 设置有界活动数量,避免单个 JSON 投影无界膨胀。短期状态仍由 `module-auth` 内存工作集执行领域校验,但不再把本机 HashMap 当作持久化或跨节点真相。 - 影响范围:`module-auth` projection、`spacetime-module` auth schema/procedure、`spacetime-client` bindings/facade、api-server 手机号 / 微信 handler、认证架构与运维文档。 - 验证方式:运行 module-auth projection roundtrip(验证码可跨恢复校验、微信 state 可跨恢复消费)、SpacetimeDB schema/runtime/DDD 门禁、api-server 定向测试、编码和 diff 检查。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index fff89e2ce..189cb3cf9 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -414,7 +414,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - 源码:`server-rs/crates/spacetime-module/src/auth/tables.rs` - 职责:保存 typed 认证投影的单调版本,以及短期手机号验证码和微信 OAuth state 的序列化投影;`phone_codes_json` / `wechat_states_json` 只承载短期认证状态,不替代 `user_account`、`auth_identity` 或 `refresh_session` 的正式表语义。 -认证恢复策略:`api-server` 启动时从 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)以及 `auth_store_projection_meta` 中的短期状态投影导出 typed `AuthStoreProjectionView`,再恢复 `module-auth` 的进程内认证工作集;生产 Bearer 中间件不再从 `InMemoryAuthStore` 读取用户或会话,而是每次通过 typed `validate_auth_session` procedure 在 SpacetimeDB 事务内校验 `token_version`、会话归属、撤销时间和过期时间,SpacetimeDB 不可用时 fail closed 返回服务错误。`validate_auth_session`、投影导出和投影同步均从 `ctx.sender()` 派生调用方,并复用现役 runtime service identity 白名单;启动恢复先完成该服务身份初始化,普通 SpacetimeDB identity 不能读取或改写私有认证表。测试构建仍可使用显式的内存测试夹具。短期认证请求在本进程工作集内未命中且工作集没有待同步变更时,先对正式投影做一次受 CAS 保护的只读刷新,再决定验证码 / OAuth state 是否失效,不依赖粘性会话;refresh cookie 仍只按正式 `refresh_session` 校验,其他认证数据也不得绕过正式同步。`module-auth` 只保留内存工作集和 projection 导入 / 导出能力,不再保留 JSON 快照导入 / 导出能力,也不写本地持久化文件;`auth-store.json` / `GENARRATIVE_AUTH_STORE_PATH` 不再是兼容恢复源。认证创建、登录会话、刷新、退出、改密、重置密码、绑定和资料变更等写操作仍必须在返回客户端前通过 `sync_auth_store_projection` 成功同步 SpacetimeDB 正式认证表;同步失败时接口返回错误,不允许把只存在于当前进程内存的账号、会话、短信验证码或微信 state 当成成功结果。每个 API 工作集绑定启动恢复或上次成功同步得到的 `auth_store_projection_meta.updated_at` 版本作为 `base_updated_at_micros`,SpacetimeDB 在同一事务内执行基线 CAS,并要求新的 `updated_at_micros` 严格递增;基线不一致或版本不晚于当前值时整包写入失败,冲突节点在确认本次同步前没有遗留未确认本地变更且同步期间没有其它本地认证变更后,才可丢弃本地工作集并从正式表恢复,不能用陈旧工作集删除、恢复或覆盖另一节点的新状态;若此前已有待重试 revision 或同期还有本地变更则保留旧基线并继续失败关闭,不用自动恢复覆盖未提交变更;同步成功但期间又出现新本地变更时最多连续补同步三轮,仍未稳定则失败关闭。这只是迁移期并发保护,不改变正式认证表的权威地位。新用户注册奖励、邀请码绑定和登录埋点必须排在认证同步成功之后,避免认证没落库时先写出钱包或邀请关系。若启动恢复阶段 SpacetimeDB 不可连接或超时,`api-server` 会按固定间隔持续重试认证工作集恢复,恢复成功后才开始监听 HTTP,避免一次短超时让进程永久停留在依赖不可用状态。 +认证恢复策略:`api-server` 启动时从 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)以及 `auth_store_projection_meta` 中的短期状态投影导出 typed `AuthStoreProjectionView`,再恢复 `module-auth` 的进程内认证工作集;生产 Bearer 中间件不再从 `InMemoryAuthStore` 读取用户或会话,而是每次通过 typed `validate_auth_session` procedure 在 SpacetimeDB 事务内校验 `token_version`、会话归属、撤销时间和过期时间,SpacetimeDB 不可用时 fail closed 返回服务错误。`validate_auth_session`、投影导出和投影同步均从 `ctx.sender()` 派生调用方,并复用现役 runtime service identity 白名单;启动恢复先完成该服务身份初始化,普通 SpacetimeDB identity 不能读取或改写私有认证表。测试构建仍可使用显式的内存测试夹具。所有会读取或变更本机认证工作集的认证主链路(登录、刷新、`/me`、会话管理、密码、绑定和微信 state)在领域操作前先从正式投影做一次受 CAS 保护的只读刷新,刷新失败时 fail closed;refresh cookie 仍只按正式 `refresh_session` 校验,其他认证数据也不得绕过正式同步。`module-auth` 只保留内存工作集和 projection 导入 / 导出能力,不再保留 JSON 快照导入 / 导出能力,也不写本地持久化文件;`auth-store.json` / `GENARRATIVE_AUTH_STORE_PATH` 不再是兼容恢复源。认证创建、登录会话、刷新、退出、改密、重置密码、绑定和资料变更等写操作仍必须在返回客户端前通过 `sync_auth_store_projection` 成功同步 SpacetimeDB 正式认证表;同步失败时接口返回错误,不允许把只存在于当前进程内存的账号、会话、短信验证码或微信 state 当成成功结果。每个 API 工作集绑定启动恢复或上次成功同步得到的 `auth_store_projection_meta.updated_at` 版本作为 `base_updated_at_micros`,SpacetimeDB 在同一事务内执行基线 CAS,并要求新的 `updated_at_micros` 严格递增;基线不一致或版本不晚于当前值时整包写入失败,冲突节点只有在确认本次同步尝试期间没有新的本地认证变更后,才可丢弃失败工作集并从正式表恢复,不能用陈旧工作集删除、恢复或覆盖另一节点的新状态;若同期仍有本地变更则保留 pending revision,并由后续认证请求先重试同步,不把临时数据库故障变成永久卡死;同步成功但期间又出现新本地变更时最多连续补同步三轮,仍未稳定则失败关闭。这只是迁移期并发保护,不改变正式认证表的权威地位。新用户注册奖励、邀请码绑定和登录埋点必须排在认证同步成功之后,避免认证没落库时先写出钱包或邀请关系。若启动恢复阶段 SpacetimeDB 不可连接或超时,`api-server` 会按固定间隔持续重试认证工作集恢复,恢复成功后才开始监听 HTTP,避免一次短超时让进程永久停留在依赖不可用状态。 `auth_store_snapshot` 表和旧 `import_auth_store_snapshot_json` / `export_auth_store_snapshot_from_tables` procedure 已删除。认证投影同步只读写 `user_account`、`auth_identity`、`refresh_session` 和 `auth_store_projection_meta`;`auth_identity` 不再写 `phone_e164`、`display_name`、`avatar_url`,这些账号资料只以 `user_account` 为准。`api-server` 多节点必须使用相同的部署级验证码哈希盐(当前复用 `GENARRATIVE_JWT_SECRET`);轮换该 secret 会使尚未消费的短信验证码失效,但不会改变已持久化账号或 session。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 0641a1ddc..062547649 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -777,7 +777,7 @@ node scripts/test-ve-llm.mjs ### 手机验证码短信 -手机验证码发送走阿里云普通短信 `SendSms`,验证码由 `module-auth` 在当前 `api-server` 进程内生成、哈希存储和校验,不再调用阿里云托管验证码的 `SendSmsVerifyCode` / `CheckSmsVerifyCode`。因此 `api-server` 重启后,已发送但未校验的验证码会失效。 +手机验证码发送走阿里云普通短信 `SendSms`,验证码由 `module-auth` 在当前 `api-server` 进程内生成并哈希,短期验证码投影随 `auth_store_projection_meta` 同步到 SpacetimeDB 后由任一 API 节点恢复和校验;不再调用阿里云托管验证码的 `SendSmsVerifyCode` / `CheckSmsVerifyCode`。因此只要 SpacetimeDB 正常,`api-server` 重启不会使已发送但未过期的验证码失效。 生产默认短信配置: @@ -866,7 +866,7 @@ systemctl restart genarrative-api.service journalctl -u genarrative-api.service --since '30 seconds ago' --no-pager | grep -E 'tracking outbox|Permission denied|os error 13' ``` -`Genarrative-Server-Provision` 和 `Genarrative-Api-Deploy` 会在保留旧 `/etc/genarrative/api-server.env` 的前提下补齐缺失的 tracking outbox 运行态路径,并确保 `/var/lib/genarrative/tracking-outbox` 归属 `genarrative:genarrative`。用户认证真相源只允许从 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)和 `auth_store_projection_meta` 中的短期验证码 / 微信 state 投影恢复;请求首次未命中短期状态时会从正式投影做一次只读刷新,不依赖粘性会话。不要再配置或依赖 `GENARRATIVE_AUTH_STORE_PATH` / `auth-store.json`,`module-auth` 也不再维护本地文件持久化;`auth_store_snapshot` 不再作为备查或运行期恢复源,只在正式认证表为空时一次性转移最新旧快照并清空,且旧 `get_auth_store_snapshot` / `upsert_auth_store_snapshot` / `import_auth_store_snapshot` 入口已经删除。所有 API 节点必须使用相同的 `GENARRATIVE_JWT_SECRET`,它也作为验证码哈希盐;轮换后尚未消费的验证码会失效。如果 `api-server` 启动时连不上 SpacetimeDB,会持续重试启动恢复,直到认证工作集从 SpacetimeDB 正式表和短期投影恢复成功后才开始监听 HTTP,以避免用空本地状态或旧快照覆盖认证表。 +`Genarrative-Server-Provision` 和 `Genarrative-Api-Deploy` 会在保留旧 `/etc/genarrative/api-server.env` 的前提下补齐缺失的 tracking outbox 运行态路径,并确保 `/var/lib/genarrative/tracking-outbox` 归属 `genarrative:genarrative`。用户认证真相源只允许从 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)和 `auth_store_projection_meta` 中的短期验证码 / 微信 state 投影恢复;所有会读取或变更本机认证工作集的认证主链路在领域操作前都会从正式投影做一次只读刷新,刷新失败即 fail closed,不依赖粘性会话。不要再配置或依赖 `GENARRATIVE_AUTH_STORE_PATH` / `auth-store.json`,`module-auth` 也不再维护本地文件持久化;`auth_store_snapshot` 不再作为备查或运行期恢复源,只在正式认证表为空时一次性转移最新旧快照并清空,且旧 `get_auth_store_snapshot` / `upsert_auth_store_snapshot` / `import_auth_store_snapshot` 入口已经删除。所有 API 节点必须使用相同的 `GENARRATIVE_JWT_SECRET`,它也作为验证码哈希盐;轮换后尚未消费的验证码会失效。如果 `api-server` 启动时连不上 SpacetimeDB,会持续重试启动恢复,直到认证工作集从 SpacetimeDB 正式表和短期投影恢复成功后才开始监听 HTTP,以避免用空本地状态或旧快照覆盖认证表。 发短信运维门禁:handler 会先刷新正式认证投影,再通过 projection CAS 写入不可消费的占位验证码来占用跨节点冷却窗口;占用失败时不得调用短信 provider。微信 OAuth state 活动数量有上限,命中上限应返回服务错误并触发限流 / 入口告警;不要通过调大单个 `auth_store_projection_meta` JSON 字段来绕过该保护。 diff --git a/server-rs/crates/api-server/src/auth_me.rs b/server-rs/crates/api-server/src/auth_me.rs index bab8c4349..74321a901 100644 --- a/server-rs/crates/api-server/src/auth_me.rs +++ b/server-rs/crates/api-server/src/auth_me.rs @@ -17,6 +17,15 @@ pub async fn auth_me( Extension(authenticated): Extension, ) -> Result, AppError> { let user_id = authenticated.claims().user_id().to_string(); + // Bearer 已由 SpacetimeDB 校验;/me 返回的资料也必须先从同一正式投影恢复, + // 否则节点间会因为本机工作集滞后把有效用户返回为 401 或旧资料。 + state + .refresh_auth_store_from_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("刷新认证状态失败:{error}")) + })?; let user = state .password_entry_service() .get_user_by_id(&user_id) diff --git a/server-rs/crates/api-server/src/auth_public_user.rs b/server-rs/crates/api-server/src/auth_public_user.rs index e07b4f6ab..d7f676dfb 100644 --- a/server-rs/crates/api-server/src/auth_public_user.rs +++ b/server-rs/crates/api-server/src/auth_public_user.rs @@ -15,6 +15,13 @@ pub async fn get_public_user_by_code( Extension(request_context): Extension, Path(code): Path, ) -> Result, AppError> { + state + .refresh_auth_store_from_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("刷新认证状态失败:{error}")) + })?; let user = state .password_entry_service() .get_user_by_public_user_code(&code) @@ -41,6 +48,14 @@ pub async fn get_public_user_by_id( return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("用户 ID 不能为空")); } + state + .refresh_auth_store_from_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("刷新认证状态失败:{error}")) + })?; + let user = state .auth_user_service() .get_user_by_id(user_id) diff --git a/server-rs/crates/api-server/src/auth_sessions.rs b/server-rs/crates/api-server/src/auth_sessions.rs index c9ba471d3..0ef62c01a 100644 --- a/server-rs/crates/api-server/src/auth_sessions.rs +++ b/server-rs/crates/api-server/src/auth_sessions.rs @@ -29,6 +29,13 @@ pub async fn auth_sessions( ) -> Result, AppError> { // 当前设备识别仍然依赖 refresh cookie 命中的原始 token,对旧前端行为保持兼容。 let user_id = authenticated.claims().user_id().to_string(); + state + .refresh_auth_store_from_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("刷新认证状态失败:{error}")) + })?; let current_refresh_token_hash = maybe_refresh_token.and_then(|token| { let token = token.0.token().trim(); if token.is_empty() { @@ -78,6 +85,14 @@ pub async fn revoke_auth_session( ); } + state + .refresh_auth_store_from_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("刷新认证状态失败:{error}")) + })?; + let revoke_result = state .refresh_session_service() .revoke_session_by_user_and_session( diff --git a/server-rs/crates/api-server/src/logout.rs b/server-rs/crates/api-server/src/logout.rs index 4de4f4efb..101b533e9 100644 --- a/server-rs/crates/api-server/src/logout.rs +++ b/server-rs/crates/api-server/src/logout.rs @@ -25,6 +25,13 @@ pub async fn logout( Extension(authenticated): Extension, maybe_refresh_token: Option>, ) -> Result { + state + .refresh_auth_store_from_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("刷新认证状态失败:{error}")) + })?; let refresh_token_hash = maybe_refresh_token.and_then(|token| { let token = token.0.token().trim().to_string(); if token.is_empty() { diff --git a/server-rs/crates/api-server/src/logout_all.rs b/server-rs/crates/api-server/src/logout_all.rs index e56e1b775..5bc545e1e 100644 --- a/server-rs/crates/api-server/src/logout_all.rs +++ b/server-rs/crates/api-server/src/logout_all.rs @@ -23,6 +23,13 @@ pub async fn logout_all( Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result { + state + .refresh_auth_store_from_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("刷新认证状态失败:{error}")) + })?; state .auth_user_service() .logout_all_sessions( diff --git a/server-rs/crates/api-server/src/password_entry.rs b/server-rs/crates/api-server/src/password_entry.rs index f2e82660e..d1acc699c 100644 --- a/server-rs/crates/api-server/src/password_entry.rs +++ b/server-rs/crates/api-server/src/password_entry.rs @@ -27,6 +27,13 @@ pub async fn password_entry( headers: HeaderMap, Json(payload): Json, ) -> Result { + state + .refresh_auth_store_from_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("刷新认证状态失败:{error}")) + })?; let input = PasswordEntryInput { country_code: payload.country_code, pure_phone_number: payload.pure_phone_number, diff --git a/server-rs/crates/api-server/src/password_management.rs b/server-rs/crates/api-server/src/password_management.rs index 38a0e40ab..61b92fe98 100644 --- a/server-rs/crates/api-server/src/password_management.rs +++ b/server-rs/crates/api-server/src/password_management.rs @@ -33,6 +33,13 @@ pub async fn change_password( Extension(authenticated): Extension, Json(payload): Json, ) -> Result { + state + .refresh_auth_store_from_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("刷新认证状态失败:{error}")) + })?; let result = state .password_entry_service() .change_password_and_revoke_all_sessions( @@ -82,6 +89,16 @@ pub async fn reset_password( ); } + // reset_password 消费的是跨节点共享的短期验证码;先恢复正式投影, + // 避免发码节点与消费节点的本机工作集不一致。 + state + .refresh_auth_store_from_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("刷新短信验证码状态失败:{error}")) + })?; + let result = match state .phone_auth_service() .reset_password( diff --git a/server-rs/crates/api-server/src/phone_auth.rs b/server-rs/crates/api-server/src/phone_auth.rs index 1614d0c04..37e77acec 100644 --- a/server-rs/crates/api-server/src/phone_auth.rs +++ b/server-rs/crates/api-server/src/phone_auth.rs @@ -139,6 +139,13 @@ pub async fn phone_login( AppError::from_status(StatusCode::BAD_REQUEST).with_message("手机号登录暂未启用") ); } + state + .refresh_auth_store_from_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("刷新短信验证码状态失败:{error}")) + })?; let invite_code = payload.invite_code.clone(); let login_input = PhoneLoginInput { country_code: payload.country_code, diff --git a/server-rs/crates/api-server/src/profile_identity.rs b/server-rs/crates/api-server/src/profile_identity.rs index fd91b8eb6..dac4f110c 100644 --- a/server-rs/crates/api-server/src/profile_identity.rs +++ b/server-rs/crates/api-server/src/profile_identity.rs @@ -23,6 +23,13 @@ pub async fn update_profile_identity( Extension(authenticated): Extension, Json(payload): Json, ) -> Result, AppError> { + state + .refresh_auth_store_from_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("刷新认证状态失败:{error}")) + })?; if let Some(avatar_data_url) = payload.avatar_data_url.as_deref() { validate_avatar_data_url(avatar_data_url)?; } diff --git a/server-rs/crates/api-server/src/profile_recharge_expiration_listener.rs b/server-rs/crates/api-server/src/profile_recharge_expiration_listener.rs index 5804203c3..070ac5752 100644 --- a/server-rs/crates/api-server/src/profile_recharge_expiration_listener.rs +++ b/server-rs/crates/api-server/src/profile_recharge_expiration_listener.rs @@ -265,6 +265,14 @@ async fn process_expired_virtual_payment_order( state: &AppState, order: &RuntimeProfileRechargeOrderRecord, ) -> Result<(), ExpirationCompensationError> { + state + .refresh_auth_store_from_spacetime() + .await + .map_err(|error| { + ExpirationCompensationError::Runtime(format!( + "failed to refresh auth projection for virtual payment query: {error}" + )) + })?; let identity = state .wechat_auth_service() .get_identity_by_user_id(&order.user_id) diff --git a/server-rs/crates/api-server/src/refresh_session.rs b/server-rs/crates/api-server/src/refresh_session.rs index 4cdd87bb8..c6c783f67 100644 --- a/server-rs/crates/api-server/src/refresh_session.rs +++ b/server-rs/crates/api-server/src/refresh_session.rs @@ -39,6 +39,16 @@ pub async fn refresh_session( let next_refresh_token = platform_auth::create_refresh_session_token(); let next_refresh_token_hash = hash_refresh_session_token(&next_refresh_token); + // refresh_session 是跨节点的正式认证入口;先加载最新投影,再在本机工作集执行领域轮换, + // 避免请求落到旧节点时把合法 refresh cookie 误判为不存在。 + state + .refresh_auth_store_from_spacetime() + .await + .map_err(|error| { + AppError::from_status(axum::http::StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("刷新认证状态失败:{error}")) + })?; + let rotated = match state.refresh_session_service().rotate_session( RotateRefreshSessionInput { refresh_token_hash: refresh_token_hash.clone(), diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 290e5b12e..67625908d 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -1650,6 +1650,13 @@ async fn resolve_wechat_identity_for_payment( state: &AppState, user_id: &str, ) -> Result { + state + .refresh_auth_store_from_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("刷新微信认证状态失败:{error}")) + })?; if let Some(identity) = state .wechat_auth_service() .get_identity_by_user_id(user_id) diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index cf9792f80..284b437cb 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -618,6 +618,10 @@ impl AppState { let editor_oss_http_client = build_editor_oss_http_client()?; let http_request_permit_pools = HttpRequestPermitPools::from_config(&config); let (profile_recharge_order_updates, _) = broadcast::channel(128); + // `ensure_orphan_work_owner_user` 只为公开作品作者回退提供进程内占位账号, + // 不属于正式认证投影;将当前工作集 revision 作为已同步起点,首次认证请求会 + // 先按正式投影刷新并自然丢弃该占位账号,避免把它误当成待提交认证变更。 + let initial_auth_store_revision = auth_store.revision(); Ok(Self(Arc::new(AppStateInner { config, @@ -646,7 +650,7 @@ impl AppState { oss_client, auth_store, auth_projection_version: AtomicI64::new(auth_projection_version), - auth_projection_synced_revision: AtomicU64::new(0), + auth_projection_synced_revision: AtomicU64::new(initial_auth_store_revision), auth_projection_sync_lock: AsyncMutex::new(()), password_entry_service, refresh_session_service, @@ -1310,11 +1314,6 @@ impl AppState { #[cfg(not(test))] let _sync_guard = self.auth_projection_sync_lock.lock().await; #[cfg(not(test))] - let sync_start_revision = self.auth_store.revision(); - #[cfg(not(test))] - let mut local_changed_during_sync = - self.auth_projection_synced_revision.load(Ordering::Acquire) != sync_start_revision; - #[cfg(not(test))] for attempt in 0..3 { let base_updated_at_micros = self.auth_projection_version.load(Ordering::Acquire); let now_updated_at_micros = @@ -1332,9 +1331,6 @@ impl AppState { .auth_store .export_projection_view_with_revision(updated_at_micros) .map_err(SpacetimeClientError::Runtime)?; - if attempted_revision != sync_start_revision { - local_changed_during_sync = true; - } projection.base_updated_at_micros = base_updated_at_micros; // 当前仍由 module-auth 的进程内工作集执行业务规则;这里只用 typed projection 同步正式认证表。 @@ -1351,7 +1347,6 @@ impl AppState { .store(attempted_revision, Ordering::Release); return Ok(()); } - local_changed_during_sync = true; warn!( attempt, "认证投影同步期间工作集发生变化,将继续同步最新工作集" @@ -1363,8 +1358,9 @@ impl AppState { error = %error, "认证投影同步 SpacetimeDB 正式表失败,当前认证流程中止" ); - if local_changed_during_sync || self.auth_store.revision() != attempted_revision - { + // 当前请求已经失败;只要同步尝试期间没有新的本地变更,恢复为 + // 数据库快照,避免一次 CAS 冲突把本节点永久留在“待同步”状态。 + if self.auth_store.revision() != attempted_revision { warn!( "认证投影同步失败期间工作集发生并发变化,跳过自动恢复以避免覆盖未提交变更" ); @@ -1406,14 +1402,21 @@ impl AppState { )) } - /// 在只读短期认证状态未命中时,从正式投影刷新一次本地工作集,避免 OAuth / 短信 - /// 请求落到另一节点后必须依赖粘性会话才能成功。 + /// 在认证主链路执行前,从正式投影刷新一次本地工作集,避免请求落到另一节点后 + /// 因本机工作集滞后而必须依赖粘性会话才能成功。 pub async fn refresh_auth_store_from_spacetime(&self) -> Result<(), SpacetimeClientError> { #[cfg(test)] return Ok(()); #[cfg(not(test))] { + // 上一次业务操作可能已经改了工作集,但在返回响应前遇到数据库暂时不可用。 + // 先重试提交这份待同步变更,避免只读请求把节点永久卡在 pending 状态。 + if self.auth_projection_synced_revision.load(Ordering::Acquire) + != self.auth_store.revision() + { + self.sync_auth_store_tables_to_spacetime().await?; + } let _sync_guard = self.auth_projection_sync_lock.lock().await; let expected_revision = self.auth_store.revision(); if self.auth_projection_synced_revision.load(Ordering::Acquire) != expected_revision { diff --git a/server-rs/crates/api-server/src/wechat/auth.rs b/server-rs/crates/api-server/src/wechat/auth.rs index d4f26dcd2..092c05e59 100644 --- a/server-rs/crates/api-server/src/wechat/auth.rs +++ b/server-rs/crates/api-server/src/wechat/auth.rs @@ -43,6 +43,13 @@ pub async fn start_wechat_login( if !state.config.wechat_auth_enabled { return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用")); } + state + .refresh_auth_store_from_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("刷新微信认证状态失败:{error}")) + })?; let user_agent = headers .get("user-agent") .and_then(|value| value.to_str().ok()) @@ -95,6 +102,13 @@ pub async fn start_wechat_bind( if !state.config.wechat_auth_enabled { return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用")); } + state + .refresh_auth_store_from_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("刷新微信认证状态失败:{error}")) + })?; let user_agent = headers .get("user-agent") .and_then(|value| value.to_str().ok()) @@ -164,6 +178,20 @@ pub async fn handle_wechat_callback( .into_response()); } + state + .refresh_auth_store_from_spacetime() + .await + .map_err(|error| { + warn!( + request_id = request_context.request_id(), + operation = request_context.operation(), + error = %error, + "微信回调前刷新认证投影失败" + ); + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message("刷新微信认证状态失败") + })?; + let consume_result = state .wechat_auth_state_service() .consume_state(&state_token, OffsetDateTime::now_utc()); @@ -320,6 +348,13 @@ pub async fn bind_wechat_phone( if !state.config.wechat_auth_enabled { return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用")); } + state + .refresh_auth_store_from_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("刷新微信认证状态失败:{error}")) + })?; let result = if let Some(wechat_phone_code) = payload .wechat_phone_code .as_deref() @@ -440,6 +475,13 @@ pub async fn login_wechat_mini_program( if !state.config.wechat_auth_enabled { return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用")); } + state + .refresh_auth_store_from_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("刷新微信认证状态失败:{error}")) + })?; let code = payload.code.trim(); if code.is_empty() { return Err( diff --git a/server-rs/crates/api-server/src/wechat/pay.rs b/server-rs/crates/api-server/src/wechat/pay.rs index 556c8f00e..3cc839884 100644 --- a/server-rs/crates/api-server/src/wechat/pay.rs +++ b/server-rs/crates/api-server/src/wechat/pay.rs @@ -239,6 +239,10 @@ async fn confirm_virtual_payment_recharge_order( )); } + state + .refresh_auth_store_from_spacetime() + .await + .map_err(|error| WechatPayError::Upstream(format!("刷新认证状态失败:{error}")))?; let identity = state .wechat_auth_service() .get_identity_by_user_id(&order.user_id) diff --git a/server-rs/crates/api-server/src/wechat/subscribe_message.rs b/server-rs/crates/api-server/src/wechat/subscribe_message.rs index 8946afaf1..863c0185b 100644 --- a/server-rs/crates/api-server/src/wechat/subscribe_message.rs +++ b/server-rs/crates/api-server/src/wechat/subscribe_message.rs @@ -56,6 +56,13 @@ async fn send_generation_result_subscribe_message( AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) .with_message("微信订阅消息模板 ID 未配置") })?; + state + .refresh_auth_store_from_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("刷新微信认证状态失败:{error}")) + })?; let user = state .auth_user_service() .get_user_by_id(&message.owner_user_id) -- 2.52.0 From 347eca205646b86c59f5f5dd6dcfe16c64377604 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 27 Aug 2026 20:57:45 +0800 Subject: [PATCH 8/9] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E7=9B=AE=E6=A0=87=E8=B7=A8=E8=8A=82=E7=82=B9?= =?UTF-8?q?=E8=AF=BB=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后台充值与钱包操作先刷新用户认证正式投影 避免目标用户仅存在于其他 API 节点时被误判不存在 --- .../crates/api-server/src/admin_recharge.rs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/server-rs/crates/api-server/src/admin_recharge.rs b/server-rs/crates/api-server/src/admin_recharge.rs index 28ad682ec..28492509e 100644 --- a/server-rs/crates/api-server/src/admin_recharge.rs +++ b/server-rs/crates/api-server/src/admin_recharge.rs @@ -64,6 +64,7 @@ pub async fn admin_list_recharge_orders( Extension(_admin): Extension, Query(query): Query, ) -> Result, Response> { + refresh_auth_projection(&state, &request_context).await?; let user_id = resolve_optional_user_id(&state, query.user_id, query.public_user_code) .map_err(|error| error_response(&request_context, error))?; let input = build_runtime_profile_recharge_order_admin_list_input( @@ -112,6 +113,7 @@ pub async fn admin_get_user_detail( Extension(admin): Extension, Query(query): Query, ) -> Result, Response> { + refresh_auth_projection(&state, &request_context).await?; let user = resolve_user(&state, query.user_id, query.public_user_code) .map_err(|error| error_response(&request_context, error))?; let wallet_detail = state @@ -181,6 +183,7 @@ pub async fn admin_reconcile_user_consumption( Extension(admin): Extension, Json(payload): Json, ) -> Result, Response> { + refresh_auth_projection(&state, &request_context).await?; let user = resolve_user(&state, Some(payload.user_id), None) .map_err(|error| error_response(&request_context, error))?; let input = build_runtime_profile_wallet_consumption_reconcile_input( @@ -589,6 +592,7 @@ pub async fn admin_update_wallet_restriction( Extension(admin): Extension, Json(payload): Json, ) -> Result, Response> { + refresh_auth_projection(&state, &request_context).await?; let user = resolve_user(&state, Some(payload.user_id), None) .map_err(|error| error_response(&request_context, error))?; let input = build_runtime_profile_wallet_manual_restriction_upsert_input( @@ -1040,6 +1044,22 @@ fn map_hold(hold: RuntimeProfileRechargeRefundHoldSnapshot) -> AdminRechargeRefu } } +async fn refresh_auth_projection( + state: &AppState, + request_context: &RequestContext, +) -> Result<(), Response> { + state + .refresh_auth_store_from_spacetime() + .await + .map_err(|error| { + error_response( + request_context, + AppError::from_status(StatusCode::BAD_GATEWAY) + .with_message(format!("刷新用户认证信息失败:{error}")), + ) + }) +} + fn resolve_optional_user_id( state: &AppState, user_id: Option, -- 2.52.0 From 061f8f32d3d851699949a668dfac2c2ff2284b45 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 27 Aug 2026 21:09:05 +0800 Subject: [PATCH 9/9] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=8F=97=E4=BF=9D?= =?UTF-8?q?=E6=8A=A4=E8=AF=B7=E6=B1=82=E8=AE=A4=E8=AF=81=E6=8A=95=E5=BD=B1?= =?UTF-8?q?=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 Bearer 路由刷新收口到鉴权中间件 补齐后台素材筛选跨节点用户解析并同步决策记录 --- docs/project-memory/shared-memory/decision-log.md | 2 +- server-rs/crates/api-server/src/admin.rs | 4 ++++ server-rs/crates/api-server/src/auth.rs | 10 ++++++++++ server-rs/crates/api-server/src/auth_me.rs | 9 --------- server-rs/crates/api-server/src/auth_sessions.rs | 15 --------------- server-rs/crates/api-server/src/logout.rs | 7 ------- server-rs/crates/api-server/src/logout_all.rs | 7 ------- .../crates/api-server/src/password_management.rs | 7 ------- .../crates/api-server/src/profile_identity.rs | 7 ------- server-rs/crates/api-server/src/wechat/auth.rs | 14 -------------- 10 files changed, 15 insertions(+), 67 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index d502bc2af..dad81b194 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -26,7 +26,7 @@ ## 2026-08-27 短期认证状态进入共享 typed projection - 背景:短信验证码和微信 OAuth state 仍只存在 API 进程内 HashMap,多节点请求或 API 重启会直接丢失,无法满足无粘性会话的鉴权恢复要求。 -- 决策:`AuthStoreProjectionView` 增加 `phone_codes` 与 `wechat_states` typed 字段,由 `auth_store_projection_meta` 以 JSON 投影持久化;启动恢复、CAS 同步和失败后的权威刷新都覆盖这两类短期状态。验证码哈希使用部署级稳定盐(当前复用 `GENARRATIVE_JWT_SECRET`),各 API 节点必须一致;发码前先刷新权威投影并用占位验证码记录做一次 projection CAS,只有占用成功才调用短信 provider,避免跨节点冷却竞态;认证 handler 在发码、消费验证码、创建/消费微信 state 后都要完成 projection sync,失败即返回服务错误;所有会读取或变更本机认证工作集的认证主链路(登录、刷新、`/me`、会话管理、密码、绑定和微信 state)在领域操作前先从正式投影做一次受 CAS 保护的只读刷新,刷新失败时 fail closed,不能依赖粘性会话;同步遇到 CAS 冲突时,若本次尝试期间没有新的本地变更则恢复正式快照,若仍有待同步 revision 则由后续认证请求重试,避免节点永久卡在 pending。微信 OAuth state 设置有界活动数量,避免单个 JSON 投影无界膨胀。短期状态仍由 `module-auth` 内存工作集执行领域校验,但不再把本机 HashMap 当作持久化或跨节点真相。 +- 决策:`AuthStoreProjectionView` 增加 `phone_codes` 与 `wechat_states` typed 字段,由 `auth_store_projection_meta` 以 JSON 投影持久化;启动恢复、CAS 同步和失败后的权威刷新都覆盖这两类短期状态。验证码哈希使用部署级稳定盐(当前复用 `GENARRATIVE_JWT_SECRET`),各 API 节点必须一致;发码前先刷新权威投影并用占位验证码记录做一次 projection CAS,只有占用成功才调用短信 provider,避免跨节点冷却竞态;认证 handler 在发码、消费验证码、创建/消费微信 state 后都要完成 projection sync,失败即返回服务错误;所有会读取或变更本机认证工作集的认证主链路(登录、刷新、`/me`、会话管理、密码、绑定和微信 state)在领域操作前先从正式投影做一次受 CAS 保护的只读刷新,受保护 Bearer 中间件也会在进入业务 handler 前执行同样的刷新,刷新失败时 fail closed,不能依赖粘性会话;同步遇到 CAS 冲突时,若本次尝试期间没有新的本地变更则恢复正式快照,若仍有待同步 revision 则由后续认证请求重试,避免节点永久卡在 pending。微信 OAuth state 设置有界活动数量,避免单个 JSON 投影无界膨胀。短期状态仍由 `module-auth` 内存工作集执行领域校验,但不再把本机 HashMap 当作持久化或跨节点真相。 - 影响范围:`module-auth` projection、`spacetime-module` auth schema/procedure、`spacetime-client` bindings/facade、api-server 手机号 / 微信 handler、认证架构与运维文档。 - 验证方式:运行 module-auth projection roundtrip(验证码可跨恢复校验、微信 state 可跨恢复消费)、SpacetimeDB schema/runtime/DDD 门禁、api-server 定向测试、编码和 diff 检查。 diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index 49751fd92..5afb9ccf2 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -657,6 +657,10 @@ pub async fn admin_list_editor_assets( Extension(_admin): Extension, Query(query): Query, ) -> Result, AppError> { + state + .refresh_auth_store_from_spacetime() + .await + .map_err(map_admin_spacetime_error)?; let page_size = query .limit .unwrap_or(ADMIN_EDITOR_ASSET_DEFAULT_LIMIT) diff --git a/server-rs/crates/api-server/src/auth.rs b/server-rs/crates/api-server/src/auth.rs index e1c9639d0..7ccaf0e5f 100644 --- a/server-rs/crates/api-server/src/auth.rs +++ b/server-rs/crates/api-server/src/auth.rs @@ -146,6 +146,16 @@ pub async fn require_bearer_auth( let Some(authenticated) = authenticate_request(&state, headers, request_id).await? else { return Err(AppError::from_status(StatusCode::UNAUTHORIZED)); }; + // JWT 会话校验走 SpacetimeDB;随后刷新用户/身份投影,保证所有受保护路由在 + // 读取进程内工作集时都不会依赖粘性会话命中创建或更新它的 API 节点。 + state + .refresh_auth_store_from_spacetime() + .await + .map_err(|error| { + warn!(error = %error, "受保护请求刷新认证投影失败"); + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) + .with_message("认证状态服务暂不可用") + })?; request.extensions_mut().insert(authenticated.clone()); let mut response = next.run(request).await; diff --git a/server-rs/crates/api-server/src/auth_me.rs b/server-rs/crates/api-server/src/auth_me.rs index 74321a901..bab8c4349 100644 --- a/server-rs/crates/api-server/src/auth_me.rs +++ b/server-rs/crates/api-server/src/auth_me.rs @@ -17,15 +17,6 @@ pub async fn auth_me( Extension(authenticated): Extension, ) -> Result, AppError> { let user_id = authenticated.claims().user_id().to_string(); - // Bearer 已由 SpacetimeDB 校验;/me 返回的资料也必须先从同一正式投影恢复, - // 否则节点间会因为本机工作集滞后把有效用户返回为 401 或旧资料。 - state - .refresh_auth_store_from_spacetime() - .await - .map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) - .with_message(format!("刷新认证状态失败:{error}")) - })?; let user = state .password_entry_service() .get_user_by_id(&user_id) diff --git a/server-rs/crates/api-server/src/auth_sessions.rs b/server-rs/crates/api-server/src/auth_sessions.rs index 0ef62c01a..c9ba471d3 100644 --- a/server-rs/crates/api-server/src/auth_sessions.rs +++ b/server-rs/crates/api-server/src/auth_sessions.rs @@ -29,13 +29,6 @@ pub async fn auth_sessions( ) -> Result, AppError> { // 当前设备识别仍然依赖 refresh cookie 命中的原始 token,对旧前端行为保持兼容。 let user_id = authenticated.claims().user_id().to_string(); - state - .refresh_auth_store_from_spacetime() - .await - .map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) - .with_message(format!("刷新认证状态失败:{error}")) - })?; let current_refresh_token_hash = maybe_refresh_token.and_then(|token| { let token = token.0.token().trim(); if token.is_empty() { @@ -85,14 +78,6 @@ pub async fn revoke_auth_session( ); } - state - .refresh_auth_store_from_spacetime() - .await - .map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) - .with_message(format!("刷新认证状态失败:{error}")) - })?; - let revoke_result = state .refresh_session_service() .revoke_session_by_user_and_session( diff --git a/server-rs/crates/api-server/src/logout.rs b/server-rs/crates/api-server/src/logout.rs index 101b533e9..4de4f4efb 100644 --- a/server-rs/crates/api-server/src/logout.rs +++ b/server-rs/crates/api-server/src/logout.rs @@ -25,13 +25,6 @@ pub async fn logout( Extension(authenticated): Extension, maybe_refresh_token: Option>, ) -> Result { - state - .refresh_auth_store_from_spacetime() - .await - .map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) - .with_message(format!("刷新认证状态失败:{error}")) - })?; let refresh_token_hash = maybe_refresh_token.and_then(|token| { let token = token.0.token().trim().to_string(); if token.is_empty() { diff --git a/server-rs/crates/api-server/src/logout_all.rs b/server-rs/crates/api-server/src/logout_all.rs index 5bc545e1e..e56e1b775 100644 --- a/server-rs/crates/api-server/src/logout_all.rs +++ b/server-rs/crates/api-server/src/logout_all.rs @@ -23,13 +23,6 @@ pub async fn logout_all( Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result { - state - .refresh_auth_store_from_spacetime() - .await - .map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) - .with_message(format!("刷新认证状态失败:{error}")) - })?; state .auth_user_service() .logout_all_sessions( diff --git a/server-rs/crates/api-server/src/password_management.rs b/server-rs/crates/api-server/src/password_management.rs index 61b92fe98..068e65c7b 100644 --- a/server-rs/crates/api-server/src/password_management.rs +++ b/server-rs/crates/api-server/src/password_management.rs @@ -33,13 +33,6 @@ pub async fn change_password( Extension(authenticated): Extension, Json(payload): Json, ) -> Result { - state - .refresh_auth_store_from_spacetime() - .await - .map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) - .with_message(format!("刷新认证状态失败:{error}")) - })?; let result = state .password_entry_service() .change_password_and_revoke_all_sessions( diff --git a/server-rs/crates/api-server/src/profile_identity.rs b/server-rs/crates/api-server/src/profile_identity.rs index dac4f110c..fd91b8eb6 100644 --- a/server-rs/crates/api-server/src/profile_identity.rs +++ b/server-rs/crates/api-server/src/profile_identity.rs @@ -23,13 +23,6 @@ pub async fn update_profile_identity( Extension(authenticated): Extension, Json(payload): Json, ) -> Result, AppError> { - state - .refresh_auth_store_from_spacetime() - .await - .map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) - .with_message(format!("刷新认证状态失败:{error}")) - })?; if let Some(avatar_data_url) = payload.avatar_data_url.as_deref() { validate_avatar_data_url(avatar_data_url)?; } diff --git a/server-rs/crates/api-server/src/wechat/auth.rs b/server-rs/crates/api-server/src/wechat/auth.rs index 092c05e59..5f553f51f 100644 --- a/server-rs/crates/api-server/src/wechat/auth.rs +++ b/server-rs/crates/api-server/src/wechat/auth.rs @@ -102,13 +102,6 @@ pub async fn start_wechat_bind( if !state.config.wechat_auth_enabled { return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用")); } - state - .refresh_auth_store_from_spacetime() - .await - .map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) - .with_message(format!("刷新微信认证状态失败:{error}")) - })?; let user_agent = headers .get("user-agent") .and_then(|value| value.to_str().ok()) @@ -348,13 +341,6 @@ pub async fn bind_wechat_phone( if !state.config.wechat_auth_enabled { return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用")); } - state - .refresh_auth_store_from_spacetime() - .await - .map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) - .with_message(format!("刷新微信认证状态失败:{error}")) - })?; let result = if let Some(wechat_phone_code) = payload .wechat_phone_code .as_deref() -- 2.52.0