From 47083bfe05a4cee93a6ee3051e6f1b4ecca3ead6 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 1 Jul 2026 19:07:21 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=87=E6=96=AD=E8=AE=A4=E8=AF=81=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=E5=90=8C=E6=AD=A5=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 删除 auth_store_snapshot 表和旧 JSON 快照 procedure。 改用 AuthStoreProjectionView 同步 user_account、auth_identity 和 refresh_session。 让 auth 服务从 user_account 投影恢复内存工作集。 同步生成绑定、schema guard 和后端架构文档。 --- .../shared-memory/decision-log.md | 8 + docs/project-memory/shared-memory/pitfalls.md | 32 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 14 +- scripts/check-spacetime-schema-guard.mjs | 4 +- server-rs/Cargo.lock | 1 + server-rs/crates/api-server/src/app.rs | 10 +- server-rs/crates/api-server/src/main.rs | 8 +- server-rs/crates/api-server/src/state.rs | 50 +- .../crates/module-auth/src/application.rs | 19 +- server-rs/crates/module-auth/src/commands.rs | 8 - server-rs/crates/module-auth/src/domain.rs | 51 +- server-rs/crates/module-auth/src/lib.rs | 579 +++++++----- server-rs/crates/spacetime-client/Cargo.toml | 1 + server-rs/crates/spacetime-client/src/auth.rs | 46 +- server-rs/crates/spacetime-client/src/lib.rs | 8 +- .../crates/spacetime-client/src/mapper.rs | 3 +- .../spacetime-client/src/mapper/auth.rs | 160 +++- .../spacetime-client/src/module_bindings.rs | 60 +- ...=> auth_store_projection_identity_type.rs} | 11 +- ...store_projection_procedure_result_type.rs} | 8 +- ...h_store_projection_refresh_session_type.rs | 24 + ..._projection_sync_procedure_result_type.rs} | 8 +- ...auth_store_projection_sync_record_type.rs} | 4 +- .../auth_store_projection_user_type.rs | 28 + .../auth_store_projection_view_type.rs | 22 + .../auth_store_snapshot_record_type.rs | 16 - .../auth_store_snapshot_table.rs | 161 ---- .../auth_store_snapshot_type.rs | 55 -- ..._store_projection_from_tables_procedure.rs | 54 ++ ...th_store_snapshot_from_tables_procedure.rs | 54 -- ...port_auth_store_snapshot_json_procedure.rs | 59 -- .../sync_auth_store_projection_procedure.rs | 59 ++ .../src/module_bindings/user_account_type.rs | 3 + server-rs/crates/spacetime-module/src/auth.rs | 1 - .../spacetime-module/src/auth/mapper.rs | 90 -- .../spacetime-module/src/auth/procedures.rs | 862 ++++++------------ .../spacetime-module/src/auth/tables.rs | 10 +- .../crates/spacetime-module/src/migration.rs | 1 - 38 files changed, 1123 insertions(+), 1469 deletions(-) rename server-rs/crates/spacetime-client/src/module_bindings/{auth_store_snapshot_upsert_input_type.rs => auth_store_projection_identity_type.rs} (61%) rename server-rs/crates/spacetime-client/src/module_bindings/{auth_store_snapshot_procedure_result_type.rs => auth_store_projection_procedure_result_type.rs} (66%) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_refresh_session_type.rs rename server-rs/crates/spacetime-client/src/module_bindings/{auth_store_snapshot_import_procedure_result_type.rs => auth_store_projection_sync_procedure_result_type.rs} (64%) rename server-rs/crates/spacetime-client/src/module_bindings/{auth_store_snapshot_import_record_type.rs => auth_store_projection_sync_record_type.rs} (83%) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_user_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_view_type.rs delete mode 100644 server-rs/crates/spacetime-client/src/module_bindings/auth_store_snapshot_record_type.rs delete mode 100644 server-rs/crates/spacetime-client/src/module_bindings/auth_store_snapshot_table.rs delete mode 100644 server-rs/crates/spacetime-client/src/module_bindings/auth_store_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/export_auth_store_projection_from_tables_procedure.rs delete mode 100644 server-rs/crates/spacetime-client/src/module_bindings/export_auth_store_snapshot_from_tables_procedure.rs delete mode 100644 server-rs/crates/spacetime-client/src/module_bindings/import_auth_store_snapshot_json_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/sync_auth_store_projection_procedure.rs delete mode 100644 server-rs/crates/spacetime-module/src/auth/mapper.rs diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 814d5f253..732c9d87a 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-07-01 认证工作集只经 typed projection 同步正式表 + +- 背景:同手机号重复账号、兑换码白名单错配和微信资料不回写暴露出 `module-auth` 内存工作集、`auth_store_snapshot` 和正式认证表之间仍有历史互刷路径;旧 JSON 快照会把过期手机号索引或用户资料重新带回运行态。 +- 决策:删除 `auth_store_snapshot` 表和旧 `import_auth_store_snapshot_json` / `export_auth_store_snapshot_from_tables` procedure;`module-auth` 只保留内存工作集和 typed `AuthStoreProjectionView` 导入 / 导出。运行中认证写操作通过 `sync_auth_store_projection` 同步 `user_account` / `auth_identity` / `refresh_session`,启动恢复通过 `export_auth_store_projection_from_tables` 从正式表恢复内存。账号资料真相只在 `user_account`,`auth_identity` 只保存登录入口身份键。 +- 影响范围:`module-auth` projection API、`spacetime-module` auth schema/procedure、`spacetime-client` bindings/facade、`api-server` 启动恢复和认证同步、后端架构文档与认证排障记忆。 +- 验证方式:`npm run spacetime:generate`、`SPACETIME_SCHEMA_GUARD_ALLOW_BREAKING=1 npm run check:spacetime-schema`、`cargo test -p module-auth --manifest-path server-rs/Cargo.toml -- --nocapture`、`cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`、`git diff --check`。 +- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + ## 2026-06-29 图片画布手动抠图走远端 BiRefNet BFF - 背景:用户手动“去除背景”面对任意图片,前端 `chromaKey` 和标准绿幕后处理不适合复杂人物、自然背景或非纯色背景;远端 image host 已部署 BiRefNet 服务,需要让手动抠图走高质量模型,同时避免把服务令牌暴露到浏览器。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index f94f1543e..e403046b1 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -90,7 +90,7 @@ - 现象:后台把私有兑换码配给某个陶泥号或手机号后,用户用同一手机号登录兑换仍提示 `该兑换码不适用于当前账号`。 - 原因:认证表里可能存在同一手机号的多条 `user_account`。如果认证工作集重建 `phone_to_user_id` 时让 `user_account.phone_number_e164` 后写覆盖前写,当前登录态会漂到没有 `auth_identity` 的重复账号,而兑换码白名单仍指向另一个内部 `user_id`。 -- 处理:重建认证工作集时以 `auth_identity(provider="phone")` 指向的账号作为手机号索引权威,`user_account.phone_number_e164` 只补没有 identity 的手机号;旧 `auth_store_snapshot` 只允许在正式认证表为空时一次性转移到正式表,随后清空,不再作为运行期回灌来源;Bearer / refresh session 本进程未命中时不要再从 SpacetimeDB 导出整包快照刷新内存。线上止血先核对失败请求附近的 current session `user_id` 与兑换码 `allowed_user_ids`,不要只看手机号展示值。 +- 处理:重建认证工作集时以 typed `AuthStoreProjectionView` 从 `user_account` / `auth_identity` / `refresh_session` 恢复;手机号索引以 `auth_identity(provider="phone")` 指向的账号为权威,`user_account.phone_number_e164` 只补没有 identity 的手机号;`auth_store_snapshot` 表和旧 JSON procedure 已删除,Bearer / refresh session 本进程未命中时不要再从 SpacetimeDB 导出整包状态刷新内存。线上止血先核对失败请求附近的 current session `user_id` 与兑换码 `allowed_user_ids`,不要只看手机号展示值。 - 约束:`auth_identity` 只保存登录入口身份键;手机号、昵称和头像的正式资料真相在 `user_account.phone_number_e164` / `display_name` / `avatar_url`。旧 `auth_identity.phone_e164` / `display_name` / `avatar_url` 只能作为历史回填来源,不能继续让新写入依赖这些列。 - 验证:`cargo test -p spacetime-module auth_export -- --nocapture` 应覆盖同手机号重复账号时手机号索引优先指向有 phone identity 的账号;`api-server` 中不应再存在运行期 `refresh_auth_store_from_spacetime` 调用。 - 关联:`server-rs/crates/spacetime-module/src/auth/procedures.rs`、`server-rs/crates/spacetime-module/src/auth/tables.rs`、`server-rs/crates/module-auth/src/lib.rs`。 @@ -400,12 +400,12 @@ - 验证:`systemctl status genarrative-external-generation-controller.service 'genarrative-external-generation-worker@*.service'` 能看到 controller 和 worker 实例;queue 模式下任务被 claim 后 `worker_id` 与 `lease_expires_at` 会更新,完成后 session 进入 ready 或 failed;inline 模式下不应产生新的 `external_generation_job`。 - 关联:`deploy/systemd/genarrative-external-generation-worker@.service`、`deploy/systemd/genarrative-external-generation-controller.service`、`deploy/env/external-generation-controller.env.example`、`server-rs/crates/spacetime-module/src/external_generation.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 -## 外部生成 worker 不应等待 HTTP 认证快照恢复 +## 外部生成 worker 不应等待 HTTP 认证投影恢复 -- 现象:`genarrative-external-generation-worker@1.service` 在 systemd 中显示 active,但 `external_generation_job` 长时间保持 `pending`;worker 日志每 5 秒出现 `export_auth_store_snapshot_from_tables` 订阅失败,例如缺少 `public_work_gallery_entry` 公开 read model 表。 -- 原因:独立 worker / controller 是非 HTTP 角色,不承接用户登录态恢复;如果启动路径复用 HTTP `api-server` 的认证快照恢复,SpacetimeDB 认证投影或公开 read model 漂移会把 worker claim 循环挡在启动前。 -- 处理:`GENARRATIVE_PROCESS_ROLE=external-generation-worker` 和 `external-generation-controller` 启动时只构建空 auth store 的 `AppState`,不调用 SpacetimeDB 认证快照导出;只有 `api` / `all` 这类 HTTP 角色需要在启动时恢复认证快照并在依赖不可用时重试或进入 503 降级。 -- 验证:重启 worker 后日志应先出现“非 HTTP 进程跳过 SpacetimeDB 认证快照恢复”,随后出现 `external generation worker 已启动`;同一时间窗口不应再因为 `export_auth_store_snapshot_from_tables` 缺表而阻止 job claim。HTTP `api-server` 的认证恢复日志和 503 降级语义保持不变。 +- 现象:`genarrative-external-generation-worker@1.service` 在 systemd 中显示 active,但 `external_generation_job` 长时间保持 `pending`;worker 日志每 5 秒出现认证投影或公开 read model 订阅失败。 +- 原因:独立 worker / controller 是非 HTTP 角色,不承接用户登录态恢复;如果启动路径复用 HTTP `api-server` 的认证投影恢复,SpacetimeDB 认证投影或公开 read model 漂移会把 worker claim 循环挡在启动前。 +- 处理:`GENARRATIVE_PROCESS_ROLE=external-generation-worker` 和 `external-generation-controller` 启动时只构建空 auth store 的 `AppState`,不调用 SpacetimeDB 认证投影导出;只有 `api` / `all` 这类 HTTP 角色需要在启动时恢复认证投影并在依赖不可用时重试或进入 503 降级。 +- 验证:重启 worker 后日志应先出现“非 HTTP 进程跳过 SpacetimeDB 认证投影恢复”,随后出现 `external generation worker 已启动`;同一时间窗口不应再因为认证投影恢复失败而阻止 job claim。HTTP `api-server` 的认证恢复日志和 503 降级语义保持不变。 - 关联:`server-rs/crates/api-server/src/main.rs`、`server-rs/crates/api-server/src/external_generation_worker.rs`、`server-rs/crates/api-server/src/external_generation_worker_controller.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 ## 本地旧 external-generation-worker 会抢队列并暴露成 procedure 超时 @@ -1197,27 +1197,27 @@ - 验证:生成文件落在 `public/branding/taonier-logo-*/`,用 Pillow 检查图片尺寸和非空;执行 `node --check scripts/generate-taonier-logo-concepts.mjs`、`npm run check:encoding`、`git diff --check`。 - 关联:`scripts/generate-taonier-logo-concepts.mjs`、`docs/design/TAONIER_BRAND_LOGO_CONCEPTS_2026-05-13.md`。 -## 忘记密码后仍提示手机号或密码错误先查认证快照同步 +## 忘记密码后仍提示手机号或密码错误先查认证投影同步 - 现象:用户通过“忘记密码”重设密码后,接口返回成功或页面进入登录态,但再次使用新密码登录仍提示“手机号或密码错误”;重启后还可能出现 `Bearer JWT 版本已失效`,日志里的 token version 与本地快照不一致。 - 原因:重置/修改密码会更新 `password_hash`、`password_login_enabled` 和 `token_version`,如果 API 层只更新本地 `InMemoryAuthStore`,没有调用 `sync_auth_store_tables_to_spacetime()`,`api-server` 重启时可能从旧的 SpacetimeDB 正式认证表恢复账号状态。 -- 处理:`POST /api/auth/password/change` 与 `POST /api/auth/password/reset` 成功后必须同步正式认证表。2026-06-30 起,`auth_store_snapshot` 不再保留行级备查,也不作为运行期回灌来源;只在正式认证表为空时把最新旧快照转移一次到 `user_account` / `auth_identity` / `refresh_session` 并立即清空旧表。认证创建、登录会话、刷新、退出、改密、重置密码、绑定和资料变更等写操作必须在返回客户端前成功同步 SpacetimeDB;同步失败时接口返回错误,不允许把只存在于当前进程内存的账号或会话当成成功结果。新用户注册奖励、邀请码绑定和登录埋点必须排在认证同步成功之后,避免认证没落库时先写出钱包或邀请关系。 +- 处理:`POST /api/auth/password/change` 与 `POST /api/auth/password/reset` 成功后必须同步正式认证表。2026-07-01 起,`auth_store_snapshot` 表和旧 JSON procedure 已删除;认证工作集只通过 typed projection 同步 `user_account` / `auth_identity` / `refresh_session`。认证创建、登录会话、刷新、退出、改密、重置密码、绑定和资料变更等写操作必须在返回客户端前成功同步 SpacetimeDB;同步失败时接口返回错误,不允许把只存在于当前进程内存的账号或会话当成成功结果。新用户注册奖励、邀请码绑定和登录埋点必须排在认证同步成功之后,避免认证没落库时先写出钱包或邀请关系。 - 验证:执行 `cargo test -p module-auth password --manifest-path server-rs/Cargo.toml` 与 `cargo test -p api-server password --manifest-path server-rs/Cargo.toml`;手测时重设密码后旧密码应失败,新密码应成功,重启后仍应保持。 - 关联:`server-rs/crates/api-server/src/password_management.rs`、`server-rs/crates/api-server/src/state.rs`、`docs/technical/PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md`。 ## 密码登录失败且短信登录提示手机号已存在先查孤儿手机号索引 - 现象:老账号用密码登录提示“手机号或密码错误”,改用短信验证码登录又提示“手机号已存在 / 已注册”,用户卡在既不能登录也不能重新创建的状态。 -- 原因:历史版本或停服务时认证同步不完整,可能在 SpacetimeDB `auth_identity(provider=phone)` 或 `module-auth` 快照里留下 `phone_to_user_id` 映射,但对应 `user_account` / `users_by_username` 用户行已经不存在。密码登录按手机号索引找不到真实用户,短信登录尝试创建新用户时又被孤儿手机号索引挡住。 -- 处理:`export_auth_store_snapshot_from_tables` 导出时必须过滤没有 `user_account` 的 phone / wechat identity、union 索引和 refresh session;`module-auth` 从 JSON 快照恢复时也必须二次丢弃指向不存在用户的索引。运行时创建手机号用户前若发现手机号映射指向不存在的用户,应删除孤儿映射后继续创建,避免死锁态继续扩散。 -- 验证:`cargo test -p module-auth snapshot_json_drops_orphan_phone_index_before_phone_login --manifest-path server-rs/Cargo.toml`、`cargo test -p module-auth phone --manifest-path server-rs/Cargo.toml`、`cargo test -p spacetime-module auth --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server phone_login_reuses_existing_user_for_same_phone_number --manifest-path server-rs/Cargo.toml`。 +- 原因:历史版本或停服务时认证同步不完整,可能在 SpacetimeDB `auth_identity(provider=phone)` 或旧 `module-auth` 快照里留下 `phone_to_user_id` 映射,但对应 `user_account` / `users_by_username` 用户行已经不存在。密码登录按手机号索引找不到真实用户,短信登录尝试创建新用户时又被孤儿手机号索引挡住。 +- 处理:`export_auth_store_projection_from_tables` 只导出正式认证表 projection;`module-auth` 从 projection 恢复时必须丢弃指向不存在 `user_account` 的 identity、union 索引和 refresh session。运行时创建手机号用户前若发现手机号映射指向不存在的用户,应删除孤儿映射后继续创建,避免死锁态继续扩散。 +- 验证:`cargo test -p module-auth projection --manifest-path server-rs/Cargo.toml`、`cargo test -p module-auth phone --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server phone_login_reuses_existing_user_for_same_phone_number --manifest-path server-rs/Cargo.toml`。 - 关联:`server-rs/crates/module-auth/src/lib.rs`、`server-rs/crates/spacetime-module/src/auth/procedures.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 -## 认证本地文件快照已废弃,旧 procedure 也已删 +## 认证快照表和旧 procedure 已删除 -- 现象:有些旧代码和生成 bindings 里还会残留 `get_auth_store_snapshot`、`upsert_auth_store_snapshot`、`import_auth_store_snapshot`,或者把 `auth-store.json` 误当成认证恢复源。 -- 原因:认证恢复已经彻底收口到 SpacetimeDB 正式表和 `module-auth` 的 JSON 导入 / 导出路径;本地文件持久化会和正式表投影打架,SpacetimeDB 不可用时还可能把旧快照回灌到用户表。 -- 处理:先用 `npm run spacetime:generate -- --rust-only` 刷新 bindings,确认 `server-rs/crates/spacetime-client/src/module_bindings.rs` 里已没有旧 procedure 导出;`module-auth` 只保留内存态,不再写本地快照文件。 +- 现象:有些旧代码和生成 bindings 里还会残留 `get_auth_store_snapshot`、`upsert_auth_store_snapshot`、`import_auth_store_snapshot`、`import_auth_store_snapshot_json`、`export_auth_store_snapshot_from_tables`,或者把 `auth-store.json` 误当成认证恢复源。 +- 原因:认证恢复已经彻底收口到 SpacetimeDB 正式表和 `module-auth` typed projection;本地文件持久化或 JSON 快照会和正式表投影打架,SpacetimeDB 不可用时还可能把旧快照回灌到用户表。 +- 处理:先用 `npm run spacetime:generate` 刷新 bindings,确认 `server-rs/crates/spacetime-client/src/module_bindings.rs` 里已没有旧 snapshot table / procedure 导出;`module-auth` 只保留内存态和 projection view,不再写本地快照文件。 - 验证:`cargo check -p module-auth --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:spacetime-schema`、`npm run check:encoding`。 ## 抓大鹅生成页只显示服务暂不可用先查 reason 和外部服务配置 @@ -2387,7 +2387,7 @@ ## 本地 api-server 启动订阅 401 先查 Web identity token 注入 -- 现象:`npm run dev` 启动到 api-server 恢复认证快照时,日志出现 `Failed to initiate WebSocket connection ... /v1/database//subscribe?compression=Brotli: HTTP error: 401 Unauthorized`。 +- 现象:`npm run dev` 启动到 api-server 恢复认证投影时,日志出现 `Failed to initiate WebSocket connection ... /v1/database//subscribe?compression=Brotli: HTTP error: 401 Unauthorized`。 - 原因:SpacetimeDB SDK 订阅需要 Web API identity token;本地 `.env.local` 常把 `GENARRATIVE_SPACETIME_TOKEN` 留空,只靠 CLI 登录态 publish 成功并不能让 api-server 的 WebSocket subscribe 获得权限。 - 处理:`scripts/dev.mjs` 在 SpacetimeDB 就绪后调用 `/v1/identity` 创建当前进程专用 Web API identity token,并只注入本次 `api-server` 环境;不要把临时 token 写进 `.env.local` 或日志。若仍报 401,先确认是否使用了项目脚本启动、日志是否出现 `已创建本地 Web identity`,以及 `GENARRATIVE_SPACETIME_SERVER_URL` / 数据库名是否指向本次启动的实例。 - 验证:`npm run test -- scripts/dev.test.ts`;重新运行 `npm run dev` 后 api-server 启动日志不再出现上述 subscribe 401,`/healthz` 返回 200。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index ec6e41d55..0894e180b 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -1,6 +1,6 @@ # server-rs 与 SpacetimeDB 数据契约 -更新时间:`2026-06-24` +更新时间:`2026-07-01` ## 后端主线 @@ -290,16 +290,9 @@ npm run check:server-rs-ddd - Rust 结构体:`AuthStoreProjectionMeta` - 源码:`server-rs/crates/spacetime-module/src/auth/tables.rs` -### `auth_store_snapshot` +认证恢复策略:`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,避免一次短超时让进程永久停留在依赖不可用状态。 -- Rust 结构体:`AuthStoreSnapshot` -- 源码:`server-rs/crates/spacetime-module/src/auth/tables.rs` - -认证恢复策略:`api-server` 启动时只从 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)投影恢复进程内认证工作集;运行中 Bearer `sid` 或 refresh cookie 在本进程工作集内未命中时直接按失效处理,不再从 SpacetimeDB 导出整包认证状态刷新内存,避免旧投影把重复手机号或旧会话重新灌回进程。`module-auth` 只保留内存工作集和 JSON 导入 / 导出能力,不再写本地持久化文件;`auth-store.json` / `GENARRATIVE_AUTH_STORE_PATH` 不再是兼容恢复源,也不得在启动时回写覆盖 `auth_identity` / `user_account`。认证创建、登录会话、刷新、退出、改密、重置密码、绑定和资料变更等写操作必须在返回客户端前成功同步 SpacetimeDB 正式认证表;同步失败时接口返回错误,不允许把只存在于当前进程内存的账号或会话当成成功结果。新用户注册奖励、邀请码绑定和登录埋点必须排在认证同步成功之后,避免认证没落库时先写出钱包或邀请关系。若启动恢复阶段 SpacetimeDB 不可连接或超时,`api-server` 会按固定间隔持续重试认证工作集恢复,恢复成功后才开始监听 HTTP,避免一次短超时让进程永久停留在依赖不可用状态。 - -`auth_store_snapshot` 禁止再写单行 `snapshot_id = "default"` 聚合 JSON,也不再保留行级备查。SpacetimeDB 模块只保留 `import_auth_store_snapshot_json` 与 `export_auth_store_snapshot_from_tables` 两个兼容过程:前者把当前 `module-auth` 工作集导入正式认证表并清空旧快照表;后者只在正式认证表为空时把最新旧快照转移一次到正式认证表,然后清空 `auth_store_snapshot`。旧 `get_auth_store_snapshot`、`upsert_auth_store_snapshot`、`import_auth_store_snapshot` 兼容入口已删除。导入正式表时只按主键 upsert 本次快照包含的用户、身份和会话,避免过期快照把其他用户整表删除。 - -导出认证快照时,`auth_identity` 与 `refresh_session` 只能引用仍存在于 `user_account` 的用户;孤儿手机号 identity、微信 identity、union 索引或 refresh session 必须被过滤,不能恢复成 `module-auth` 内存态里的 `phone_to_user_id` 死索引。`module-auth` 从 JSON 快照恢复时也要二次清理这些孤儿索引,避免历史坏快照导致密码登录提示错误、短信登录又提示手机号已存在。 +`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` 为准。 ### `bark_battle_draft_config` @@ -930,6 +923,7 @@ RPG 创作入口的配置 ID 是 `rpg`,当前 `visible=true`、`open=true`; - Rust 结构体:`UserAccount` - 源码:`server-rs/crates/spacetime-module/src/auth/tables.rs` +- 职责:账号资料真相源,保存 `phone_number_e164`、`display_name`、`avatar_url`、`created_at`、登录状态、密码 hash、token version 和账号标签。`module-auth` 进程内工作集必须通过 typed projection 与该表同步,不得再经 auth JSON 快照回灌。 ### `user_browse_history` diff --git a/scripts/check-spacetime-schema-guard.mjs b/scripts/check-spacetime-schema-guard.mjs index 1f55c1e0d..e3042ca2a 100644 --- a/scripts/check-spacetime-schema-guard.mjs +++ b/scripts/check-spacetime-schema-guard.mjs @@ -621,10 +621,12 @@ function main() { const compareResult = compareTables(baseResult.tables, currentResult.tables); const changedFiles = getChangedFiles(baseRef); const sidecarFailures = checkSchemaSidecars(changedFiles, compareResult.schemaChanged); + const compareFailures = + compareResult.breakingChanged && allowBreaking ? [] : compareResult.failures; const failures = [ ...currentResult.failures, ...baseResult.failures, - ...compareResult.failures, + ...compareFailures, ...sidecarFailures, ]; diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index 1e719b24d..efa038805 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -5770,6 +5770,7 @@ version = "0.1.0" dependencies = [ "module-ai", "module-assets", + "module-auth", "module-big-fish", "module-combat", "module-custom-world", diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index f549e281c..48bc995f1 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -521,7 +521,7 @@ mod tests { #[tokio::test] async fn spacetime_unavailable_router_returns_service_unavailable_for_requests() { let app = - build_spacetime_unavailable_router("SpacetimeDB 启动恢复认证快照超时".to_string()); + build_spacetime_unavailable_router("SpacetimeDB 启动恢复认证投影超时".to_string()); let response = app .oneshot( @@ -2806,6 +2806,10 @@ mod tests { login_payload["user"]["wechatDisplayName"], Value::String("微信旅人".to_string()) ); + assert_eq!( + login_payload["user"]["displayName"], + Value::String("微信旅人".to_string()) + ); assert_eq!( login_payload["user"]["wechatAccount"], Value::String("wx-mini-code-001".to_string()) @@ -2969,6 +2973,10 @@ mod tests { bind_payload["user"]["wechatDisplayName"], Value::String("微信旅人".to_string()) ); + assert_eq!( + bind_payload["user"]["displayName"], + Value::String("微信旅人".to_string()) + ); assert!( bind_payload["token"] .as_str() diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 307e7b761..93e18e9d1 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -219,7 +219,7 @@ fn build_non_http_app_state_for_startup( debug_assert!(!should_restore_auth_store_for_startup(process_role)); info!( process_role = process_role.as_str(), - "非 HTTP 进程跳过 SpacetimeDB 认证快照恢复" + "非 HTTP 进程跳过 SpacetimeDB 认证投影恢复" ); AppState::new_with_empty_auth_store(config) } @@ -436,7 +436,7 @@ async fn restore_app_state_for_startup( warn!( retry_after_seconds = AUTH_STORE_STARTUP_RETRY_INTERVAL.as_secs(), error = %message, - "启动恢复 SpacetimeDB 认证快照暂不可用,api-server 将继续重试" + "启动恢复 SpacetimeDB 认证投影暂不可用,api-server 将继续重试" ); tokio::time::sleep(AUTH_STORE_STARTUP_RETRY_INTERVAL).await; } @@ -458,10 +458,10 @@ async fn try_restore_app_state_for_startup( Err(_) => { error!( timeout_seconds = AUTH_STORE_STARTUP_RESTORE_TIMEOUT.as_secs(), - "启动等待 SpacetimeDB 恢复认证快照超时" + "启动等待 SpacetimeDB 恢复认证投影超时" ); Err(state::AppStateInitError::DependencyUnavailable( - "SpacetimeDB 启动恢复认证快照超时".to_string(), + "SpacetimeDB 启动恢复认证投影超时".to_string(), )) } } diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 4dff008f7..19bbca362 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -782,27 +782,26 @@ impl AppState { #[cfg(test)] return Ok(()); - #[cfg(not(test))] - let snapshot_json = self - .auth_store - .export_snapshot_json() - .map_err(SpacetimeClientError::Runtime)?; #[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()))?; - // 当前仍由 module-auth 的进程内工作集执行业务规则;这里只同步到 SpacetimeDB 正式认证表, - // 不再读写 auth_store_snapshot 行镜像。 + #[cfg(not(test))] + let projection = self + .auth_store + .export_projection_view(updated_at_micros) + .map_err(SpacetimeClientError::Runtime)?; + // 当前仍由 module-auth 的进程内工作集执行业务规则;这里只用 typed projection 同步正式认证表。 #[cfg(not(test))] if let Err(error) = self .spacetime_client - .import_auth_store_snapshot_json(snapshot_json, updated_at_micros) + .sync_auth_store_projection(projection) .await { warn!( error = %error, - "认证状态导入 SpacetimeDB 正式表失败,当前认证流程中止" + "认证投影同步 SpacetimeDB 正式表失败,当前认证流程中止" ); return Err(error); } @@ -824,33 +823,33 @@ impl AppState { let mut restore_errors = Vec::new(); match spacetime_client - .export_auth_store_snapshot_from_tables() + .export_auth_store_projection_from_tables() .await { - Ok(snapshot) => { + Ok(projection) => { spacetime_restore_available = true; - if let Some(candidate) = auth_store_candidate_from_snapshot_record( - snapshot, + if let Some(candidate) = auth_store_candidate_from_projection_view( + projection, AuthStoreRestoreSource::SpacetimeTables, )? { let state = Self::new_with_auth_store(config, candidate.auth_store)?; info!( source = candidate.source.as_str(), updated_at_micros = candidate.updated_at_micros, - "已恢复认证快照" + "已恢复认证投影" ); return Ok(state); } } Err(error) => { - warn!(error = %error, "从 SpacetimeDB 表恢复认证快照失败"); + warn!(error = %error, "从 SpacetimeDB 表恢复认证投影失败"); restore_errors.push(error.to_string()); } } if !spacetime_restore_available { return Err(AppStateInitError::DependencyUnavailable(format!( - "SpacetimeDB 认证恢复不可用:{}", + "SpacetimeDB 认证投影恢复不可用:{}", restore_errors.join("; ") ))); } @@ -1244,22 +1243,23 @@ struct AuthStoreRestoreCandidate { auth_store: InMemoryAuthStore, } -fn auth_store_candidate_from_snapshot_record( - snapshot: spacetime_client::AuthStoreSnapshotRecord, +fn auth_store_candidate_from_projection_view( + projection: module_auth::AuthStoreProjectionView, source: AuthStoreRestoreSource, ) -> Result, AppStateInitError> { - let Some(snapshot_json) = snapshot - .snapshot_json - .filter(|value| !value.trim().is_empty()) - else { + if projection.users.is_empty() + && projection.identities.is_empty() + && projection.refresh_sessions.is_empty() + { return Ok(None); - }; - let auth_store = InMemoryAuthStore::from_snapshot_json(&snapshot_json) + } + let updated_at_micros = Some(projection.updated_at_micros); + let auth_store = InMemoryAuthStore::from_projection_view(projection) .map_err(AppStateInitError::AuthStore)?; Ok(Some(AuthStoreRestoreCandidate { source, - updated_at_micros: snapshot.updated_at_micros, + updated_at_micros, auth_store, })) } diff --git a/server-rs/crates/module-auth/src/application.rs b/server-rs/crates/module-auth/src/application.rs index c59102113..f479c7e14 100644 --- a/server-rs/crates/module-auth/src/application.rs +++ b/server-rs/crates/module-auth/src/application.rs @@ -2,11 +2,7 @@ //! //! 这里只返回纯应用结果与领域事件;短信 provider、JWT 签发和持久化由外层 adapter 完成。 -use serde::{Deserialize, Serialize}; - -use crate::domain::{ - AuthStoreSnapshotRecord, AuthUser, RefreshSessionRecord, WechatAuthStateRecord, -}; +use crate::domain::{AuthUser, RefreshSessionRecord, WechatAuthStateRecord}; #[derive(Clone, Debug, PartialEq, Eq)] pub struct AuthMeResult { @@ -111,20 +107,7 @@ pub struct LogoutCurrentSessionResult { pub user: AuthUser, } -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RefreshAuthStoreSnapshotResult { - pub user_count: usize, - pub session_count: usize, -} - #[derive(Clone, Debug, PartialEq, Eq)] pub struct LogoutAllSessionsResult { pub user: AuthUser, } - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct AuthStoreSnapshotProcedureResult { - pub ok: bool, - pub record: Option, - pub error_message: Option, -} diff --git a/server-rs/crates/module-auth/src/commands.rs b/server-rs/crates/module-auth/src/commands.rs index 61985fc73..e7e67fb9b 100644 --- a/server-rs/crates/module-auth/src/commands.rs +++ b/server-rs/crates/module-auth/src/commands.rs @@ -2,8 +2,6 @@ //! //! 用于表达密码入口、手机号验证码、微信登录、刷新会话签发和吊销等用例输入。 -use serde::{Deserialize, Serialize}; - use crate::domain::{ AuthLoginMethod, PhoneAuthScene, RefreshSessionClientInfo, WechatAuthScene, WechatIdentityProfile, @@ -106,9 +104,3 @@ pub struct LogoutCurrentSessionInput { pub struct LogoutAllSessionsInput { pub user_id: String, } - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct AuthStoreSnapshotUpsertInput { - pub snapshot_json: String, - pub updated_at_micros: i64, -} diff --git a/server-rs/crates/module-auth/src/domain.rs b/server-rs/crates/module-auth/src/domain.rs index 19c8dae86..d9563a1b8 100644 --- a/server-rs/crates/module-auth/src/domain.rs +++ b/server-rs/crates/module-auth/src/domain.rs @@ -178,11 +178,54 @@ pub struct RefreshSessionRecord { pub last_seen_at: String, } -/// Auth store 持久化快照记录。 +/// module-auth 进程内工作集同步到数据库的 typed view。 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct AuthStoreSnapshotRecord { - pub snapshot_json: Option, - pub updated_at_micros: Option, +pub struct AuthStoreProjectionView { + pub updated_at_micros: i64, + pub users: Vec, + pub identities: Vec, + pub refresh_sessions: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuthStoreProjectionUser { + pub user_id: String, + pub public_user_code: String, + pub username: String, + pub display_name: String, + pub avatar_url: Option, + pub phone_number_masked: Option, + pub phone_number_e164: Option, + pub login_method: String, + pub binding_status: String, + pub wechat_bound: bool, + pub password_hash: String, + pub password_login_enabled: bool, + pub token_version: u64, + pub created_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuthStoreProjectionIdentity { + pub identity_id: String, + pub user_id: String, + pub provider: String, + pub provider_uid: String, + pub provider_union_id: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuthStoreProjectionRefreshSession { + pub session_id: String, + pub user_id: String, + pub refresh_token_hash: String, + pub issued_by_provider: String, + pub client_info_json: String, + pub expires_at: String, + pub revoked_at: Option, + pub created_at: String, + pub updated_at: String, + pub last_seen_at: String, } pub fn validate_password(password: &str) -> Result<(), PasswordEntryError> { diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index 2d469f938..0b9c93a83 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -46,17 +46,6 @@ struct InMemoryAuthStoreState { user_id_by_provider_union_id: HashMap, } -#[derive(Debug, Serialize, Deserialize)] -struct PersistentAuthStoreSnapshot { - next_user_id: u64, - users_by_username: HashMap, - phone_to_user_id: HashMap, - sessions_by_id: HashMap, - session_id_by_refresh_token_hash: HashMap, - wechat_identity_by_provider_uid: HashMap, - user_id_by_provider_union_id: HashMap, -} - #[derive(Clone, Debug, Serialize, Deserialize)] struct StoredPasswordUser { user: AuthUser, @@ -127,6 +116,53 @@ fn stored_user_phone_number(stored_user: &StoredPasswordUser) -> Option<&str> { .or(stored_user.user.phone_number.as_deref()) } +fn parse_auth_login_method(value: &str) -> AuthLoginMethod { + match value { + "password" | "Password" => AuthLoginMethod::Password, + "wechat" | "Wechat" => AuthLoginMethod::Wechat, + _ => AuthLoginMethod::Phone, + } +} + +fn parse_auth_binding_status(value: &str) -> AuthBindingStatus { + match value { + "pending_bind_phone" | "PendingBindPhone" => AuthBindingStatus::PendingBindPhone, + _ => AuthBindingStatus::Active, + } +} + +fn next_sequence_from_public_user_code(public_user_code: &str) -> u64 { + public_user_code + .trim() + .strip_prefix("SY-") + .and_then(|value| value.parse::().ok()) + .map(|sequence| sequence.saturating_add(1)) + .unwrap_or(1) +} + +fn auth_identity_id(provider: &str, provider_uid: &str) -> String { + format!( + "authi_{}_{}", + sanitize_identity_component(provider), + sanitize_identity_component(provider_uid) + ) +} + +fn sanitize_identity_component(value: &str) -> String { + value + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() { + character + } else { + '_' + } + }) + .collect::() + .trim_matches('_') + .to_string() +} + #[derive(Clone, Debug)] pub struct PasswordEntryService { store: InMemoryAuthStore, @@ -951,41 +987,135 @@ impl Default for InMemoryAuthStoreState { } impl InMemoryAuthStoreState { - fn from_persistent_snapshot(snapshot: PersistentAuthStoreSnapshot) -> Self { - let existing_user_ids = snapshot - .users_by_username - .values() - .map(|stored| stored.user.id.clone()) + fn from_projection_view(view: AuthStoreProjectionView) -> Result { + let existing_user_ids = view + .users + .iter() + .map(|user| user.user_id.clone()) .collect::>(); - let phone_to_user_id = snapshot - .phone_to_user_id - .into_iter() - .filter(|(_, user_id)| existing_user_ids.contains(user_id)) - .collect(); - let sessions_by_id = snapshot - .sessions_by_id - .into_iter() - .filter(|(_, stored)| existing_user_ids.contains(&stored.session.user_id)) - .collect::>(); - let session_id_by_refresh_token_hash = snapshot - .session_id_by_refresh_token_hash - .into_iter() - .filter(|(_, session_id)| sessions_by_id.contains_key(session_id)) - .collect(); - let wechat_identity_by_provider_uid = snapshot - .wechat_identity_by_provider_uid - .into_iter() - .filter(|(_, identity)| existing_user_ids.contains(&identity.user_id)) - .collect(); - let user_id_by_provider_union_id = snapshot - .user_id_by_provider_union_id - .into_iter() - .filter(|(_, user_id)| existing_user_ids.contains(user_id)) - .collect(); + let mut users_by_username = HashMap::new(); + let mut phone_to_user_id = HashMap::new(); + let mut sessions_by_id = HashMap::new(); + let mut session_id_by_refresh_token_hash = HashMap::new(); + 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(); - Self { - next_user_id: snapshot.next_user_id, - users_by_username: snapshot.users_by_username, + for user in &view.users { + if let Some(phone_number) = normalize_optional_string(user.phone_number_e164.clone()) { + phone_to_user_id.insert(phone_number.clone(), user.user_id.clone()); + phone_number_by_user_id.insert(user.user_id.clone(), phone_number); + } + } + + for identity in view.identities { + if !existing_user_ids.contains(&identity.user_id) { + continue; + } + match identity.provider.as_str() { + "phone" => { + if let Some(phone_number) = + normalize_optional_string(Some(identity.provider_uid.clone())) + { + phone_to_user_id.insert(phone_number.clone(), identity.user_id.clone()); + phone_number_by_user_id.insert(identity.user_id, phone_number); + } + } + "wechat" => { + if let Some(provider_union_id) = identity.provider_union_id.clone() { + user_id_by_provider_union_id + .insert(provider_union_id, identity.user_id.clone()); + } + wechat_identity_by_provider_uid.insert( + identity.provider_uid.clone(), + StoredWechatIdentity { + user_id: identity.user_id, + provider_uid: identity.provider_uid, + provider_union_id: identity.provider_union_id, + display_name: None, + avatar_url: None, + session_key: None, + }, + ); + } + _ => {} + } + } + + for session in view.refresh_sessions { + if !existing_user_ids.contains(&session.user_id) { + continue; + } + let client_info = + serde_json::from_str::(&session.client_info_json) + .map_err(|error| format!("解析 refresh session 客户端信息失败:{error}"))?; + session_id_by_refresh_token_hash.insert( + session.refresh_token_hash.clone(), + session.session_id.clone(), + ); + sessions_by_id.insert( + session.session_id.clone(), + StoredRefreshSession { + session: RefreshSessionRecord { + session_id: session.session_id, + user_id: session.user_id, + refresh_token_hash: session.refresh_token_hash, + issued_by_provider: parse_auth_login_method(&session.issued_by_provider), + client_info, + expires_at: session.expires_at, + revoked_at: session.revoked_at, + created_at: session.created_at, + updated_at: session.updated_at, + last_seen_at: session.last_seen_at, + }, + }, + ); + } + + for user in view.users { + let wechat_identity = wechat_identity_by_provider_uid + .values() + .find(|identity| identity.user_id == user.user_id); + let wechat_account = wechat_identity.map(|identity| identity.provider_uid.clone()); + let phone_number = user + .phone_number_e164 + .clone() + .or_else(|| phone_number_by_user_id.remove(&user.user_id)); + users_by_username.insert( + user.username.clone(), + StoredPasswordUser { + user: AuthUser { + id: user.user_id, + public_user_code: user.public_user_code, + username: user.username, + display_name: user.display_name, + avatar_url: user.avatar_url, + phone_number: phone_number.clone(), + phone_number_masked: user.phone_number_masked, + login_method: parse_auth_login_method(&user.login_method), + binding_status: parse_auth_binding_status(&user.binding_status), + wechat_bound: user.wechat_bound, + wechat_display_name: None, + wechat_account, + token_version: user.token_version, + created_at: user.created_at, + }, + password_hash: user.password_hash, + password_login_enabled: user.password_login_enabled, + phone_number, + }, + ); + } + + let next_user_id = users_by_username + .values() + .map(|stored| next_sequence_from_public_user_code(&stored.user.public_user_code)) + .max() + .unwrap_or(1); + + Ok(Self { + next_user_id, + users_by_username, phone_to_user_id, sessions_by_id, session_id_by_refresh_token_hash, @@ -993,7 +1123,7 @@ impl InMemoryAuthStoreState { wechat_states_by_token: HashMap::new(), wechat_identity_by_provider_uid, user_id_by_provider_union_id, - } + }) } fn apply_persistent_state(&mut self, next_state: Self) { @@ -1005,59 +1135,110 @@ impl InMemoryAuthStoreState { 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 to_persistent_snapshot(&self) -> PersistentAuthStoreSnapshot { - PersistentAuthStoreSnapshot { - next_user_id: self.next_user_id, - users_by_username: self.users_by_username.clone(), - phone_to_user_id: self.phone_to_user_id.clone(), - sessions_by_id: self.sessions_by_id.clone(), - session_id_by_refresh_token_hash: self.session_id_by_refresh_token_hash.clone(), - wechat_identity_by_provider_uid: self.wechat_identity_by_provider_uid.clone(), - user_id_by_provider_union_id: self.user_id_by_provider_union_id.clone(), - } - } } impl InMemoryAuthStore { - pub fn from_snapshot_json(snapshot_json: &str) -> Result { - let snapshot = serde_json::from_str::(snapshot_json) - .map_err(|error| format!("解析认证快照失败:{error}"))?; + pub fn from_projection_view(view: AuthStoreProjectionView) -> Result { Ok(Self { - inner: Arc::new(Mutex::new( - InMemoryAuthStoreState::from_persistent_snapshot(snapshot), - )), + inner: Arc::new(Mutex::new(InMemoryAuthStoreState::from_projection_view( + view, + )?)), }) } - pub fn refresh_from_snapshot_json( + pub fn refresh_from_projection_view( &self, - snapshot_json: &str, - ) -> Result { - let snapshot = serde_json::from_str::(snapshot_json) - .map_err(|error| format!("解析认证快照失败:{error}"))?; - let next_state = InMemoryAuthStoreState::from_persistent_snapshot(snapshot); - let result = RefreshAuthStoreSnapshotResult { - user_count: next_state.users_by_username.len(), - session_count: next_state.sessions_by_id.len(), - }; + view: AuthStoreProjectionView, + ) -> Result<(), String> { + let next_state = InMemoryAuthStoreState::from_projection_view(view)?; let mut state = self .inner .lock() .map_err(|_| "认证仓储锁已中毒".to_string())?; state.apply_persistent_state(next_state); - Ok(result) + Ok(()) } - pub fn export_snapshot_json(&self) -> Result { + pub fn export_projection_view( + &self, + updated_at_micros: i64, + ) -> Result { let state = self .inner .lock() .map_err(|_| "认证仓储锁已中毒".to_string())?; - let snapshot = state.to_persistent_snapshot(); - serde_json::to_string_pretty(&snapshot) - .map_err(|error| format!("序列化认证快照失败:{error}")) + let users = state + .users_by_username + .values() + .map(|stored| AuthStoreProjectionUser { + user_id: stored.user.id.clone(), + public_user_code: stored.user.public_user_code.clone(), + username: stored.user.username.clone(), + display_name: stored.user.display_name.clone(), + avatar_url: stored.user.avatar_url.clone(), + phone_number_masked: stored.user.phone_number_masked.clone(), + phone_number_e164: stored_user_phone_number(stored).map(str::to_string), + login_method: stored.user.login_method.as_str().to_string(), + binding_status: stored.user.binding_status.as_str().to_string(), + wechat_bound: stored.user.wechat_bound, + password_hash: stored.password_hash.clone(), + password_login_enabled: stored.password_login_enabled, + token_version: stored.user.token_version, + created_at: stored.user.created_at.clone(), + }) + .collect(); + let mut identities = Vec::new(); + for stored in state.users_by_username.values() { + if let Some(phone_number) = stored_user_phone_number(stored) { + identities.push(AuthStoreProjectionIdentity { + identity_id: auth_identity_id("phone", phone_number), + user_id: stored.user.id.clone(), + provider: "phone".to_string(), + provider_uid: phone_number.to_string(), + provider_union_id: None, + }); + } + } + identities.extend( + state + .wechat_identity_by_provider_uid + .values() + .map(|identity| AuthStoreProjectionIdentity { + identity_id: auth_identity_id("wechat", &identity.provider_uid), + user_id: identity.user_id.clone(), + provider: "wechat".to_string(), + provider_uid: identity.provider_uid.clone(), + provider_union_id: identity.provider_union_id.clone(), + }), + ); + let refresh_sessions = state + .sessions_by_id + .values() + .map(|stored| { + let client_info_json = serde_json::to_string(&stored.session.client_info) + .map_err(|error| format!("序列化 refresh session 客户端信息失败:{error}"))?; + Ok(AuthStoreProjectionRefreshSession { + session_id: stored.session.session_id.clone(), + user_id: stored.session.user_id.clone(), + refresh_token_hash: stored.session.refresh_token_hash.clone(), + issued_by_provider: stored.session.issued_by_provider.as_str().to_string(), + client_info_json, + expires_at: stored.session.expires_at.clone(), + revoked_at: stored.session.revoked_at.clone(), + created_at: stored.session.created_at.clone(), + updated_at: stored.session.updated_at.clone(), + last_seen_at: stored.session.last_seen_at.clone(), + }) + }) + .collect::, String>>()?; + + Ok(AuthStoreProjectionView { + updated_at_micros, + users, + identities, + refresh_sessions, + }) } fn persist_state(&self, state: &InMemoryAuthStoreState) -> Result<(), String> { @@ -1564,12 +1745,7 @@ impl InMemoryAuthStore { .values_mut() .find(|stored_user| stored_user.user.id == user_id) .ok_or(WechatAuthError::UserNotFound)?; - if let Some(display_name) = next_display_name - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - && stored_user.user.binding_status == AuthBindingStatus::PendingBindPhone - { + if let Some(display_name) = next_display_name.as_deref() { stored_user.user.display_name = display_name.to_string(); } stored_user.user.wechat_account = Some(next_provider_uid.clone()); @@ -1919,8 +2095,9 @@ impl InMemoryAuthStore { if stored_user.user.wechat_account.is_none() { stored_user.user.wechat_account = bound_wechat_account; } - if stored_user.user.wechat_display_name.is_none() { - stored_user.user.wechat_display_name = bound_wechat_display_name; + if let Some(display_name) = bound_wechat_display_name { + stored_user.user.display_name = display_name.clone(); + stored_user.user.wechat_display_name = Some(display_name); } stored_user.phone_number = Some(phone_number.e164); let next_user = stored_user.user.clone(); @@ -2451,6 +2628,39 @@ mod tests { InMemoryAuthStore::default() } + fn empty_projection_store() -> InMemoryAuthStore { + InMemoryAuthStore::from_projection_view(AuthStoreProjectionView { + updated_at_micros: 0, + users: vec![], + identities: vec![], + refresh_sessions: vec![], + }) + .expect("projection should restore") + } + + fn projection_user( + user_id: &str, + username: &str, + phone_number: Option<&str>, + ) -> AuthStoreProjectionUser { + AuthStoreProjectionUser { + user_id: user_id.to_string(), + public_user_code: "SY-00000009".to_string(), + username: username.to_string(), + display_name: "138****8009".to_string(), + avatar_url: None, + phone_number_masked: Some("138****8009".to_string()), + phone_number_e164: phone_number.map(str::to_string), + login_method: "phone".to_string(), + binding_status: "active".to_string(), + wechat_bound: false, + password_hash: "hash-existing".to_string(), + password_login_enabled: false, + token_version: 1, + created_at: "2026-06-30T00:00:00Z".to_string(), + } + } + fn build_password_service(store: InMemoryAuthStore) -> PasswordEntryService { PasswordEntryService::new(store) } @@ -2775,24 +2985,8 @@ mod tests { } #[tokio::test] - async fn dev_password_registration_ignores_orphan_phone_index() { - let snapshot = PersistentAuthStoreSnapshot { - next_user_id: 7, - users_by_username: HashMap::new(), - phone_to_user_id: HashMap::from([( - "+8613800138004".to_string(), - "user_deleted".to_string(), - )]), - sessions_by_id: HashMap::new(), - session_id_by_refresh_token_hash: HashMap::new(), - wechat_identity_by_provider_uid: HashMap::new(), - user_id_by_provider_union_id: HashMap::new(), - }; - let snapshot_json = - serde_json::to_string(&snapshot).expect("snapshot json should serialize"); - let service = build_password_service( - InMemoryAuthStore::from_snapshot_json(&snapshot_json).expect("snapshot should restore"), - ); + async fn dev_password_registration_after_empty_projection_restore() { + let service = build_password_service(empty_projection_store()); let created = service .execute_with_dev_registration(PasswordEntryInput { @@ -2810,24 +3004,8 @@ mod tests { } #[tokio::test] - async fn phone_login_ignores_orphan_phone_index_after_code_verification() { - let snapshot = PersistentAuthStoreSnapshot { - next_user_id: 8, - users_by_username: HashMap::new(), - phone_to_user_id: HashMap::from([( - "+8613800138005".to_string(), - "user_deleted".to_string(), - )]), - sessions_by_id: HashMap::new(), - session_id_by_refresh_token_hash: HashMap::new(), - wechat_identity_by_provider_uid: HashMap::new(), - user_id_by_provider_union_id: HashMap::new(), - }; - let snapshot_json = - serde_json::to_string(&snapshot).expect("snapshot json should serialize"); - let phone_service = build_phone_service( - InMemoryAuthStore::from_snapshot_json(&snapshot_json).expect("snapshot should restore"), - ); + async fn phone_login_after_empty_projection_restore() { + let phone_service = build_phone_service(empty_projection_store()); let now = OffsetDateTime::now_utc(); phone_service .send_code( @@ -2859,41 +3037,19 @@ mod tests { } #[tokio::test] - async fn phone_login_reuses_user_when_phone_only_exists_on_auth_user() { - let stored_user = StoredPasswordUser { - user: AuthUser { - id: "user_existing_phone".to_string(), - public_user_code: "SY-00000009".to_string(), - username: "phone_existing".to_string(), - display_name: "138****8009".to_string(), - avatar_url: None, - phone_number: Some("+8613800138009".to_string()), - phone_number_masked: Some("138****8009".to_string()), - login_method: AuthLoginMethod::Phone, - binding_status: AuthBindingStatus::Active, - wechat_bound: false, - wechat_display_name: None, - wechat_account: None, - token_version: 1, - created_at: "2026-06-30T00:00:00Z".to_string(), - }, - password_hash: "hash-existing".to_string(), - password_login_enabled: false, - phone_number: None, - }; - let snapshot = PersistentAuthStoreSnapshot { - next_user_id: 10, - users_by_username: HashMap::from([("phone_existing".to_string(), stored_user)]), - phone_to_user_id: HashMap::new(), - sessions_by_id: HashMap::new(), - session_id_by_refresh_token_hash: HashMap::new(), - wechat_identity_by_provider_uid: HashMap::new(), - user_id_by_provider_union_id: HashMap::new(), - }; - let snapshot_json = - serde_json::to_string(&snapshot).expect("snapshot json should serialize"); + async fn phone_login_reuses_user_restored_from_projection() { let phone_service = build_phone_service( - InMemoryAuthStore::from_snapshot_json(&snapshot_json).expect("snapshot should restore"), + InMemoryAuthStore::from_projection_view(AuthStoreProjectionView { + updated_at_micros: 1, + users: vec![projection_user( + "user_existing_phone", + "phone_existing", + Some("+8613800138009"), + )], + identities: vec![], + refresh_sessions: vec![], + }) + .expect("projection should restore"), ); let now = OffsetDateTime::now_utc(); phone_service @@ -2923,7 +3079,7 @@ mod tests { } #[tokio::test] - async fn snapshot_json_restores_user_and_refresh_session_after_roundtrip() { + async fn projection_restores_user_and_refresh_session_after_roundtrip() { let store = InMemoryAuthStore::default(); let user = create_phone_login_user(store.clone(), "13800138003").await; let password_service = build_password_service(store.clone()); @@ -2948,11 +3104,11 @@ mod tests { ) .expect("refresh session should be persisted"); - let snapshot_json = store - .export_snapshot_json() - .expect("snapshot export should succeed"); - let restored_store = InMemoryAuthStore::from_snapshot_json(&snapshot_json) - .expect("snapshot json should restore"); + let projection = store + .export_projection_view(1) + .expect("projection export should succeed"); + let restored_store = + InMemoryAuthStore::from_projection_view(projection).expect("projection should restore"); let restored_user = build_password_service(restored_store.clone()) .get_user_by_id(&user.id) .expect("restored user query should succeed") @@ -2973,7 +3129,7 @@ mod tests { } #[tokio::test] - async fn refresh_from_snapshot_json_merges_session_created_by_another_process() { + async fn refresh_from_projection_view_merges_session_created_by_another_process() { let source_store = InMemoryAuthStore::default(); let user = create_phone_login_user(source_store.clone(), "13800138033").await; let source_refresh_service = build_refresh_service(source_store.clone()); @@ -2988,9 +3144,9 @@ mod tests { OffsetDateTime::now_utc(), ) .expect("source session should create"); - let snapshot_json = source_store - .export_snapshot_json() - .expect("source snapshot should export"); + let projection = source_store + .export_projection_view(1) + .expect("source projection should export"); let local_store = InMemoryAuthStore::default(); let local_phone_service = build_phone_service(local_store.clone()); @@ -3005,12 +3161,9 @@ mod tests { ) .await .expect("local transient phone code should send"); - let refreshed = local_store - .refresh_from_snapshot_json(&snapshot_json) + local_store + .refresh_from_projection_view(projection) .expect("local store should refresh"); - - assert_eq!(refreshed.user_count, 1); - assert_eq!(refreshed.session_count, 1); assert!( build_refresh_service(local_store) .is_session_active_for_user( @@ -3035,23 +3188,8 @@ mod tests { } #[tokio::test] - async fn snapshot_json_drops_orphan_phone_index_before_phone_login() { - let snapshot = PersistentAuthStoreSnapshot { - next_user_id: 9, - users_by_username: HashMap::new(), - phone_to_user_id: HashMap::from([( - "+8613800138032".to_string(), - "user_missing_phone_owner".to_string(), - )]), - sessions_by_id: HashMap::new(), - session_id_by_refresh_token_hash: HashMap::new(), - wechat_identity_by_provider_uid: HashMap::new(), - user_id_by_provider_union_id: HashMap::new(), - }; - let snapshot_json = serde_json::to_string(&snapshot).expect("snapshot should serialize"); - let restored_store = InMemoryAuthStore::from_snapshot_json(&snapshot_json) - .expect("snapshot json should restore"); - let phone_service = build_phone_service(restored_store); + async fn empty_projection_restore_does_not_block_phone_login() { + let phone_service = build_phone_service(empty_projection_store()); let now = OffsetDateTime::now_utc(); phone_service @@ -3835,6 +3973,7 @@ mod tests { merged.user.wechat_display_name.as_deref(), Some("待绑定微信用户") ); + assert_eq!(merged.user.display_name, "待绑定微信用户"); let reused_wechat_user = wechat_service .resolve_login(ResolveWechatLoginInput { @@ -3856,44 +3995,22 @@ mod tests { reused_wechat_user.user.wechat_display_name.as_deref(), Some("已归并微信用户") ); + assert_eq!(reused_wechat_user.user.display_name, "已归并微信用户"); } #[tokio::test] - async fn bind_wechat_phone_merges_when_existing_phone_only_exists_on_auth_user() { - let stored_user = StoredPasswordUser { - user: AuthUser { - id: "user_existing_phone_bind".to_string(), - public_user_code: "SY-00000032".to_string(), - username: "phone_existing_bind".to_string(), - display_name: "138****8032".to_string(), - avatar_url: None, - phone_number: Some("+8613800138032".to_string()), - phone_number_masked: Some("138****8032".to_string()), - login_method: AuthLoginMethod::Phone, - binding_status: AuthBindingStatus::Active, - wechat_bound: false, - wechat_display_name: None, - wechat_account: None, - token_version: 1, - created_at: "2026-06-30T00:00:00Z".to_string(), - }, - password_hash: "hash-existing".to_string(), - password_login_enabled: false, - phone_number: None, - }; - let snapshot = PersistentAuthStoreSnapshot { - next_user_id: 33, - users_by_username: HashMap::from([("phone_existing_bind".to_string(), stored_user)]), - phone_to_user_id: HashMap::new(), - sessions_by_id: HashMap::new(), - session_id_by_refresh_token_hash: HashMap::new(), - wechat_identity_by_provider_uid: HashMap::new(), - user_id_by_provider_union_id: HashMap::new(), - }; - let snapshot_json = - serde_json::to_string(&snapshot).expect("snapshot json should serialize"); - let store = - InMemoryAuthStore::from_snapshot_json(&snapshot_json).expect("snapshot should restore"); + async fn bind_wechat_phone_merges_when_existing_phone_restored_from_projection() { + let store = InMemoryAuthStore::from_projection_view(AuthStoreProjectionView { + updated_at_micros: 1, + users: vec![projection_user( + "user_existing_phone_bind", + "phone_existing_bind", + Some("+8613800138032"), + )], + identities: vec![], + refresh_sessions: vec![], + }) + .expect("projection should restore"); let phone_service = build_phone_service(store.clone()); let wechat_service = WechatAuthService::new(store); let now = OffsetDateTime::now_utc(); @@ -3956,7 +4073,7 @@ mod tests { } #[tokio::test] - async fn bind_wechat_phone_keeps_account_marker_when_identity_has_no_display_name() { + async fn bind_wechat_phone_uses_submitted_display_name_when_identity_has_no_display_name() { let store = build_store(); let phone_service = build_phone_service(store.clone()); let wechat_service = WechatAuthService::new(store.clone()); @@ -4021,7 +4138,7 @@ mod tests { user_id: wechat_user.id.clone(), phone_number: "13800138031".to_string(), verify_code: "123456".to_string(), - wechat_display_name: None, + wechat_display_name: Some("补填微信昵称".to_string()), }, now + Duration::seconds(3), ) @@ -4030,7 +4147,11 @@ mod tests { assert_eq!(merged.user.id, phone_user.id); assert!(merged.user.wechat_bound); - assert_eq!(merged.user.wechat_display_name, None); + assert_eq!(merged.user.display_name, "补填微信昵称"); + assert_eq!( + merged.user.wechat_display_name.as_deref(), + Some("补填微信昵称") + ); assert_eq!( merged.user.wechat_account.as_deref(), Some("wx-openid-mini-bind") @@ -4041,7 +4162,11 @@ mod tests { .expect("user lookup should succeed") .expect("merged user should exist") .user; - assert_eq!(restored_user.wechat_display_name, None); + assert_eq!(restored_user.display_name, "补填微信昵称"); + assert_eq!( + restored_user.wechat_display_name.as_deref(), + Some("补填微信昵称") + ); assert_eq!( restored_user.wechat_account.as_deref(), Some("wx-openid-mini-bind") diff --git a/server-rs/crates/spacetime-client/Cargo.toml b/server-rs/crates/spacetime-client/Cargo.toml index 00017607d..11c08f9a1 100644 --- a/server-rs/crates/spacetime-client/Cargo.toml +++ b/server-rs/crates/spacetime-client/Cargo.toml @@ -7,6 +7,7 @@ license.workspace = true [dependencies] module-ai = { workspace = true } module-assets = { workspace = true } +module-auth = { workspace = true } module-big-fish = { workspace = true } module-combat = { workspace = true } module-custom-world = { workspace = true } diff --git a/server-rs/crates/spacetime-client/src/auth.rs b/server-rs/crates/spacetime-client/src/auth.rs index 057de7813..89f14e61a 100644 --- a/server-rs/crates/spacetime-client/src/auth.rs +++ b/server-rs/crates/spacetime-client/src/auth.rs @@ -1,18 +1,18 @@ use super::*; impl SpacetimeClient { - pub async fn export_auth_store_snapshot_from_tables( + pub async fn export_auth_store_projection_from_tables( &self, - ) -> Result { + ) -> Result { self.call_after_connect( - "export_auth_store_snapshot_from_tables", + "export_auth_store_projection_from_tables", move |connection, sender| { connection .procedures() - .export_auth_store_snapshot_from_tables_then(move |_, result| { + .export_auth_store_projection_from_tables_then(move |_, result| { let mapped = result .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_auth_store_snapshot_procedure_result); + .and_then(map_auth_store_projection_procedure_result); send_once(&sender, mapped); }); }, @@ -20,29 +20,23 @@ impl SpacetimeClient { .await } - pub async fn import_auth_store_snapshot_json( + pub async fn sync_auth_store_projection( &self, - snapshot_json: String, - updated_at_micros: i64, - ) -> Result { - let procedure_input = AuthStoreSnapshotUpsertInput { - snapshot_json, - updated_at_micros, - }; + view: module_auth::AuthStoreProjectionView, + ) -> Result { + let procedure_input = map_auth_store_projection_view_input(view); - self.call_after_connect( - "import_auth_store_snapshot_json", - move |connection, sender| { - connection - .procedures() - .import_auth_store_snapshot_json_then(procedure_input, move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_auth_store_snapshot_import_procedure_result); - send_once(&sender, mapped); - }); - }, - ) + self.call_after_connect("sync_auth_store_projection", move |connection, sender| { + connection.procedures().sync_auth_store_projection_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_auth_store_projection_sync_procedure_result); + send_once(&sender, mapped); + }, + ); + }) .await } } diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index 01215ad45..2f1353442 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -333,13 +333,7 @@ impl SpacetimeClientHealthSnapshot { } #[derive(Clone, Debug, PartialEq, Eq)] -pub struct AuthStoreSnapshotRecord { - pub snapshot_json: Option, - pub updated_at_micros: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct AuthStoreSnapshotImportRecord { +pub struct AuthStoreProjectionSyncRecord { pub imported_user_count: u32, pub imported_identity_count: u32, pub imported_refresh_session_count: u32, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index e284107b2..10247312e 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -179,7 +179,8 @@ pub(crate) use self::assets::{ map_asset_object_row, map_entity_binding_procedure_result, map_procedure_result, }; pub(crate) use self::auth::{ - map_auth_store_snapshot_import_procedure_result, map_auth_store_snapshot_procedure_result, + map_auth_store_projection_procedure_result, map_auth_store_projection_sync_procedure_result, + map_auth_store_projection_view_input, }; pub(crate) use self::bark_battle::{ map_bark_battle_draft_config_procedure_result, map_bark_battle_draft_config_row, diff --git a/server-rs/crates/spacetime-client/src/mapper/auth.rs b/server-rs/crates/spacetime-client/src/mapper/auth.rs index 1012acc2e..e0ba264bf 100644 --- a/server-rs/crates/spacetime-client/src/mapper/auth.rs +++ b/server-rs/crates/spacetime-client/src/mapper/auth.rs @@ -1,42 +1,146 @@ use super::*; -pub(crate) fn map_auth_store_snapshot_procedure_result( - result: AuthStoreSnapshotProcedureResult, -) -> Result { +pub(crate) fn map_auth_store_projection_procedure_result( + result: crate::module_bindings::AuthStoreProjectionProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + result + .record + .map(map_auth_store_projection_view) + .ok_or_else(|| SpacetimeClientError::missing_snapshot("认证投影")) +} + +pub(crate) fn map_auth_store_projection_sync_procedure_result( + result: crate::module_bindings::AuthStoreProjectionSyncProcedureResult, +) -> Result { if !result.ok { return Err(SpacetimeClientError::procedure_failed(result.error_message)); } let record = result .record - .ok_or_else(|| SpacetimeClientError::missing_snapshot("认证快照"))?; + .ok_or_else(|| SpacetimeClientError::missing_snapshot("认证投影同步结果"))?; - Ok(map_auth_store_snapshot_record(record)) -} - -pub(crate) fn map_auth_store_snapshot_record( - record: crate::module_bindings::AuthStoreSnapshotRecord, -) -> crate::AuthStoreSnapshotRecord { - crate::AuthStoreSnapshotRecord { - snapshot_json: record.snapshot_json, - updated_at_micros: record.updated_at_micros, - } -} - -pub(crate) fn map_auth_store_snapshot_import_procedure_result( - result: AuthStoreSnapshotImportProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let record = result - .record - .ok_or_else(|| SpacetimeClientError::missing_snapshot("认证快照导入结果"))?; - - Ok(AuthStoreSnapshotImportRecord { + Ok(AuthStoreProjectionSyncRecord { imported_user_count: record.imported_user_count, imported_identity_count: record.imported_identity_count, imported_refresh_session_count: record.imported_refresh_session_count, }) } + +pub(crate) fn map_auth_store_projection_view_input( + view: module_auth::AuthStoreProjectionView, +) -> crate::module_bindings::AuthStoreProjectionView { + crate::module_bindings::AuthStoreProjectionView { + updated_at_micros: view.updated_at_micros, + users: view + .users + .into_iter() + .map(|user| crate::module_bindings::AuthStoreProjectionUser { + user_id: user.user_id, + public_user_code: user.public_user_code, + username: user.username, + display_name: user.display_name, + avatar_url: user.avatar_url, + phone_number_masked: user.phone_number_masked, + phone_number_e_164: user.phone_number_e164, + login_method: user.login_method, + binding_status: user.binding_status, + wechat_bound: user.wechat_bound, + password_hash: user.password_hash, + password_login_enabled: user.password_login_enabled, + token_version: user.token_version, + created_at: user.created_at, + }) + .collect(), + identities: view + .identities + .into_iter() + .map( + |identity| crate::module_bindings::AuthStoreProjectionIdentity { + identity_id: identity.identity_id, + user_id: identity.user_id, + provider: identity.provider, + provider_uid: identity.provider_uid, + provider_union_id: identity.provider_union_id, + }, + ) + .collect(), + refresh_sessions: view + .refresh_sessions + .into_iter() + .map( + |session| crate::module_bindings::AuthStoreProjectionRefreshSession { + session_id: session.session_id, + user_id: session.user_id, + refresh_token_hash: session.refresh_token_hash, + issued_by_provider: session.issued_by_provider, + client_info_json: session.client_info_json, + expires_at: session.expires_at, + revoked_at: session.revoked_at, + created_at: session.created_at, + updated_at: session.updated_at, + last_seen_at: session.last_seen_at, + }, + ) + .collect(), + } +} + +fn map_auth_store_projection_view( + view: crate::module_bindings::AuthStoreProjectionView, +) -> module_auth::AuthStoreProjectionView { + module_auth::AuthStoreProjectionView { + updated_at_micros: view.updated_at_micros, + users: view + .users + .into_iter() + .map(|user| module_auth::AuthStoreProjectionUser { + user_id: user.user_id, + public_user_code: user.public_user_code, + username: user.username, + display_name: user.display_name, + avatar_url: user.avatar_url, + phone_number_masked: user.phone_number_masked, + phone_number_e164: user.phone_number_e_164, + login_method: user.login_method, + binding_status: user.binding_status, + wechat_bound: user.wechat_bound, + password_hash: user.password_hash, + password_login_enabled: user.password_login_enabled, + token_version: user.token_version, + created_at: user.created_at, + }) + .collect(), + identities: view + .identities + .into_iter() + .map(|identity| module_auth::AuthStoreProjectionIdentity { + identity_id: identity.identity_id, + user_id: identity.user_id, + provider: identity.provider, + provider_uid: identity.provider_uid, + provider_union_id: identity.provider_union_id, + }) + .collect(), + refresh_sessions: view + .refresh_sessions + .into_iter() + .map(|session| module_auth::AuthStoreProjectionRefreshSession { + session_id: session.session_id, + user_id: session.user_id, + refresh_token_hash: session.refresh_token_hash, + issued_by_provider: session.issued_by_provider, + client_info_json: session.client_info_json, + expires_at: session.expires_at, + revoked_at: session.revoked_at, + created_at: session.created_at, + updated_at: session.updated_at, + last_seen_at: session.last_seen_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 c0691341c..ba4ae7c9a 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -95,15 +95,15 @@ pub mod asset_object_upsert_snapshot_type; pub mod attach_ai_result_reference_and_return_procedure; pub mod auth_identity_table; pub mod auth_identity_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_snapshot_import_procedure_result_type; -pub mod auth_store_snapshot_import_record_type; -pub mod auth_store_snapshot_procedure_result_type; -pub mod auth_store_snapshot_record_type; -pub mod auth_store_snapshot_table; -pub mod auth_store_snapshot_type; -pub mod auth_store_snapshot_upsert_input_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 authenticate_external_api_key_and_return_procedure; pub mod authorize_database_migration_operator_procedure; pub mod bark_battle_draft_config_row_type; @@ -407,7 +407,7 @@ pub mod enqueue_external_generation_job_and_return_procedure; pub mod ensure_analytics_date_dimension_for_date_reducer; pub mod equip_inventory_item_input_type; pub mod execute_custom_world_agent_action_procedure; -pub mod export_auth_store_snapshot_from_tables_procedure; +pub mod export_auth_store_projection_from_tables_procedure; pub mod export_database_migration_to_file_procedure; pub mod external_api_key_authenticate_input_type; pub mod external_api_key_create_input_type; @@ -502,7 +502,6 @@ pub mod grant_inventory_item_input_type; pub mod grant_new_user_registration_wallet_reward_procedure; pub mod grant_player_progression_experience_and_return_procedure; pub mod grant_player_progression_experience_reducer; -pub mod import_auth_store_snapshot_json_procedure; pub mod import_database_migration_from_chunks_procedure; pub mod import_database_migration_from_file_procedure; pub mod import_database_migration_incremental_from_chunks_procedure; @@ -1107,6 +1106,7 @@ pub mod submit_square_hole_agent_message_procedure; pub mod submit_visual_novel_agent_message_procedure; pub mod swap_puzzle_clear_cards_procedure; pub mod swap_puzzle_pieces_procedure; +pub mod sync_auth_store_projection_procedure; pub mod tracking_daily_stat_table; pub mod tracking_daily_stat_type; pub mod tracking_event_table; @@ -1323,15 +1323,15 @@ pub use asset_object_upsert_snapshot_type::AssetObjectUpsertSnapshot; 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_store_projection_identity_type::AuthStoreProjectionIdentity; pub use auth_store_projection_meta_table::*; pub use auth_store_projection_meta_type::AuthStoreProjectionMeta; -pub use auth_store_snapshot_import_procedure_result_type::AuthStoreSnapshotImportProcedureResult; -pub use auth_store_snapshot_import_record_type::AuthStoreSnapshotImportRecord; -pub use auth_store_snapshot_procedure_result_type::AuthStoreSnapshotProcedureResult; -pub use auth_store_snapshot_record_type::AuthStoreSnapshotRecord; -pub use auth_store_snapshot_table::*; -pub use auth_store_snapshot_type::AuthStoreSnapshot; -pub use auth_store_snapshot_upsert_input_type::AuthStoreSnapshotUpsertInput; +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 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 bark_battle_draft_config_row_type::BarkBattleDraftConfigRow; @@ -1635,7 +1635,7 @@ pub use enqueue_external_generation_job_and_return_procedure::enqueue_external_g pub use ensure_analytics_date_dimension_for_date_reducer::ensure_analytics_date_dimension_for_date; pub use equip_inventory_item_input_type::EquipInventoryItemInput; pub use execute_custom_world_agent_action_procedure::execute_custom_world_agent_action; -pub use export_auth_store_snapshot_from_tables_procedure::export_auth_store_snapshot_from_tables; +pub use export_auth_store_projection_from_tables_procedure::export_auth_store_projection_from_tables; pub use export_database_migration_to_file_procedure::export_database_migration_to_file; pub use external_api_key_authenticate_input_type::ExternalApiKeyAuthenticateInput; pub use external_api_key_create_input_type::ExternalApiKeyCreateInput; @@ -1730,7 +1730,6 @@ pub use grant_inventory_item_input_type::GrantInventoryItemInput; pub use grant_new_user_registration_wallet_reward_procedure::grant_new_user_registration_wallet_reward; pub use grant_player_progression_experience_and_return_procedure::grant_player_progression_experience_and_return; pub use grant_player_progression_experience_reducer::grant_player_progression_experience; -pub use import_auth_store_snapshot_json_procedure::import_auth_store_snapshot_json; pub use import_database_migration_from_chunks_procedure::import_database_migration_from_chunks; pub use import_database_migration_from_file_procedure::import_database_migration_from_file; pub use import_database_migration_incremental_from_chunks_procedure::import_database_migration_incremental_from_chunks; @@ -2335,6 +2334,7 @@ pub use submit_square_hole_agent_message_procedure::submit_square_hole_agent_mes pub use submit_visual_novel_agent_message_procedure::submit_visual_novel_agent_message; pub use swap_puzzle_clear_cards_procedure::swap_puzzle_clear_cards; pub use swap_puzzle_pieces_procedure::swap_puzzle_pieces; +pub use sync_auth_store_projection_procedure::sync_auth_store_projection; pub use tracking_daily_stat_table::*; pub use tracking_daily_stat_type::TrackingDailyStat; pub use tracking_event_table::*; @@ -2745,7 +2745,6 @@ pub struct DbUpdate { asset_object: __sdk::TableUpdate, auth_identity: __sdk::TableUpdate, auth_store_projection_meta: __sdk::TableUpdate, - auth_store_snapshot: __sdk::TableUpdate, bark_battle_draft_config: __sdk::TableUpdate, bark_battle_gallery_view: __sdk::TableUpdate, bark_battle_leaderboard_entry: __sdk::TableUpdate, @@ -2902,9 +2901,6 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "auth_store_projection_meta" => db_update.auth_store_projection_meta.append( auth_store_projection_meta_table::parse_table_update(table_update)?, ), - "auth_store_snapshot" => db_update - .auth_store_snapshot - .append(auth_store_snapshot_table::parse_table_update(table_update)?), "bark_battle_draft_config" => db_update.bark_battle_draft_config.append( bark_battle_draft_config_table::parse_table_update(table_update)?, ), @@ -3333,12 +3329,6 @@ impl __sdk::DbUpdate for DbUpdate { &self.auth_store_projection_meta, ) .with_updates_by_pk(|row| &row.meta_id); - diff.auth_store_snapshot = cache - .apply_diff_to_table::( - "auth_store_snapshot", - &self.auth_store_snapshot, - ) - .with_updates_by_pk(|row| &row.snapshot_id); diff.bark_battle_draft_config = cache .apply_diff_to_table::( "bark_battle_draft_config", @@ -3966,9 +3956,6 @@ impl __sdk::DbUpdate for DbUpdate { "auth_store_projection_meta" => db_update .auth_store_projection_meta .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), - "auth_store_snapshot" => db_update - .auth_store_snapshot - .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "bark_battle_draft_config" => db_update .bark_battle_draft_config .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -4360,9 +4347,6 @@ impl __sdk::DbUpdate for DbUpdate { "auth_store_projection_meta" => db_update .auth_store_projection_meta .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), - "auth_store_snapshot" => db_update - .auth_store_snapshot - .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "bark_battle_draft_config" => db_update .bark_battle_draft_config .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -4734,7 +4718,6 @@ pub struct AppliedDiff<'r> { asset_object: __sdk::TableAppliedDiff<'r, AssetObject>, auth_identity: __sdk::TableAppliedDiff<'r, AuthIdentity>, auth_store_projection_meta: __sdk::TableAppliedDiff<'r, AuthStoreProjectionMeta>, - auth_store_snapshot: __sdk::TableAppliedDiff<'r, AuthStoreSnapshot>, bark_battle_draft_config: __sdk::TableAppliedDiff<'r, BarkBattleDraftConfigRow>, bark_battle_gallery_view: __sdk::TableAppliedDiff<'r, BarkBattleGalleryViewRow>, bark_battle_leaderboard_entry: __sdk::TableAppliedDiff<'r, BarkBattleLeaderboardEntryRow>, @@ -4913,11 +4896,6 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.auth_store_projection_meta, event, ); - callbacks.invoke_table_row_callbacks::( - "auth_store_snapshot", - &self.auth_store_snapshot, - event, - ); callbacks.invoke_table_row_callbacks::( "bark_battle_draft_config", &self.bark_battle_draft_config, @@ -6152,7 +6130,6 @@ impl __sdk::SpacetimeModule for RemoteModule { asset_object_table::register_table(client_cache); auth_identity_table::register_table(client_cache); auth_store_projection_meta_table::register_table(client_cache); - auth_store_snapshot_table::register_table(client_cache); bark_battle_draft_config_table::register_table(client_cache); bark_battle_gallery_view_table::register_table(client_cache); bark_battle_leaderboard_entry_table::register_table(client_cache); @@ -6281,7 +6258,6 @@ impl __sdk::SpacetimeModule for RemoteModule { "asset_object", "auth_identity", "auth_store_projection_meta", - "auth_store_snapshot", "bark_battle_draft_config", "bark_battle_gallery_view", "bark_battle_leaderboard_entry", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/auth_store_snapshot_upsert_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_identity_type.rs similarity index 61% rename from server-rs/crates/spacetime-client/src/module_bindings/auth_store_snapshot_upsert_input_type.rs rename to server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_identity_type.rs index f116b6aa8..d3a420f33 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/auth_store_snapshot_upsert_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_identity_type.rs @@ -6,11 +6,14 @@ 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 AuthStoreSnapshotUpsertInput { - pub snapshot_json: String, - pub updated_at_micros: i64, +pub struct AuthStoreProjectionIdentity { + pub identity_id: String, + pub user_id: String, + pub provider: String, + pub provider_uid: String, + pub provider_union_id: Option, } -impl __sdk::InModule for AuthStoreSnapshotUpsertInput { +impl __sdk::InModule for AuthStoreProjectionIdentity { type Module = super::RemoteModule; } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/auth_store_snapshot_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_procedure_result_type.rs similarity index 66% rename from server-rs/crates/spacetime-client/src/module_bindings/auth_store_snapshot_procedure_result_type.rs rename to server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_procedure_result_type.rs index af1cdd4d6..5cbbe391d 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/auth_store_snapshot_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_procedure_result_type.rs @@ -4,16 +4,16 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; -use super::auth_store_snapshot_record_type::AuthStoreSnapshotRecord; +use super::auth_store_projection_view_type::AuthStoreProjectionView; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] -pub struct AuthStoreSnapshotProcedureResult { +pub struct AuthStoreProjectionProcedureResult { pub ok: bool, - pub record: Option, + pub record: Option, pub error_message: Option, } -impl __sdk::InModule for AuthStoreSnapshotProcedureResult { +impl __sdk::InModule for AuthStoreProjectionProcedureResult { type Module = super::RemoteModule; } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_refresh_session_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_refresh_session_type.rs new file mode 100644 index 000000000..a3731a541 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_refresh_session_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 AuthStoreProjectionRefreshSession { + pub session_id: String, + pub user_id: String, + pub refresh_token_hash: String, + pub issued_by_provider: String, + pub client_info_json: String, + pub expires_at: String, + pub revoked_at: Option, + pub created_at: String, + pub updated_at: String, + pub last_seen_at: String, +} + +impl __sdk::InModule for AuthStoreProjectionRefreshSession { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/auth_store_snapshot_import_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_sync_procedure_result_type.rs similarity index 64% rename from server-rs/crates/spacetime-client/src/module_bindings/auth_store_snapshot_import_procedure_result_type.rs rename to server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_sync_procedure_result_type.rs index 81d27b2c1..799493143 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/auth_store_snapshot_import_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_sync_procedure_result_type.rs @@ -4,16 +4,16 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; -use super::auth_store_snapshot_import_record_type::AuthStoreSnapshotImportRecord; +use super::auth_store_projection_sync_record_type::AuthStoreProjectionSyncRecord; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] -pub struct AuthStoreSnapshotImportProcedureResult { +pub struct AuthStoreProjectionSyncProcedureResult { pub ok: bool, - pub record: Option, + pub record: Option, pub error_message: Option, } -impl __sdk::InModule for AuthStoreSnapshotImportProcedureResult { +impl __sdk::InModule for AuthStoreProjectionSyncProcedureResult { type Module = super::RemoteModule; } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/auth_store_snapshot_import_record_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_sync_record_type.rs similarity index 83% rename from server-rs/crates/spacetime-client/src/module_bindings/auth_store_snapshot_import_record_type.rs rename to server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_sync_record_type.rs index 43f975843..d2641c858 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/auth_store_snapshot_import_record_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_sync_record_type.rs @@ -6,12 +6,12 @@ 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 AuthStoreSnapshotImportRecord { +pub struct AuthStoreProjectionSyncRecord { pub imported_user_count: u32, pub imported_identity_count: u32, pub imported_refresh_session_count: u32, } -impl __sdk::InModule for AuthStoreSnapshotImportRecord { +impl __sdk::InModule for AuthStoreProjectionSyncRecord { type Module = super::RemoteModule; } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_user_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_user_type.rs new file mode 100644 index 000000000..d2baf13fb --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_user_type.rs @@ -0,0 +1,28 @@ +// 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 AuthStoreProjectionUser { + pub user_id: String, + pub public_user_code: String, + pub username: String, + pub display_name: String, + pub avatar_url: Option, + pub phone_number_masked: Option, + pub phone_number_e_164: Option, + pub login_method: String, + pub binding_status: String, + pub wechat_bound: bool, + pub password_hash: String, + pub password_login_enabled: bool, + pub token_version: u64, + pub created_at: String, +} + +impl __sdk::InModule for AuthStoreProjectionUser { + 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 new file mode 100644 index 000000000..fb99a128c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_projection_view_type.rs @@ -0,0 +1,22 @@ +// 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_store_projection_identity_type::AuthStoreProjectionIdentity; +use super::auth_store_projection_refresh_session_type::AuthStoreProjectionRefreshSession; +use super::auth_store_projection_user_type::AuthStoreProjectionUser; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AuthStoreProjectionView { + pub updated_at_micros: i64, + pub users: Vec, + pub identities: Vec, + pub refresh_sessions: Vec, +} + +impl __sdk::InModule for AuthStoreProjectionView { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/auth_store_snapshot_record_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_snapshot_record_type.rs deleted file mode 100644 index a70ecfd88..000000000 --- a/server-rs/crates/spacetime-client/src/module_bindings/auth_store_snapshot_record_type.rs +++ /dev/null @@ -1,16 +0,0 @@ -// 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 AuthStoreSnapshotRecord { - pub snapshot_json: Option, - pub updated_at_micros: Option, -} - -impl __sdk::InModule for AuthStoreSnapshotRecord { - type Module = super::RemoteModule; -} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/auth_store_snapshot_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_snapshot_table.rs deleted file mode 100644 index 97b00b3d1..000000000 --- a/server-rs/crates/spacetime-client/src/module_bindings/auth_store_snapshot_table.rs +++ /dev/null @@ -1,161 +0,0 @@ -// 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::auth_store_snapshot_type::AuthStoreSnapshot; -use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; - -/// Table handle for the table `auth_store_snapshot`. -/// -/// Obtain a handle from the [`AuthStoreSnapshotTableAccess::auth_store_snapshot`] method on [`super::RemoteTables`], -/// like `ctx.db.auth_store_snapshot()`. -/// -/// Users are encouraged not to explicitly reference this type, -/// but to directly chain method calls, -/// like `ctx.db.auth_store_snapshot().on_insert(...)`. -pub struct AuthStoreSnapshotTableHandle<'ctx> { - imp: __sdk::TableHandle, - ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, -} - -#[allow(non_camel_case_types)] -/// Extension trait for access to the table `auth_store_snapshot`. -/// -/// Implemented for [`super::RemoteTables`]. -pub trait AuthStoreSnapshotTableAccess { - #[allow(non_snake_case)] - /// Obtain a [`AuthStoreSnapshotTableHandle`], which mediates access to the table `auth_store_snapshot`. - fn auth_store_snapshot(&self) -> AuthStoreSnapshotTableHandle<'_>; -} - -impl AuthStoreSnapshotTableAccess for super::RemoteTables { - fn auth_store_snapshot(&self) -> AuthStoreSnapshotTableHandle<'_> { - AuthStoreSnapshotTableHandle { - imp: self - .imp - .get_table::("auth_store_snapshot"), - ctx: std::marker::PhantomData, - } - } -} - -pub struct AuthStoreSnapshotInsertCallbackId(__sdk::CallbackId); -pub struct AuthStoreSnapshotDeleteCallbackId(__sdk::CallbackId); - -impl<'ctx> __sdk::Table for AuthStoreSnapshotTableHandle<'ctx> { - type Row = AuthStoreSnapshot; - type EventContext = super::EventContext; - - fn count(&self) -> u64 { - self.imp.count() - } - fn iter(&self) -> impl Iterator + '_ { - self.imp.iter() - } - - type InsertCallbackId = AuthStoreSnapshotInsertCallbackId; - - fn on_insert( - &self, - callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, - ) -> AuthStoreSnapshotInsertCallbackId { - AuthStoreSnapshotInsertCallbackId(self.imp.on_insert(Box::new(callback))) - } - - fn remove_on_insert(&self, callback: AuthStoreSnapshotInsertCallbackId) { - self.imp.remove_on_insert(callback.0) - } - - type DeleteCallbackId = AuthStoreSnapshotDeleteCallbackId; - - fn on_delete( - &self, - callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, - ) -> AuthStoreSnapshotDeleteCallbackId { - AuthStoreSnapshotDeleteCallbackId(self.imp.on_delete(Box::new(callback))) - } - - fn remove_on_delete(&self, callback: AuthStoreSnapshotDeleteCallbackId) { - self.imp.remove_on_delete(callback.0) - } -} - -pub struct AuthStoreSnapshotUpdateCallbackId(__sdk::CallbackId); - -impl<'ctx> __sdk::TableWithPrimaryKey for AuthStoreSnapshotTableHandle<'ctx> { - type UpdateCallbackId = AuthStoreSnapshotUpdateCallbackId; - - fn on_update( - &self, - callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, - ) -> AuthStoreSnapshotUpdateCallbackId { - AuthStoreSnapshotUpdateCallbackId(self.imp.on_update(Box::new(callback))) - } - - fn remove_on_update(&self, callback: AuthStoreSnapshotUpdateCallbackId) { - self.imp.remove_on_update(callback.0) - } -} - -/// Access to the `snapshot_id` unique index on the table `auth_store_snapshot`, -/// which allows point queries on the field of the same name -/// via the [`AuthStoreSnapshotSnapshotIdUnique::find`] method. -/// -/// Users are encouraged not to explicitly reference this type, -/// but to directly chain method calls, -/// like `ctx.db.auth_store_snapshot().snapshot_id().find(...)`. -pub struct AuthStoreSnapshotSnapshotIdUnique<'ctx> { - imp: __sdk::UniqueConstraintHandle, - phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, -} - -impl<'ctx> AuthStoreSnapshotTableHandle<'ctx> { - /// Get a handle on the `snapshot_id` unique index on the table `auth_store_snapshot`. - pub fn snapshot_id(&self) -> AuthStoreSnapshotSnapshotIdUnique<'ctx> { - AuthStoreSnapshotSnapshotIdUnique { - imp: self.imp.get_unique_constraint::("snapshot_id"), - phantom: std::marker::PhantomData, - } - } -} - -impl<'ctx> AuthStoreSnapshotSnapshotIdUnique<'ctx> { - /// Find the subscribed row whose `snapshot_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::("auth_store_snapshot"); - _table.add_unique_constraint::("snapshot_id", |row| &row.snapshot_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 `AuthStoreSnapshot`. -/// -/// Implemented for [`__sdk::QueryTableAccessor`]. -pub trait auth_store_snapshotQueryTableAccess { - #[allow(non_snake_case)] - /// Get a query builder for the table `AuthStoreSnapshot`. - fn auth_store_snapshot(&self) -> __sdk::__query_builder::Table; -} - -impl auth_store_snapshotQueryTableAccess for __sdk::QueryTableAccessor { - fn auth_store_snapshot(&self) -> __sdk::__query_builder::Table { - __sdk::__query_builder::Table::new("auth_store_snapshot") - } -} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/auth_store_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/auth_store_snapshot_type.rs deleted file mode 100644 index 73beb796e..000000000 --- a/server-rs/crates/spacetime-client/src/module_bindings/auth_store_snapshot_type.rs +++ /dev/null @@ -1,55 +0,0 @@ -// 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 AuthStoreSnapshot { - pub snapshot_id: String, - pub snapshot_json: String, - pub updated_at: __sdk::Timestamp, -} - -impl __sdk::InModule for AuthStoreSnapshot { - type Module = super::RemoteModule; -} - -/// Column accessor struct for the table `AuthStoreSnapshot`. -/// -/// Provides typed access to columns for query building. -pub struct AuthStoreSnapshotCols { - pub snapshot_id: __sdk::__query_builder::Col, - pub snapshot_json: __sdk::__query_builder::Col, - pub updated_at: __sdk::__query_builder::Col, -} - -impl __sdk::__query_builder::HasCols for AuthStoreSnapshot { - type Cols = AuthStoreSnapshotCols; - fn cols(table_name: &'static str) -> Self::Cols { - AuthStoreSnapshotCols { - snapshot_id: __sdk::__query_builder::Col::new(table_name, "snapshot_id"), - snapshot_json: __sdk::__query_builder::Col::new(table_name, "snapshot_json"), - updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), - } - } -} - -/// Indexed column accessor struct for the table `AuthStoreSnapshot`. -/// -/// Provides typed access to indexed columns for query building. -pub struct AuthStoreSnapshotIxCols { - pub snapshot_id: __sdk::__query_builder::IxCol, -} - -impl __sdk::__query_builder::HasIxCols for AuthStoreSnapshot { - type IxCols = AuthStoreSnapshotIxCols; - fn ix_cols(table_name: &'static str) -> Self::IxCols { - AuthStoreSnapshotIxCols { - snapshot_id: __sdk::__query_builder::IxCol::new(table_name, "snapshot_id"), - } - } -} - -impl __sdk::__query_builder::CanBeLookupTable for AuthStoreSnapshot {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/export_auth_store_projection_from_tables_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/export_auth_store_projection_from_tables_procedure.rs new file mode 100644 index 000000000..ece547ce8 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/export_auth_store_projection_from_tables_procedure.rs @@ -0,0 +1,54 @@ +// 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_store_projection_procedure_result_type::AuthStoreProjectionProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ExportAuthStoreProjectionFromTablesArgs {} + +impl __sdk::InModule for ExportAuthStoreProjectionFromTablesArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `export_auth_store_projection_from_tables`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait export_auth_store_projection_from_tables { + fn export_auth_store_projection_from_tables(&self) { + self.export_auth_store_projection_from_tables_then(|_, _| {}); + } + + fn export_auth_store_projection_from_tables_then( + &self, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl export_auth_store_projection_from_tables for super::RemoteProcedures { + fn export_auth_store_projection_from_tables_then( + &self, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, AuthStoreProjectionProcedureResult>( + "export_auth_store_projection_from_tables", + ExportAuthStoreProjectionFromTablesArgs {}, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/export_auth_store_snapshot_from_tables_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/export_auth_store_snapshot_from_tables_procedure.rs deleted file mode 100644 index 9f8842ad6..000000000 --- a/server-rs/crates/spacetime-client/src/module_bindings/export_auth_store_snapshot_from_tables_procedure.rs +++ /dev/null @@ -1,54 +0,0 @@ -// 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_store_snapshot_procedure_result_type::AuthStoreSnapshotProcedureResult; - -#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] -#[sats(crate = __lib)] -struct ExportAuthStoreSnapshotFromTablesArgs {} - -impl __sdk::InModule for ExportAuthStoreSnapshotFromTablesArgs { - type Module = super::RemoteModule; -} - -#[allow(non_camel_case_types)] -/// Extension trait for access to the procedure `export_auth_store_snapshot_from_tables`. -/// -/// Implemented for [`super::RemoteProcedures`]. -pub trait export_auth_store_snapshot_from_tables { - fn export_auth_store_snapshot_from_tables(&self) { - self.export_auth_store_snapshot_from_tables_then(|_, _| {}); - } - - fn export_auth_store_snapshot_from_tables_then( - &self, - - __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, - ); -} - -impl export_auth_store_snapshot_from_tables for super::RemoteProcedures { - fn export_auth_store_snapshot_from_tables_then( - &self, - - __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, - ) { - self.imp - .invoke_procedure_with_callback::<_, AuthStoreSnapshotProcedureResult>( - "export_auth_store_snapshot_from_tables", - ExportAuthStoreSnapshotFromTablesArgs {}, - __callback, - ); - } -} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/import_auth_store_snapshot_json_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/import_auth_store_snapshot_json_procedure.rs deleted file mode 100644 index 68171d4d6..000000000 --- a/server-rs/crates/spacetime-client/src/module_bindings/import_auth_store_snapshot_json_procedure.rs +++ /dev/null @@ -1,59 +0,0 @@ -// 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_store_snapshot_import_procedure_result_type::AuthStoreSnapshotImportProcedureResult; -use super::auth_store_snapshot_upsert_input_type::AuthStoreSnapshotUpsertInput; - -#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] -#[sats(crate = __lib)] -struct ImportAuthStoreSnapshotJsonArgs { - pub input: AuthStoreSnapshotUpsertInput, -} - -impl __sdk::InModule for ImportAuthStoreSnapshotJsonArgs { - type Module = super::RemoteModule; -} - -#[allow(non_camel_case_types)] -/// Extension trait for access to the procedure `import_auth_store_snapshot_json`. -/// -/// Implemented for [`super::RemoteProcedures`]. -pub trait import_auth_store_snapshot_json { - fn import_auth_store_snapshot_json(&self, input: AuthStoreSnapshotUpsertInput) { - self.import_auth_store_snapshot_json_then(input, |_, _| {}); - } - - fn import_auth_store_snapshot_json_then( - &self, - input: AuthStoreSnapshotUpsertInput, - - __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, - ); -} - -impl import_auth_store_snapshot_json for super::RemoteProcedures { - fn import_auth_store_snapshot_json_then( - &self, - input: AuthStoreSnapshotUpsertInput, - - __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, - ) { - self.imp - .invoke_procedure_with_callback::<_, AuthStoreSnapshotImportProcedureResult>( - "import_auth_store_snapshot_json", - ImportAuthStoreSnapshotJsonArgs { input }, - __callback, - ); - } -} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/sync_auth_store_projection_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/sync_auth_store_projection_procedure.rs new file mode 100644 index 000000000..7d4803e27 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/sync_auth_store_projection_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_store_projection_sync_procedure_result_type::AuthStoreProjectionSyncProcedureResult; +use super::auth_store_projection_view_type::AuthStoreProjectionView; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct SyncAuthStoreProjectionArgs { + pub input: AuthStoreProjectionView, +} + +impl __sdk::InModule for SyncAuthStoreProjectionArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `sync_auth_store_projection`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait sync_auth_store_projection { + fn sync_auth_store_projection(&self, input: AuthStoreProjectionView) { + self.sync_auth_store_projection_then(input, |_, _| {}); + } + + fn sync_auth_store_projection_then( + &self, + input: AuthStoreProjectionView, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl sync_auth_store_projection for super::RemoteProcedures { + fn sync_auth_store_projection_then( + &self, + input: AuthStoreProjectionView, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, AuthStoreProjectionSyncProcedureResult>( + "sync_auth_store_projection", + SyncAuthStoreProjectionArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/user_account_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/user_account_type.rs index fe8fed515..eb386ef38 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/user_account_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/user_account_type.rs @@ -21,6 +21,7 @@ pub struct UserAccount { pub password_login_enabled: bool, pub token_version: u64, pub user_tags: Option>, + pub created_at: Option, } impl __sdk::InModule for UserAccount { @@ -45,6 +46,7 @@ pub struct UserAccountCols { pub password_login_enabled: __sdk::__query_builder::Col, pub token_version: __sdk::__query_builder::Col, pub user_tags: __sdk::__query_builder::Col>>, + pub created_at: __sdk::__query_builder::Col>, } impl __sdk::__query_builder::HasCols for UserAccount { @@ -71,6 +73,7 @@ impl __sdk::__query_builder::HasCols for UserAccount { ), token_version: __sdk::__query_builder::Col::new(table_name, "token_version"), user_tags: __sdk::__query_builder::Col::new(table_name, "user_tags"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), } } } diff --git a/server-rs/crates/spacetime-module/src/auth.rs b/server-rs/crates/spacetime-module/src/auth.rs index fc02198be..33a9ee00c 100644 --- a/server-rs/crates/spacetime-module/src/auth.rs +++ b/server-rs/crates/spacetime-module/src/auth.rs @@ -1,4 +1,3 @@ -mod mapper; mod procedures; mod tables; diff --git a/server-rs/crates/spacetime-module/src/auth/mapper.rs b/server-rs/crates/spacetime-module/src/auth/mapper.rs deleted file mode 100644 index 6fe205f01..000000000 --- a/server-rs/crates/spacetime-module/src/auth/mapper.rs +++ /dev/null @@ -1,90 +0,0 @@ -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -pub(super) fn sanitize_identity_component(value: &str) -> String { - let sanitized = value - .chars() - .map(|character| { - if character.is_ascii_alphanumeric() { - character - } else { - '_' - } - }) - .collect::(); - sanitized.trim_matches('_').to_string() -} - -#[derive(Deserialize, Serialize)] -pub(super) struct PersistentAuthStoreSnapshot { - #[serde(default = "default_next_user_id")] - pub(super) next_user_id: u64, - pub(super) users_by_username: std::collections::HashMap, - #[serde(default)] - pub(super) phone_to_user_id: std::collections::HashMap, - pub(super) sessions_by_id: std::collections::HashMap, - #[serde(default)] - pub(super) session_id_by_refresh_token_hash: std::collections::HashMap, - pub(super) wechat_identity_by_provider_uid: - std::collections::HashMap, - #[serde(default)] - pub(super) user_id_by_provider_union_id: std::collections::HashMap, -} - -fn default_next_user_id() -> u64 { - 1 -} - -#[derive(Deserialize, Serialize)] -pub(super) struct StoredPasswordUserSnapshot { - pub(super) user: AuthUserSnapshot, - pub(super) password_hash: String, - #[serde(default)] - pub(super) password_login_enabled: bool, - pub(super) phone_number: Option, -} - -#[derive(Deserialize, Serialize)] -pub(super) struct AuthUserSnapshot { - pub(super) id: String, - pub(super) public_user_code: String, - pub(super) username: String, - pub(super) display_name: String, - #[serde(default)] - pub(super) avatar_url: Option, - pub(super) phone_number_masked: Option, - pub(super) login_method: String, - pub(super) binding_status: String, - pub(super) wechat_bound: bool, - pub(super) token_version: u64, - #[serde(default)] - pub(super) user_tags: Vec, -} - -#[derive(Deserialize, Serialize)] -pub(super) struct StoredWechatIdentitySnapshot { - pub(super) user_id: String, - pub(super) provider_uid: String, - pub(super) provider_union_id: Option, - pub(super) display_name: Option, - pub(super) avatar_url: Option, -} - -#[derive(Deserialize, Serialize)] -pub(super) struct StoredRefreshSessionSnapshot { - pub(super) session: RefreshSessionSnapshot, -} - -#[derive(Deserialize, Serialize)] -pub(super) struct RefreshSessionSnapshot { - pub(super) session_id: String, - pub(super) user_id: String, - pub(super) refresh_token_hash: String, - pub(super) issued_by_provider: String, - pub(super) client_info: Value, - pub(super) expires_at: String, - pub(super) revoked_at: Option, - pub(super) created_at: String, - pub(super) updated_at: String, - pub(super) last_seen_at: String, -} diff --git a/server-rs/crates/spacetime-module/src/auth/procedures.rs b/server-rs/crates/spacetime-module/src/auth/procedures.rs index 34161c780..5823f5450 100644 --- a/server-rs/crates/spacetime-module/src/auth/procedures.rs +++ b/server-rs/crates/spacetime-module/src/auth/procedures.rs @@ -1,114 +1,94 @@ use crate::{ProcedureContext, ReducerContext, SpacetimeType, Table, Timestamp}; -use super::{ - mapper::{ - AuthUserSnapshot, PersistentAuthStoreSnapshot, RefreshSessionSnapshot, - StoredPasswordUserSnapshot, StoredRefreshSessionSnapshot, StoredWechatIdentitySnapshot, - sanitize_identity_component, - }, - tables::{ - AuthIdentity, AuthStoreProjectionMeta, RefreshSession, UserAccount, auth_identity, - auth_store_projection_meta, auth_store_snapshot, refresh_session, user_account, - }, +use super::tables::{ + AuthIdentity, AuthStoreProjectionMeta, RefreshSession, UserAccount, auth_identity, + auth_store_projection_meta, refresh_session, user_account, }; const AUTH_STORE_PROJECTION_META_ID: &str = "default"; #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] -pub struct AuthStoreSnapshotRecord { - pub snapshot_json: Option, - pub updated_at_micros: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] -pub struct AuthStoreSnapshotUpsertInput { - pub snapshot_json: String, +pub struct AuthStoreProjectionView { pub updated_at_micros: i64, + pub users: Vec, + pub identities: Vec, + pub refresh_sessions: Vec, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] -pub struct AuthStoreSnapshotProcedureResult { - pub ok: bool, - pub record: Option, - pub error_message: Option, -} - -fn normalize_user_account_tags( - tags: Option>, -) -> Result, module_runtime::RuntimeProfileFieldError> { - module_runtime::normalize_profile_user_tags(tags.unwrap_or_default()) -} - -fn snapshot_has_user_rows(snapshot: &PersistentAuthStoreSnapshot) -> bool { - !snapshot.users_by_username.is_empty() -} - -fn normalize_optional_snapshot_string(value: Option) -> Option { - value - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) -} - -fn merge_identity_profile_into_user( - mut user: AuthUserSnapshot, - identity_profile: Option<(Option, Option)>, -) -> AuthUserSnapshot { - let Some((display_name, avatar_url)) = identity_profile else { - return user; - }; - - if let Some(display_name) = normalize_optional_snapshot_string(display_name) { - let current_display_name = user.display_name.trim(); - let is_masked_phone_display_name = user - .phone_number_masked - .as_deref() - .map(str::trim) - .is_some_and(|masked| masked == current_display_name); - if current_display_name.is_empty() || is_masked_phone_display_name { - user.display_name = display_name; - } - } - - if user - .avatar_url - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .is_none() - { - user.avatar_url = normalize_optional_snapshot_string(avatar_url); - } - - user +pub struct AuthStoreProjectionUser { + pub user_id: String, + pub public_user_code: String, + pub username: String, + pub display_name: String, + pub avatar_url: Option, + pub phone_number_masked: Option, + pub phone_number_e164: Option, + pub login_method: String, + pub binding_status: String, + pub wechat_bound: bool, + pub password_hash: String, + pub password_login_enabled: bool, + pub token_version: u64, + pub created_at: String, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] -pub struct AuthStoreSnapshotImportRecord { +pub struct AuthStoreProjectionIdentity { + pub identity_id: String, + pub user_id: String, + pub provider: String, + pub provider_uid: String, + pub provider_union_id: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct AuthStoreProjectionRefreshSession { + pub session_id: String, + pub user_id: String, + pub refresh_token_hash: String, + pub issued_by_provider: String, + pub client_info_json: String, + pub expires_at: String, + pub revoked_at: Option, + pub created_at: String, + pub updated_at: String, + pub last_seen_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct AuthStoreProjectionSyncRecord { pub imported_user_count: u32, pub imported_identity_count: u32, pub imported_refresh_session_count: u32, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] -pub struct AuthStoreSnapshotImportProcedureResult { +pub struct AuthStoreProjectionProcedureResult { pub ok: bool, - pub record: Option, + pub record: Option, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct AuthStoreProjectionSyncProcedureResult { + pub ok: bool, + pub record: Option, pub error_message: Option, } -// Axum 运行期认证变更直接导入正式认证表;旧 `auth_store_snapshot` 只做一次性清理。 #[spacetimedb::procedure] -pub fn import_auth_store_snapshot_json( +pub fn sync_auth_store_projection( ctx: &mut ProcedureContext, - input: AuthStoreSnapshotUpsertInput, -) -> AuthStoreSnapshotImportProcedureResult { - match ctx.try_with_tx(|tx| import_auth_store_snapshot_json_tx(tx, input.clone())) { - Ok(record) => AuthStoreSnapshotImportProcedureResult { + input: AuthStoreProjectionView, +) -> AuthStoreProjectionSyncProcedureResult { + match ctx.try_with_tx(|tx| sync_auth_store_projection_tx(tx, input.clone())) { + Ok(record) => AuthStoreProjectionSyncProcedureResult { ok: true, record: Some(record), error_message: None, }, - Err(message) => AuthStoreSnapshotImportProcedureResult { + Err(message) => AuthStoreProjectionSyncProcedureResult { ok: false, record: None, error_message: Some(message), @@ -116,18 +96,17 @@ pub fn import_auth_store_snapshot_json( } } -// Axum 启动时可从正式表重新导出 module-auth 使用的整份认证快照。 #[spacetimedb::procedure] -pub fn export_auth_store_snapshot_from_tables( +pub fn export_auth_store_projection_from_tables( ctx: &mut ProcedureContext, -) -> AuthStoreSnapshotProcedureResult { - match ctx.try_with_tx(|tx| export_auth_store_snapshot_from_tables_tx(tx)) { - Ok(record) => AuthStoreSnapshotProcedureResult { +) -> AuthStoreProjectionProcedureResult { + match ctx.try_with_tx(|tx| export_auth_store_projection_from_tables_tx(tx)) { + Ok(record) => AuthStoreProjectionProcedureResult { ok: true, record: Some(record), error_message: None, }, - Err(message) => AuthStoreSnapshotProcedureResult { + Err(message) => AuthStoreProjectionProcedureResult { ok: false, record: None, error_message: Some(message), @@ -135,129 +114,97 @@ pub fn export_auth_store_snapshot_from_tables( } } -fn import_auth_store_snapshot_json_tx( +fn sync_auth_store_projection_tx( ctx: &ReducerContext, - input: AuthStoreSnapshotUpsertInput, -) -> Result { - import_auth_store_snapshot_json_value_tx(ctx, &input.snapshot_json, input.updated_at_micros) -} - -fn import_auth_store_snapshot_json_value_tx( - ctx: &ReducerContext, - snapshot_json: &str, - updated_at_micros: i64, -) -> Result { - let snapshot_json = snapshot_json.trim(); - if snapshot_json.is_empty() { - return Err("认证快照 JSON 不能为空".to_string()); - } - let parsed = serde_json::from_str::(snapshot_json) - .map_err(|error| format!("认证快照 JSON 解析失败:{error}"))?; - import_auth_store_snapshot_value_tx(ctx, parsed, updated_at_micros) -} - -fn import_auth_store_snapshot_value_tx( - ctx: &ReducerContext, - parsed: PersistentAuthStoreSnapshot, - updated_at_micros: i64, -) -> Result { - if !snapshot_has_user_rows(&parsed) { - return Err("认证快照缺少用户记录,拒绝导入正式表".to_string()); + input: AuthStoreProjectionView, +) -> Result { + let user_ids = input + .users + .iter() + .map(|user| user.user_id.clone()) + .collect::>(); + if user_ids.len() != input.users.len() { + return Err("认证用户投影包含重复 user_id".to_string()); } - clear_legacy_auth_store_snapshot_rows(ctx); - upsert_auth_projection_meta(ctx, updated_at_micros); + let identity_ids = input + .identities + .iter() + .map(|identity| identity.identity_id.clone()) + .collect::>(); + if identity_ids.len() != input.identities.len() { + return Err("认证身份投影包含重复 identity_id".to_string()); + } + + let session_ids = input + .refresh_sessions + .iter() + .map(|session| session.session_id.clone()) + .collect::>(); + if session_ids.len() != input.refresh_sessions.len() { + return Err("认证会话投影包含重复 session_id".to_string()); + } + + delete_missing_user_accounts(ctx, &user_ids); + delete_missing_auth_identities(ctx, &identity_ids); + delete_missing_refresh_sessions(ctx, &session_ids); let mut imported_user_count = 0_u32; - let mut imported_identity_count = 0_u32; - let mut imported_refresh_session_count = 0_u32; - let mut wechat_profile_by_user_id = std::collections::HashMap::new(); - - for identity in parsed.wechat_identity_by_provider_uid.values() { - let entry = wechat_profile_by_user_id - .entry(identity.user_id.clone()) - .or_insert_with(|| (None, None)); - if entry.0.is_none() { - entry.0 = identity.display_name.clone(); - } - if entry.1.is_none() { - entry.1 = identity.avatar_url.clone(); - } - } - - for stored_user in parsed.users_by_username.into_values() { - let user_id = stored_user.user.id.clone(); - let user = merge_identity_profile_into_user( - stored_user.user, - wechat_profile_by_user_id.remove(&user_id), - ); - if ctx.db.user_account().user_id().find(&user_id).is_some() { - ctx.db.user_account().user_id().delete(&user_id); + for user in input.users { + let existing = ctx.db.user_account().user_id().find(&user.user_id); + if existing.is_some() { + ctx.db.user_account().user_id().delete(&user.user_id); } + let existing_tags = existing.as_ref().and_then(|row| row.user_tags.clone()); + let existing_created_at = existing + .as_ref() + .and_then(|row| row.created_at.clone()) + .filter(|value| !value.trim().is_empty()); ctx.db.user_account().insert(UserAccount { - user_id: user_id.clone(), + user_id: user.user_id, public_user_code: user.public_user_code, username: user.username, display_name: user.display_name, avatar_url: user.avatar_url, phone_number_masked: user.phone_number_masked, - phone_number_e164: stored_user.phone_number.clone(), + phone_number_e164: user.phone_number_e164, login_method: user.login_method, binding_status: user.binding_status, wechat_bound: user.wechat_bound, - password_hash: stored_user.password_hash, - password_login_enabled: stored_user.password_login_enabled, + password_hash: user.password_hash, + password_login_enabled: user.password_login_enabled, token_version: user.token_version, - user_tags: Some( - module_runtime::normalize_profile_user_tags(user.user_tags) - .map_err(|error| error.to_string())?, - ), + user_tags: existing_tags.or(Some(vec![])), + created_at: if user.created_at.trim().is_empty() { + existing_created_at + } else { + Some(user.created_at) + }, }); imported_user_count += 1; - - if let Some(phone_number) = stored_user.phone_number { - let identity_id = format!("authi_phone_{}", sanitize_identity_component(&phone_number)); - if ctx - .db - .auth_identity() - .identity_id() - .find(&identity_id) - .is_some() - { - ctx.db.auth_identity().identity_id().delete(&identity_id); - } - ctx.db.auth_identity().insert(AuthIdentity { - identity_id, - user_id, - provider: "phone".to_string(), - provider_uid: phone_number.clone(), - provider_union_id: None, - phone_e164: None, - display_name: None, - avatar_url: None, - }); - imported_identity_count += 1; - } } - for identity in parsed.wechat_identity_by_provider_uid.into_values() { - let identity_id = format!( - "authi_wechat_{}", - sanitize_identity_component(&identity.provider_uid) - ); + let mut imported_identity_count = 0_u32; + for identity in input.identities { + if !user_ids.contains(&identity.user_id) { + continue; + } if ctx .db .auth_identity() .identity_id() - .find(&identity_id) + .find(&identity.identity_id) .is_some() { - ctx.db.auth_identity().identity_id().delete(&identity_id); + ctx.db + .auth_identity() + .identity_id() + .delete(&identity.identity_id); } ctx.db.auth_identity().insert(AuthIdentity { - identity_id, + identity_id: identity.identity_id, user_id: identity.user_id, - provider: "wechat".to_string(), + provider: identity.provider, provider_uid: identity.provider_uid, provider_union_id: identity.provider_union_id, phone_e164: None, @@ -267,10 +214,11 @@ fn import_auth_store_snapshot_value_tx( imported_identity_count += 1; } - for stored_session in parsed.sessions_by_id.into_values() { - let session = stored_session.session; - let client_info_json = serde_json::to_string(&session.client_info) - .map_err(|error| format!("客户端身份序列化失败:{error}"))?; + let mut imported_refresh_session_count = 0_u32; + for session in input.refresh_sessions { + if !user_ids.contains(&session.user_id) { + continue; + } if ctx .db .refresh_session() @@ -288,7 +236,7 @@ fn import_auth_store_snapshot_value_tx( user_id: session.user_id, refresh_token_hash: session.refresh_token_hash, issued_by_provider: session.issued_by_provider, - client_info_json, + client_info_json: session.client_info_json, expires_at: session.expires_at, revoked_at: session.revoked_at, created_at: session.created_at, @@ -298,238 +246,121 @@ fn import_auth_store_snapshot_value_tx( imported_refresh_session_count += 1; } - Ok(AuthStoreSnapshotImportRecord { + upsert_auth_projection_meta(ctx, input.updated_at_micros); + + Ok(AuthStoreProjectionSyncRecord { imported_user_count, imported_identity_count, imported_refresh_session_count, }) } -fn clear_legacy_auth_store_snapshot_rows(ctx: &ReducerContext) { - for row in ctx.db.auth_store_snapshot().iter().collect::>() { - ctx.db - .auth_store_snapshot() - .snapshot_id() - .delete(&row.snapshot_id); - } -} - -fn export_auth_store_snapshot_from_tables_tx( +fn export_auth_store_projection_from_tables_tx( ctx: &ReducerContext, -) -> Result { - let mut users = ctx.db.user_account().iter().collect::>(); - let mut identities = ctx.db.auth_identity().iter().collect::>(); - let mut sessions = ctx.db.refresh_session().iter().collect::>(); - if users.is_empty() && identities.is_empty() && sessions.is_empty() { - transfer_latest_legacy_auth_store_snapshot_to_tables(ctx)?; - users = ctx.db.user_account().iter().collect::>(); - identities = ctx.db.auth_identity().iter().collect::>(); - sessions = ctx.db.refresh_session().iter().collect::>(); - } - clear_legacy_auth_store_snapshot_rows(ctx); - if users.is_empty() && identities.is_empty() && sessions.is_empty() { - return Ok(AuthStoreSnapshotRecord { - snapshot_json: None, - updated_at_micros: None, - }); - } +) -> Result { let 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()); - let snapshot = build_auth_store_snapshot_from_rows(users, identities, sessions)?; - let snapshot_json = serde_json::to_string_pretty(&snapshot) - .map_err(|error| format!("序列化认证快照失败:{error}"))?; - - Ok(AuthStoreSnapshotRecord { - snapshot_json: Some(snapshot_json), - updated_at_micros, - }) -} - -fn transfer_latest_legacy_auth_store_snapshot_to_tables( - ctx: &ReducerContext, -) -> Result<(), String> { - let Some(row) = ctx + .map(|row| row.updated_at.to_micros_since_unix_epoch()) + .unwrap_or(0); + let users = ctx .db - .auth_store_snapshot() + .user_account() .iter() - .max_by_key(|row| row.updated_at.to_micros_since_unix_epoch()) - else { - return Ok(()); - }; - let snapshot_json = row.snapshot_json; - let updated_at_micros = row.updated_at.to_micros_since_unix_epoch(); - let parsed = serde_json::from_str::(snapshot_json.trim()) - .map_err(|error| format!("旧认证快照 JSON 解析失败:{error}"))?; - if !snapshot_has_user_rows(&parsed) { - clear_legacy_auth_store_snapshot_rows(ctx); - return Ok(()); - } - import_auth_store_snapshot_value_tx(ctx, parsed, updated_at_micros)?; - Ok(()) -} - -fn build_auth_store_snapshot_from_rows( - users: Vec, - identities: Vec, - sessions: Vec, -) -> Result { - let valid_user_ids = users - .iter() - .map(|user| user.user_id.clone()) - .collect::>(); - let user_profile_by_id = users - .iter() - .map(|user| { - ( - user.user_id.clone(), - ( - normalize_optional_snapshot_string(Some(user.display_name.clone())), - user.avatar_url.clone(), - ), - ) - }) - .collect::>(); - let mut phone_identity_by_user_id = std::collections::HashMap::new(); - let mut phone_user_id_by_phone = std::collections::HashMap::new(); - let mut wechat_identity_by_provider_uid = std::collections::HashMap::new(); - let mut user_id_by_provider_union_id = std::collections::HashMap::new(); - - for identity in identities { - if !valid_user_ids.contains(&identity.user_id) { - continue; - } - - match identity.provider.as_str() { - "phone" => { - let user_id = identity.user_id.clone(); - let phone_number = if identity.provider_uid.trim().is_empty() { - identity.phone_e164.clone().unwrap_or_default() - } else { - identity.provider_uid.clone() - }; - if phone_number.trim().is_empty() { - continue; - } - phone_identity_by_user_id.insert(user_id.clone(), phone_number.clone()); - phone_user_id_by_phone.insert(phone_number, user_id); - } - "wechat" => { - if let Some(union_id) = identity.provider_union_id.clone() { - user_id_by_provider_union_id.insert(union_id, identity.user_id.clone()); - } - let (display_name, avatar_url) = user_profile_by_id - .get(&identity.user_id) - .cloned() - .unwrap_or((None, None)); - wechat_identity_by_provider_uid.insert( - identity.provider_uid.clone(), - StoredWechatIdentitySnapshot { - user_id: identity.user_id, - provider_uid: identity.provider_uid, - provider_union_id: identity.provider_union_id, - display_name, - avatar_url, - }, - ); - } - _ => {} - } - } - - let mut next_user_id = 1_u64; - let mut phone_to_user_id = phone_user_id_by_phone; - let mut users_by_username = std::collections::HashMap::new(); - for user in users { - next_user_id = - next_user_id.max(next_sequence_from_public_user_code(&user.public_user_code)); - let phone_number = user - .phone_number_e164 - .clone() - .or_else(|| phone_identity_by_user_id.remove(&user.user_id)); - if let Some(phone_number) = phone_number.clone() { - phone_to_user_id - .entry(phone_number) - .or_insert_with(|| user.user_id.clone()); - } - let auth_user = AuthUserSnapshot { - id: user.user_id.clone(), + .map(|user| AuthStoreProjectionUser { + user_id: user.user_id, public_user_code: user.public_user_code, - username: user.username.clone(), + username: user.username, display_name: user.display_name, avatar_url: user.avatar_url, phone_number_masked: user.phone_number_masked, + phone_number_e164: user.phone_number_e164, login_method: user.login_method, binding_status: user.binding_status, wechat_bound: user.wechat_bound, + password_hash: user.password_hash, + password_login_enabled: user.password_login_enabled, token_version: user.token_version, - user_tags: normalize_user_account_tags(user.user_tags) - .map_err(|error| error.to_string())?, - }; - users_by_username.insert( - user.username, - StoredPasswordUserSnapshot { - user: auth_user, - password_hash: user.password_hash, - password_login_enabled: user.password_login_enabled, - phone_number, - }, - ); - } + created_at: user.created_at.unwrap_or_default(), + }) + .collect(); + let identities = ctx + .db + .auth_identity() + .iter() + .map(|identity| AuthStoreProjectionIdentity { + identity_id: identity.identity_id, + user_id: identity.user_id, + provider: identity.provider, + provider_uid: identity.provider_uid, + provider_union_id: identity.provider_union_id, + }) + .collect(); + let refresh_sessions = ctx + .db + .refresh_session() + .iter() + .map(|session| AuthStoreProjectionRefreshSession { + session_id: session.session_id, + user_id: session.user_id, + refresh_token_hash: session.refresh_token_hash, + issued_by_provider: session.issued_by_provider, + client_info_json: session.client_info_json, + expires_at: session.expires_at, + revoked_at: session.revoked_at, + created_at: session.created_at, + updated_at: session.updated_at, + last_seen_at: session.last_seen_at, + }) + .collect(); - let mut sessions_by_id = std::collections::HashMap::new(); - let mut session_id_by_refresh_token_hash = std::collections::HashMap::new(); - for session in sessions { - if !valid_user_ids.contains(&session.user_id) { - continue; - } - - let client_info = serde_json::from_str::(&session.client_info_json) - .map_err(|error| format!("refresh session 客户端信息 JSON 解析失败:{error}"))?; - session_id_by_refresh_token_hash.insert( - session.refresh_token_hash.clone(), - session.session_id.clone(), - ); - sessions_by_id.insert( - session.session_id.clone(), - StoredRefreshSessionSnapshot { - session: RefreshSessionSnapshot { - session_id: session.session_id, - user_id: session.user_id, - refresh_token_hash: session.refresh_token_hash, - issued_by_provider: session.issued_by_provider, - client_info, - expires_at: session.expires_at, - revoked_at: session.revoked_at, - created_at: session.created_at, - updated_at: session.updated_at, - last_seen_at: session.last_seen_at, - }, - }, - ); - } - - Ok(PersistentAuthStoreSnapshot { - next_user_id, - users_by_username, - phone_to_user_id, - sessions_by_id, - session_id_by_refresh_token_hash, - wechat_identity_by_provider_uid, - user_id_by_provider_union_id, + Ok(AuthStoreProjectionView { + updated_at_micros, + users, + identities, + refresh_sessions, }) } -fn next_sequence_from_public_user_code(public_user_code: &str) -> u64 { - public_user_code - .trim() - .strip_prefix("SY-") - .and_then(|value| value.parse::().ok()) - .map(|sequence| sequence.saturating_add(1)) - .unwrap_or(1) +fn delete_missing_user_accounts( + ctx: &ReducerContext, + keep_ids: &std::collections::HashSet, +) { + for row in ctx.db.user_account().iter().collect::>() { + if !keep_ids.contains(&row.user_id) { + ctx.db.user_account().user_id().delete(&row.user_id); + } + } +} + +fn delete_missing_auth_identities( + ctx: &ReducerContext, + keep_ids: &std::collections::HashSet, +) { + for row in ctx.db.auth_identity().iter().collect::>() { + if !keep_ids.contains(&row.identity_id) { + ctx.db + .auth_identity() + .identity_id() + .delete(&row.identity_id); + } + } +} + +fn delete_missing_refresh_sessions( + ctx: &ReducerContext, + keep_ids: &std::collections::HashSet, +) { + for row in ctx.db.refresh_session().iter().collect::>() { + if !keep_ids.contains(&row.session_id) { + ctx.db + .refresh_session() + .session_id() + .delete(&row.session_id); + } + } } fn upsert_auth_projection_meta(ctx: &ReducerContext, updated_at_micros: i64) { @@ -558,12 +389,11 @@ fn upsert_auth_projection_meta(ctx: &ReducerContext, updated_at_micros: i64) { mod tests { use super::*; - #[test] - fn auth_export_ignores_phone_identity_without_user_account() { - let live_user = UserAccount { - user_id: "user_live".to_string(), + fn user_account(user_id: &str) -> UserAccount { + UserAccount { + user_id: user_id.to_string(), public_user_code: "SY-00000001".to_string(), - username: "phone_live".to_string(), + username: format!("phone_{user_id}"), display_name: "测试玩家".to_string(), avatar_url: None, phone_number_masked: Some("138****8000".to_string()), @@ -574,217 +404,65 @@ mod tests { password_hash: "hash-live".to_string(), password_login_enabled: true, token_version: 1, - user_tags: Some(vec![]), - }; - let orphan_identity = AuthIdentity { - identity_id: "authi_phone_orphan".to_string(), - user_id: "user_deleted".to_string(), - provider: "phone".to_string(), - provider_uid: "+8613900009999".to_string(), - provider_union_id: None, - phone_e164: Some("+8613900009999".to_string()), - display_name: None, - avatar_url: None, - }; - - let snapshot = - build_auth_store_snapshot_from_rows(vec![live_user], vec![orphan_identity], vec![]) - .expect("auth rows should export"); - - assert_eq!( - snapshot.phone_to_user_id, - std::collections::HashMap::from([( - "+8613800008000".to_string(), - "user_live".to_string() - )]) - ); - assert!(!snapshot.phone_to_user_id.contains_key("+8613900009999")); - } - - #[test] - fn auth_export_prefers_phone_identity_when_phone_user_accounts_are_duplicated() { - fn phone_user(user_id: &str, public_user_code: &str, username: &str) -> UserAccount { - UserAccount { - user_id: user_id.to_string(), - public_user_code: public_user_code.to_string(), - username: username.to_string(), - display_name: "138****8000".to_string(), - avatar_url: None, - phone_number_masked: Some("138****8000".to_string()), - phone_number_e164: Some("+8613800008000".to_string()), - login_method: "Phone".to_string(), - binding_status: "Active".to_string(), - wechat_bound: false, - password_hash: "hash-live".to_string(), - password_login_enabled: true, - token_version: 1, - user_tags: Some(vec![]), - } + user_tags: Some(vec!["vip".to_string()]), + created_at: Some("2026-07-01T00:00:00Z".to_string()), } - - let identity = AuthIdentity { - identity_id: "authi_phone_8613800008000".to_string(), - user_id: "user_identity".to_string(), - provider: "phone".to_string(), - provider_uid: "+8613800008000".to_string(), - provider_union_id: None, - phone_e164: Some("+8613999999999".to_string()), - display_name: None, - avatar_url: None, - }; - - let snapshot = build_auth_store_snapshot_from_rows( - vec![ - phone_user("user_duplicate", "SY-00000042", "phone_duplicate"), - phone_user("user_identity", "SY-00000023", "phone_identity"), - ], - vec![identity], - vec![], - ) - .expect("auth rows should export"); - - assert_eq!( - snapshot - .phone_to_user_id - .get("+8613800008000") - .map(String::as_str), - Some("user_identity") - ); } #[test] - fn auth_import_backfills_identity_profile_into_user_account() { - let user = AuthUserSnapshot { - id: "user_wechat".to_string(), - public_user_code: "SY-00000024".to_string(), - username: "phone_wechat".to_string(), - display_name: "138****8000".to_string(), - avatar_url: None, - phone_number_masked: Some("138****8000".to_string()), - login_method: "phone".to_string(), - binding_status: "active".to_string(), - wechat_bound: true, - token_version: 1, - user_tags: vec![], - }; - - let merged = merge_identity_profile_into_user( - user, - Some(( - Some("微信昵称".to_string()), - Some("https://example.com/avatar.png".to_string()), - )), - ); - - assert_eq!(merged.display_name, "微信昵称"); - assert_eq!( - merged.avatar_url.as_deref(), - Some("https://example.com/avatar.png") - ); - } - - #[test] - fn auth_export_reads_wechat_profile_from_user_account() { - let user = UserAccount { - user_id: "user_wechat".to_string(), - public_user_code: "SY-00000025".to_string(), - username: "wechat_user".to_string(), - display_name: "账户昵称".to_string(), - avatar_url: Some("https://example.com/account-avatar.png".to_string()), - phone_number_masked: None, - phone_number_e164: None, - login_method: "wechat".to_string(), - binding_status: "pending_bind_phone".to_string(), - wechat_bound: true, - password_hash: String::new(), - password_login_enabled: false, - token_version: 1, - user_tags: Some(vec![]), - }; + fn export_projection_ignores_identity_profile_columns() { let identity = AuthIdentity { identity_id: "authi_wechat_openid_001".to_string(), user_id: "user_wechat".to_string(), provider: "wechat".to_string(), provider_uid: "openid_001".to_string(), provider_union_id: Some("union_001".to_string()), - phone_e164: None, + phone_e164: Some("+8613999999999".to_string()), display_name: Some("旧身份昵称".to_string()), avatar_url: Some("https://example.com/identity-avatar.png".to_string()), }; - let snapshot = build_auth_store_snapshot_from_rows(vec![user], vec![identity], vec![]) - .expect("auth rows should export"); - let identity = snapshot - .wechat_identity_by_provider_uid - .get("openid_001") - .expect("wechat identity should export"); + let projection = AuthStoreProjectionIdentity { + identity_id: identity.identity_id, + user_id: identity.user_id, + provider: identity.provider, + provider_uid: identity.provider_uid, + provider_union_id: identity.provider_union_id, + }; + + assert_eq!(projection.provider_uid, "openid_001"); + } + + #[test] + fn sync_user_preserves_account_tags_and_created_at_when_projection_is_empty() { + let existing = user_account("user_live"); + let projection = AuthStoreProjectionUser { + user_id: existing.user_id.clone(), + public_user_code: existing.public_user_code.clone(), + username: existing.username.clone(), + display_name: "新昵称".to_string(), + avatar_url: None, + phone_number_masked: existing.phone_number_masked.clone(), + phone_number_e164: existing.phone_number_e164.clone(), + login_method: "phone".to_string(), + binding_status: "active".to_string(), + wechat_bound: false, + password_hash: existing.password_hash.clone(), + password_login_enabled: true, + token_version: 2, + created_at: String::new(), + }; + + let created_at = if projection.created_at.trim().is_empty() { + existing.created_at.clone().unwrap_or_default() + } else { + projection.created_at.clone() + }; - assert_eq!(identity.display_name.as_deref(), Some("账户昵称")); assert_eq!( - identity.avatar_url.as_deref(), - Some("https://example.com/account-avatar.png") + existing.user_tags.as_deref(), + Some(&["vip".to_string()][..]) ); - } - - #[test] - fn auth_export_next_user_id_follows_public_user_code_for_uuid_user_ids() { - let users = vec![ - UserAccount { - user_id: "user_5c3a59c4ff4044f2a2f43d55c7f445ac".to_string(), - public_user_code: "SY-00000042".to_string(), - username: "phone_00000043".to_string(), - display_name: "测试玩家".to_string(), - avatar_url: None, - phone_number_masked: Some("138****8000".to_string()), - phone_number_e164: Some("+8613800008000".to_string()), - login_method: "phone".to_string(), - binding_status: "active".to_string(), - wechat_bound: false, - password_hash: "hash-live".to_string(), - password_login_enabled: true, - token_version: 1, - user_tags: Some(vec![]), - }, - UserAccount { - user_id: "user_6d4a59c4ff4044f2a2f43d55c7f445ad".to_string(), - public_user_code: "SY-00000107".to_string(), - username: "phone_00000108".to_string(), - display_name: "测试玩家2".to_string(), - avatar_url: None, - phone_number_masked: Some("139****8000".to_string()), - phone_number_e164: Some("+8613900008000".to_string()), - login_method: "phone".to_string(), - binding_status: "active".to_string(), - wechat_bound: false, - password_hash: "hash-live-2".to_string(), - password_login_enabled: true, - token_version: 1, - user_tags: Some(vec![]), - }, - ]; - - let snapshot = build_auth_store_snapshot_from_rows(users, vec![], vec![]) - .expect("auth rows should export"); - - assert_eq!(snapshot.next_user_id, 108); - } - - #[test] - fn auth_legacy_empty_snapshot_is_cleanup_only() { - let snapshot = serde_json::to_string(&PersistentAuthStoreSnapshot { - next_user_id: 1, - users_by_username: std::collections::HashMap::new(), - phone_to_user_id: std::collections::HashMap::new(), - sessions_by_id: std::collections::HashMap::new(), - session_id_by_refresh_token_hash: std::collections::HashMap::new(), - wechat_identity_by_provider_uid: std::collections::HashMap::new(), - user_id_by_provider_union_id: std::collections::HashMap::new(), - }) - .expect("legacy snapshot should serialize"); - - let parsed = serde_json::from_str::(&snapshot) - .expect("legacy snapshot should parse"); - - assert!(!snapshot_has_user_rows(&parsed)); + assert_eq!(created_at, "2026-07-01T00:00:00Z"); } } diff --git a/server-rs/crates/spacetime-module/src/auth/tables.rs b/server-rs/crates/spacetime-module/src/auth/tables.rs index c2154cc5a..e9f6e6c11 100644 --- a/server-rs/crates/spacetime-module/src/auth/tables.rs +++ b/server-rs/crates/spacetime-module/src/auth/tables.rs @@ -1,13 +1,5 @@ use crate::Timestamp; -#[spacetimedb::table(accessor = auth_store_snapshot)] -pub struct AuthStoreSnapshot { - #[primary_key] - pub(crate) snapshot_id: String, - pub(crate) snapshot_json: String, - pub(crate) updated_at: Timestamp, -} - #[spacetimedb::table(accessor = auth_store_projection_meta)] pub struct AuthStoreProjectionMeta { #[primary_key] @@ -37,6 +29,8 @@ pub struct UserAccount { pub(crate) token_version: u64, #[default(None::>)] pub(crate) user_tags: Option>, + #[default(None::)] + pub(crate) created_at: Option, } #[spacetimedb::table( diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index ef6bffff4..51de7fa9c 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -173,7 +173,6 @@ macro_rules! migration_tables { ($macro_name:ident $(, $arg:expr)* $(,)?) => { $macro_name! { $($arg,)* - auth_store_snapshot, auth_store_projection_meta, user_account, auth_identity,