Fail closed when SpacetimeDB auth restore is unavailable

This commit is contained in:
kdletters
2026-05-27 20:58:37 +08:00
parent 948d5a698c
commit 418fcb0548
24 changed files with 595 additions and 601 deletions

View File

@@ -46,8 +46,6 @@ AUTH_REFRESH_SESSION_TTL_DAYS="30"
AUTH_REFRESH_COOKIE_PATH="/api/auth" AUTH_REFRESH_COOKIE_PATH="/api/auth"
AUTH_REFRESH_COOKIE_SAME_SITE="Lax" AUTH_REFRESH_COOKIE_SAME_SITE="Lax"
AUTH_REFRESH_COOKIE_SECURE="false" AUTH_REFRESH_COOKIE_SECURE="false"
# Rust 鉴权快照路径;包含 password_hash 与 refresh token hash只能放服务端私有目录。
GENARRATIVE_AUTH_STORE_PATH="server-rs/.data/auth-store.json"
# 开发期便捷开关true 时允许 /api/auth/entry 对未知手机号用本次密码直接创建账号;生产必须保持 false。 # 开发期便捷开关true 时允许 /api/auth/entry 对未知手机号用本次密码直接创建账号;生产必须保持 false。
GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED="false" GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED="false"

View File

@@ -507,6 +507,13 @@
- 验证方式:执行 `npm run spacetime:generate -- --rust-only``cargo check -p api-server --manifest-path server-rs/Cargo.toml`、认证相关定向测试和 `npm run check:encoding` - 验证方式:执行 `npm run spacetime:generate -- --rust-only``cargo check -p api-server --manifest-path server-rs/Cargo.toml`、认证相关定向测试和 `npm run check:encoding`
- 关联文档:`docs/technical/AUTH_SPACETIMEDB_FORMAL_TABLE_RECOVERY_STAGE3_2026-04-24.md``docs/technical/SPACETIMEDB_TABLE_CATALOG.md` - 关联文档:`docs/technical/AUTH_SPACETIMEDB_FORMAL_TABLE_RECOVERY_STAGE3_2026-04-24.md``docs/technical/SPACETIMEDB_TABLE_CATALOG.md`
## 2026-05-27 auth_store_snapshot 改为行级记录,不再保留 default 聚合单行
- 背景:`auth_store_snapshot/default` 聚合 JSON 行会把整份认证快照收敛到单键,过期快照一旦被导入就可能覆盖 `user_account` / `auth_identity` / `refresh_session` 的整表状态。
- 决策:`auth_store_snapshot` 只保留行级记录,按 `meta/next_user_id``user/<user_id>``phone/<phone+user>``session/<session_id>``session_hash/<hash+session>``wechat/<provider_uid+user>``union/<union+user>` 拆分存储;`api-server` 启动恢复只认正式认证表,`auth_store_snapshot` 仅作为行级备查,不再作为文件快照替代源。
- 影响范围:`spacetime-module` auth procedures、`spacetime-client` auth facade、`api-server` 启动恢复、后端架构文档、开发运维文档、认证排障记忆。
- 验证方式:`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml``cargo check -p api-server --manifest-path server-rs/Cargo.toml``cargo test -p api-server spacetime_unavailable_router_returns_service_unavailable_for_requests --manifest-path server-rs/Cargo.toml -- --nocapture``npm run check:encoding`
## 2026-05-13 微信小程序支付以后端通知为唯一入账事实 ## 2026-05-13 微信小程序支付以后端通知为唯一入账事实
- 背景:“我的”账户充值需要接入微信小程序支付,同时保留本地 / H5 mock 支付联调能力。 - 背景:“我的”账户充值需要接入微信小程序支付,同时保留本地 / H5 mock 支付联调能力。
@@ -1070,4 +1077,12 @@
- 决策:新增 `GET /api/creation/wooden-fish/works` 作为当前用户木鱼作品架事实源,返回 `WoodenFishWorksResponse.items` 摘要;平台壳在发布成功后必须同时刷新作品架和公开广场列表。 - 决策:新增 `GET /api/creation/wooden-fish/works` 作为当前用户木鱼作品架事实源,返回 `WoodenFishWorksResponse.items` 摘要;平台壳在发布成功后必须同时刷新作品架和公开广场列表。
- 影响范围:`server-rs/crates/api-server/src/wooden_fish.rs``server-rs/crates/api-server/src/modules/wooden_fish.rs``src/services/wooden-fish/woodenFishClient.ts``src/components/custom-world-home/creationWorkShelf.ts``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx` - 影响范围:`server-rs/crates/api-server/src/wooden_fish.rs``server-rs/crates/api-server/src/modules/wooden_fish.rs``src/services/wooden-fish/woodenFishClient.ts``src/components/custom-world-home/creationWorkShelf.ts``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
- 验证方式:发布一个木鱼作品后,草稿 Tab 的已发布筛选应立刻出现 `WF-*` 作品卡,推荐 / 最新流也应立即刷新出公开卡片。 - 验证方式:发布一个木鱼作品后,草稿 Tab 的已发布筛选应立刻出现 `WF-*` 作品卡,推荐 / 最新流也应立即刷新出公开卡片。
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md``docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md` - 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md``docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md`
## 2026-05-27 认证快照完全去文件化并仅保留行级备查
- 背景:`api-server` 依赖本地 `auth-store.json``GENARRATIVE_AUTH_STORE_PATH` 恢复认证真相会在 SpacetimeDB 不可用时把旧快照回灌到 `auth_identity` / `user_account`,导致用户数据被清空或覆盖。
- 决策:`api-server` 启动时只允许从 SpacetimeDB 正式认证表恢复;`module-auth` 不再维护本地持久化文件,只保留内存工作集和 JSON 导入 / 导出;`spacetime-module` 的认证快照只保留行级 `auth_store_snapshot` 备查,不再提供旧 `get_auth_store_snapshot` / `upsert_auth_store_snapshot` / `import_auth_store_snapshot` 兼容入口。
- 影响范围:`server-rs/crates/api-server/src/state.rs``server-rs/crates/module-auth/src/lib.rs``server-rs/crates/spacetime-module/src/auth/procedures.rs``server-rs/crates/spacetime-client/src/auth.rs`、对应生成 bindings。
- 验证方式:`cargo check -p module-auth --manifest-path server-rs/Cargo.toml``cargo check -p api-server --manifest-path server-rs/Cargo.toml``cargo test -p module-auth password --manifest-path server-rs/Cargo.toml -- --nocapture``npm run check:spacetime-schema``npm run check:encoding``cargo test -p api-server spacetime_unavailable_router_returns_service_unavailable_for_requests --manifest-path server-rs/Cargo.toml -- --nocapture`
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`

View File

@@ -512,10 +512,17 @@
- 现象:用户通过“忘记密码”重设密码后,接口返回成功或页面进入登录态,但再次使用新密码登录仍提示“手机号或密码错误”;重启后还可能出现 `Bearer JWT 版本已失效`,日志里的 token version 与本地快照不一致。 - 现象:用户通过“忘记密码”重设密码后,接口返回成功或页面进入登录态,但再次使用新密码登录仍提示“手机号或密码错误”;重启后还可能出现 `Bearer JWT 版本已失效`,日志里的 token version 与本地快照不一致。
- 原因:重置/修改密码会更新 `password_hash``password_login_enabled``token_version`,如果 API 层只更新本地 `InMemoryAuthStore`,没有调用 `sync_auth_store_snapshot_to_spacetime()``api-server` 重启时可能从旧的 SpacetimeDB 表或旧快照恢复账号状态。 - 原因:重置/修改密码会更新 `password_hash``password_login_enabled``token_version`,如果 API 层只更新本地 `InMemoryAuthStore`,没有调用 `sync_auth_store_snapshot_to_spacetime()``api-server` 重启时可能从旧的 SpacetimeDB 表或旧快照恢复账号状态。
- 处理:`POST /api/auth/password/change``POST /api/auth/password/reset` 成功后必须同步认证快照启动恢复从 SpacetimeDB 表、SpacetimeDB 快照记录和本地 `GENARRATIVE_AUTH_STORE_PATH` 文件中选择可判断的最新快照,本地文件更新时尝试回写 SpacetimeDB - 处理:`POST /api/auth/password/change``POST /api/auth/password/reset` 成功后必须同步认证快照。2026-05-27 起,启动恢复只允许从 SpacetimeDB 正式认证表恢复;`auth_store_snapshot` 只保留行级记录,不再写 `default` 聚合单行,也不再把本地文件 `auth-store.json` / `GENARRATIVE_AUTH_STORE_PATH` 当作恢复源。若启动时连不上 SpacetimeDB`api-server` 等待启动恢复超时后进入依赖不可用模式,所有请求返回 `503 SERVICE_UNAVAILABLE``details.reason = "spacetime_startup_unavailable"`
- 验证:执行 `cargo test -p module-auth password --manifest-path server-rs/Cargo.toml``cargo test -p api-server password --manifest-path server-rs/Cargo.toml`;手测时重设密码后旧密码应失败,新密码应成功,重启后仍应保持。 - 验证:执行 `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` - 关联:`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`
## 认证本地文件快照已废弃,旧 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` 只保留内存态,不再写本地快照文件。
- 验证:`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 和外部服务配置 ## 抓大鹅生成页只显示服务暂不可用先查 reason 和外部服务配置
- 现象:点击生成抓大鹅草稿后,页面只提示“服务暂不可用”,或者本地 `npm run dev:api-server` 看似启动但生成接口不可用。 - 现象:点击生成抓大鹅草稿后,页面只提示“服务暂不可用”,或者本地 `npm run dev:api-server` 看似启动但生成接口不可用。

View File

@@ -24,7 +24,6 @@ EXPOSE 8082
ENV GENARRATIVE_ENV=container \ ENV GENARRATIVE_ENV=container \
GENARRATIVE_API_HOST=0.0.0.0 \ GENARRATIVE_API_HOST=0.0.0.0 \
GENARRATIVE_API_PORT=8082 \ GENARRATIVE_API_PORT=8082 \
GENARRATIVE_AUTH_STORE_PATH=/var/lib/genarrative/auth/auth-store.json \
GENARRATIVE_TRACKING_OUTBOX_DIR=/var/lib/genarrative/tracking-outbox GENARRATIVE_TRACKING_OUTBOX_DIR=/var/lib/genarrative/tracking-outbox
CMD ["api-server"] CMD ["api-server"]

View File

@@ -27,7 +27,6 @@ GENARRATIVE_INTERNAL_API_SECRET=CHANGE_ME_FOR_CONTAINER
GENARRATIVE_JWT_ISSUER=genarrative-container GENARRATIVE_JWT_ISSUER=genarrative-container
GENARRATIVE_JWT_SECRET=CHANGE_ME_FOR_CONTAINER GENARRATIVE_JWT_SECRET=CHANGE_ME_FOR_CONTAINER
AUTH_REFRESH_COOKIE_SECURE=false AUTH_REFRESH_COOKIE_SECURE=false
GENARRATIVE_AUTH_STORE_PATH=/var/lib/genarrative/auth/auth-store.json
# 默认连接 compose 内部 SpacetimeDB宿主机发布模块使用 127.0.0.1:13101。 # 默认连接 compose 内部 SpacetimeDB宿主机发布模块使用 127.0.0.1:13101。
GENARRATIVE_SPACETIME_SERVER_URL=http://spacetimedb:3101 GENARRATIVE_SPACETIME_SERVER_URL=http://spacetimedb:3101

View File

@@ -52,7 +52,6 @@ services:
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
volumes: volumes:
- api-auth-store:/var/lib/genarrative/auth
- api-tracking-outbox:/var/lib/genarrative/tracking-outbox - api-tracking-outbox:/var/lib/genarrative/tracking-outbox
ulimits: ulimits:
nofile: nofile:
@@ -142,6 +141,5 @@ services:
volumes: volumes:
spacetime-data: spacetime-data:
api-auth-store:
api-tracking-outbox: api-tracking-outbox:
nginx-logs: nginx-logs:

View File

@@ -34,7 +34,6 @@ AUTH_REFRESH_SESSION_TTL_DAYS=30
AUTH_REFRESH_COOKIE_PATH=/api/auth AUTH_REFRESH_COOKIE_PATH=/api/auth
AUTH_REFRESH_COOKIE_SAME_SITE=Lax AUTH_REFRESH_COOKIE_SAME_SITE=Lax
AUTH_REFRESH_COOKIE_SECURE=true AUTH_REFRESH_COOKIE_SECURE=true
GENARRATIVE_AUTH_STORE_PATH=/var/lib/genarrative/auth/auth-store.json
GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=false GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=false
GENARRATIVE_SPACETIME_SERVER_URL=http://127.0.0.1:3101 GENARRATIVE_SPACETIME_SERVER_URL=http://127.0.0.1:3101

View File

@@ -233,6 +233,10 @@ npm run check:server-rs-ddd
- Rust 结构体:`AuthStoreSnapshot` - Rust 结构体:`AuthStoreSnapshot`
- 源码:`server-rs/crates/spacetime-module/src/auth/tables.rs` - 源码:`server-rs/crates/spacetime-module/src/auth/tables.rs`
认证恢复策略:`api-server` 启动时只从 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)投影恢复进程内认证工作集;`auth_store_snapshot` 只保留行级快照备查,不再作为启动兜底来源。`module-auth` 只保留内存工作集和 JSON 导入 / 导出能力,不再写本地持久化文件;`auth-store.json` / `GENARRATIVE_AUTH_STORE_PATH` 不再是兼容恢复源,也不得在启动时回写覆盖 `auth_identity` / `user_account`。若启动恢复阶段 SpacetimeDB 不可连接或超时,`api-server` 进入依赖不可用模式并对请求返回 `503 SERVICE_UNAVAILABLE`,直到运维恢复 SpacetimeDB 并重启服务。
`auth_store_snapshot` 禁止再写单行 `snapshot_id = "default"` 聚合 JSON。认证同步入口收到 `module-auth` 整份快照后必须拆成行级记录写入同一张表,当前行键前缀包括:`meta/next_user_id``user/<user_id>``phone/<phone+user>``session/<session_id>``session_hash/<hash+session>``wechat/<provider_uid+user>``union/<union+user>`。SpacetimeDB 模块只保留 `import_auth_store_snapshot_json``export_auth_store_snapshot_from_tables` 两个认证快照过程;旧 `get_auth_store_snapshot``upsert_auth_store_snapshot``import_auth_store_snapshot` 兼容入口已删除。导入正式表时只按主键 upsert 本次快照包含的用户、身份和会话,避免过期快照把其他用户整表删除。
### `bark_battle_draft_config` ### `bark_battle_draft_config`
- Rust 结构体:`BarkBattleDraftConfigRow` - Rust 结构体:`BarkBattleDraftConfigRow`

View File

@@ -342,7 +342,7 @@ systemctl restart genarrative-api.service
journalctl -u genarrative-api.service --since '30 seconds ago' --no-pager | grep -E 'tracking outbox|Permission denied|os error 13' journalctl -u genarrative-api.service --since '30 seconds ago' --no-pager | grep -E 'tracking outbox|Permission denied|os error 13'
``` ```
`Genarrative-Server-Provision``Genarrative-Api-Deploy` 会在保留旧 `/etc/genarrative/api-server.env` 的前提下补齐缺失的 tracking outbox 与 auth-store 运行态路径,并确保 `/var/lib/genarrative/tracking-outbox``/var/lib/genarrative/auth` 归属 `genarrative:genarrative` `Genarrative-Server-Provision``Genarrative-Api-Deploy` 会在保留旧 `/etc/genarrative/api-server.env` 的前提下补齐缺失的 tracking outbox 运行态路径,并确保 `/var/lib/genarrative/tracking-outbox` 归属 `genarrative:genarrative`。用户认证真相源只允许在 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)恢复;不要再配置或依赖 `GENARRATIVE_AUTH_STORE_PATH` / `auth-store.json``module-auth` 也不再维护本地文件持久化;`auth_store_snapshot` 只保留行级记录,不再保存为单行 `default` 聚合快照,且旧 `get_auth_store_snapshot` / `upsert_auth_store_snapshot` / `import_auth_store_snapshot` 入口已经删除。如果 `api-server` 启动时连不上 SpacetimeDB会等待启动恢复超时后继续监听但进入依赖不可用模式所有请求统一返回 `503 SERVICE_UNAVAILABLE`,错误详情包含 `reason=spacetime_startup_unavailable`,以避免用空本地状态或旧快照覆盖认证表
常用检查思路: 常用检查思路:

View File

@@ -205,7 +205,7 @@ ensure_runtime_dir() {
ensure_runtime_env_and_dirs() { ensure_runtime_env_and_dirs() {
local api_env_file="$1" local api_env_file="$1"
local tracking_enabled tracking_outbox_dir auth_store_path auth_store_dir local tracking_enabled tracking_outbox_dir
# 旧生产环境文件会被 server-provision 保留,不一定包含新增的运行态写入路径。 # 旧生产环境文件会被 server-provision 保留,不一定包含新增的运行态写入路径。
# 发布前只补缺省值,不覆盖线上已经定制过的目录或开关。 # 发布前只补缺省值,不覆盖线上已经定制过的目录或开关。
@@ -214,19 +214,12 @@ ensure_runtime_env_and_dirs() {
ensure_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE" "500" ensure_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE" "500"
ensure_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS" "1000" ensure_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS" "1000"
ensure_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES" "268435456" ensure_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES" "268435456"
ensure_env_value "${api_env_file}" "GENARRATIVE_AUTH_STORE_PATH" "/var/lib/genarrative/auth/auth-store.json"
tracking_enabled="$(read_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_ENABLED")" tracking_enabled="$(read_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_ENABLED")"
tracking_outbox_dir="$(read_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_DIR")" tracking_outbox_dir="$(read_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_DIR")"
if [[ "$(printf "%s" "${tracking_enabled}" | tr '[:upper:]' '[:lower:]')" != "false" ]]; then if [[ "$(printf "%s" "${tracking_enabled}" | tr '[:upper:]' '[:lower:]')" != "false" ]]; then
ensure_runtime_dir "${tracking_outbox_dir}" "0750" ensure_runtime_dir "${tracking_outbox_dir}" "0750"
fi fi
auth_store_path="$(read_env_value "${api_env_file}" "GENARRATIVE_AUTH_STORE_PATH")"
if [[ -n "${auth_store_path}" ]]; then
auth_store_dir="$(dirname "${auth_store_path}")"
ensure_runtime_dir "${auth_store_dir}" "0750"
fi
} }
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"

View File

@@ -325,7 +325,6 @@ ensure_api_runtime_env_defaults() {
ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE" "500" ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE" "500"
ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS" "1000" ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS" "1000"
ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES" "268435456" ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES" "268435456"
ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_AUTH_STORE_PATH" "/var/lib/genarrative/auth/auth-store.json"
} }
parse_json_string_field() { parse_json_string_field() {

View File

@@ -2,11 +2,12 @@ use axum::{
Router, Router,
body::Body, body::Body,
extract::{Extension, FromRef}, extract::{Extension, FromRef},
http::Request, http::{Request, StatusCode},
middleware, middleware,
response::Response, response::Response,
routing::{get, post}, routing::{get, post},
}; };
use serde_json::json;
use tower_http::{ use tower_http::{
classify::ServerErrorsFailureClass, classify::ServerErrorsFailureClass,
trace::{DefaultOnRequest, TraceLayer}, trace::{DefaultOnRequest, TraceLayer},
@@ -18,6 +19,7 @@ use crate::{
backpressure::limit_concurrent_requests, backpressure::limit_concurrent_requests,
creation_entry_config::require_creation_entry_route_enabled, creation_entry_config::require_creation_entry_route_enabled,
error_middleware::normalize_error_response, error_middleware::normalize_error_response,
http_error::AppError,
modules, modules,
request_context::{RequestContext, attach_request_context, resolve_request_id}, request_context::{RequestContext, attach_request_context, resolve_request_id},
response_headers::propagate_request_id_header, response_headers::propagate_request_id_header,
@@ -164,6 +166,96 @@ pub fn build_router(state: AppState) -> Router {
.with_state(state) .with_state(state)
} }
pub fn build_spacetime_unavailable_router(message: String) -> Router {
Router::new()
.fallback(spacetime_unavailable_handler)
.layer(Extension(SpacetimeUnavailableState {
message: message.into(),
}))
// 依赖不可用模式不挂业务 state统一返回 503并继续保留 request_id / API 版本 / 耗时响应头。
.layer(middleware::from_fn(normalize_error_response))
.layer(middleware::from_fn(propagate_request_id_header))
.layer(
TraceLayer::new_for_http()
.make_span_with(|request: &Request<Body>| {
let request_id =
resolve_request_id(request).unwrap_or_else(|| "unknown".to_string());
let route = crate::telemetry::observability_route(request.uri().path());
let scheme = crate::telemetry::resolve_request_scheme(request.headers());
let span_name = format!("{} {}", request.method(), route);
info_span!(
"http.request",
otel.kind = "server",
otel.name = %span_name,
otel.status_code = tracing::field::Empty,
http.response.status_code = tracing::field::Empty,
method = %request.method(),
http.request.method = %request.method(),
http.route = %route,
url.scheme = %scheme,
url.path = %request.uri().path(),
request_id = %request_id,
status = tracing::field::Empty,
latency_ms = tracing::field::Empty,
)
})
.on_request(DefaultOnRequest::new().level(Level::INFO))
.on_response(
|response: &axum::response::Response,
latency: std::time::Duration,
span: &Span| {
let latency_ms = latency.as_millis().min(u64::MAX as u128) as u64;
let status = response.status().as_u16();
span.record("status", status);
span.record("http.response.status_code", status);
span.record(
"otel.status_code",
if response.status().is_server_error() {
"ERROR"
} else {
"OK"
},
);
span.record("latency_ms", latency_ms);
},
)
.on_failure(
|failure: ServerErrorsFailureClass,
latency: std::time::Duration,
span: &Span| {
let latency_ms = latency.as_millis().min(u64::MAX as u128) as u64;
error!(
parent: span,
latency_ms,
failure = %failure,
"http request failed"
);
},
),
)
.layer(middleware::from_fn(attach_request_context))
}
#[derive(Clone, Debug)]
struct SpacetimeUnavailableState {
message: std::sync::Arc<str>,
}
async fn spacetime_unavailable_handler(
Extension(state): Extension<SpacetimeUnavailableState>,
Extension(request_context): Extension<RequestContext>,
) -> Response {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
.with_message("SpacetimeDB 暂不可用api-server 正在等待数据库恢复")
.with_details(json!({
"provider": "spacetimedb",
"reason": "spacetime_startup_unavailable",
"message": state.message.as_ref(),
}))
.into_response_with_context(Some(&request_context))
}
async fn record_api_tracking_after_success( async fn record_api_tracking_after_success(
axum::extract::State(state): axum::extract::State<AppState>, axum::extract::State(state): axum::extract::State<AppState>,
Extension(request_context): Extension<RequestContext>, Extension(request_context): Extension<RequestContext>,
@@ -368,7 +460,7 @@ mod tests {
use crate::{config::AppConfig, state::AppState}; use crate::{config::AppConfig, state::AppState};
use super::build_router; use super::{build_router, build_spacetime_unavailable_router};
const TEST_PASSWORD: &str = "secret123"; const TEST_PASSWORD: &str = "secret123";
const INTERNAL_TEST_SECRET: &str = "test-internal-secret"; const INTERNAL_TEST_SECRET: &str = "test-internal-secret";
@@ -564,6 +656,38 @@ mod tests {
); );
} }
#[tokio::test]
async fn spacetime_unavailable_router_returns_service_unavailable_for_requests() {
let app = build_spacetime_unavailable_router("SpacetimeDB 启动恢复认证快照超时".to_string());
let response = app
.oneshot(
Request::builder()
.uri("/api/auth/login-options")
.header("x-request-id", "req-spacetime-unavailable")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(
response
.headers()
.get("x-request-id")
.and_then(|value| value.to_str().ok()),
Some("req-spacetime-unavailable")
);
let body = read_json_response(response).await;
assert_eq!(body["error"]["code"], "SERVICE_UNAVAILABLE");
assert_eq!(
body["error"]["details"]["reason"],
"spacetime_startup_unavailable"
);
assert_eq!(body["error"]["details"]["provider"], "spacetimedb");
}
#[tokio::test] #[tokio::test]
async fn creation_entry_route_disabled_returns_service_unavailable() { async fn creation_entry_route_disabled_returns_service_unavailable() {
let state = AppState::new(AppConfig::default()).expect("state should build"); let state = AppState::new(AppConfig::default()).expect("state should build");

View File

@@ -11,7 +11,6 @@ use platform_speech::{
}; };
const DEFAULT_INTERNAL_API_SECRET: &str = "genarrative-dev-internal-bridge"; const DEFAULT_INTERNAL_API_SECRET: &str = "genarrative-dev-internal-bridge";
const DEFAULT_AUTH_STORE_PATH: &str = "server-rs/.data/auth-store.json";
const SPACETIME_LOCAL_CONFIG_FILE: &str = "spacetime.local.json"; const SPACETIME_LOCAL_CONFIG_FILE: &str = "spacetime.local.json";
pub(crate) const DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS: u64 = 1_000_000; pub(crate) const DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS: u64 = 1_000_000;
@@ -45,7 +44,6 @@ pub struct AppConfig {
pub refresh_cookie_secure: bool, pub refresh_cookie_secure: bool,
pub refresh_cookie_same_site: String, pub refresh_cookie_same_site: String,
pub refresh_session_ttl_days: u32, pub refresh_session_ttl_days: u32,
pub auth_store_path: PathBuf,
pub dev_password_entry_auto_register_enabled: bool, pub dev_password_entry_auto_register_enabled: bool,
pub sms_auth_enabled: bool, pub sms_auth_enabled: bool,
pub sms_auth_provider: String, pub sms_auth_provider: String,
@@ -184,7 +182,6 @@ impl Default for AppConfig {
refresh_cookie_secure: false, refresh_cookie_secure: false,
refresh_cookie_same_site: "Lax".to_string(), refresh_cookie_same_site: "Lax".to_string(),
refresh_session_ttl_days: 30, refresh_session_ttl_days: 30,
auth_store_path: PathBuf::from(DEFAULT_AUTH_STORE_PATH),
dev_password_entry_auto_register_enabled: false, dev_password_entry_auto_register_enabled: false,
sms_auth_enabled: false, sms_auth_enabled: false,
sms_auth_provider: "mock".to_string(), sms_auth_provider: "mock".to_string(),
@@ -433,9 +430,6 @@ impl AppConfig {
config.refresh_session_ttl_days = refresh_session_ttl_days; config.refresh_session_ttl_days = refresh_session_ttl_days;
} }
if let Some(auth_store_path) = read_first_non_empty_env(&["GENARRATIVE_AUTH_STORE_PATH"]) {
config.auth_store_path = PathBuf::from(auth_store_path);
}
if let Some(enabled) = if let Some(enabled) =
read_first_bool_env(&["GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED"]) read_first_bool_env(&["GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED"])
{ {

View File

@@ -236,7 +236,6 @@ mod tests {
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token, AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
}; };
use serde_json::{Value, json}; use serde_json::{Value, json};
use std::path::PathBuf;
use time::OffsetDateTime; use time::OffsetDateTime;
use tower::ServiceExt; use tower::ServiceExt;
@@ -394,12 +393,7 @@ mod tests {
} }
async fn build_test_state(label: &str) -> AppState { async fn build_test_state(label: &str) -> AppState {
let mut config = AppConfig::default(); let _ = label;
config.auth_store_path = PathBuf::from(format!( AppState::new(AppConfig::default()).expect("state should build")
".codex-temp/api-server-auth-store-creation-doc-{label}.json"
));
let _ = std::fs::remove_file(&config.auth_store_path);
AppState::new(config).expect("state should build")
} }
} }

View File

@@ -107,9 +107,13 @@ use std::{
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tokio::runtime::Builder as TokioRuntimeBuilder; use tokio::runtime::Builder as TokioRuntimeBuilder;
use tokio::time::timeout; use tokio::time::timeout;
use tracing::{info, warn}; use tracing::{error, info};
use crate::{app::build_router, config::AppConfig, state::AppState}; use crate::{
app::{build_router, build_spacetime_unavailable_router},
config::AppConfig,
state::{AppState, AppStateInitError},
};
const API_SERVER_STARTUP_STACK_SIZE_BYTES: usize = 32 * 1024 * 1024; const API_SERVER_STARTUP_STACK_SIZE_BYTES: usize = 32 * 1024 * 1024;
const AUTH_STORE_STARTUP_RESTORE_TIMEOUT: Duration = Duration::from_secs(8); const AUTH_STORE_STARTUP_RESTORE_TIMEOUT: Duration = Duration::from_secs(8);
@@ -156,14 +160,21 @@ async fn run_server(config: AppConfig) -> Result<(), io::Error> {
let otel_enabled = config.otel_enabled; let otel_enabled = config.otel_enabled;
let listener = build_tcp_listener(bind_address, listen_backlog)?; let listener = build_tcp_listener(bind_address, listen_backlog)?;
let state = restore_app_state_for_startup(config) let router = match restore_app_state_for_startup(config).await {
.await Ok(state) => {
.map_err(|error| std::io::Error::other(format!("初始化应用状态失败:{error}")))?;
state.puzzle_gallery_cache().spawn_cleanup_task(); state.puzzle_gallery_cache().spawn_cleanup_task();
if let Some(outbox) = state.tracking_outbox() { if let Some(outbox) = state.tracking_outbox() {
outbox.spawn_worker(); outbox.spawn_worker();
} }
let router = build_router(state); build_router(state)
}
Err(AppStateInitError::DependencyUnavailable(message)) => {
build_spacetime_unavailable_router(message)
}
Err(error) => {
return Err(std::io::Error::other(format!("初始化应用状态失败:{error}")));
}
};
info!( info!(
%bind_address, %bind_address,
@@ -192,7 +203,6 @@ fn build_tcp_listener(
async fn restore_app_state_for_startup( async fn restore_app_state_for_startup(
config: AppConfig, config: AppConfig,
) -> Result<AppState, state::AppStateInitError> { ) -> Result<AppState, state::AppStateInitError> {
let fallback_config = config.clone();
match timeout( match timeout(
AUTH_STORE_STARTUP_RESTORE_TIMEOUT, AUTH_STORE_STARTUP_RESTORE_TIMEOUT,
AppState::try_restore_auth_store_from_spacetime(config), AppState::try_restore_auth_store_from_spacetime(config),
@@ -201,11 +211,13 @@ async fn restore_app_state_for_startup(
{ {
Ok(result) => result, Ok(result) => result,
Err(_) => { Err(_) => {
warn!( error!(
timeout_seconds = AUTH_STORE_STARTUP_RESTORE_TIMEOUT.as_secs(), timeout_seconds = AUTH_STORE_STARTUP_RESTORE_TIMEOUT.as_secs(),
"启动恢复认证快照超时,跳过远端恢复并继续启动 api-server" "启动等待 SpacetimeDB 恢复认证快照超时api-server 将进入依赖不可用模式"
); );
AppState::new(fallback_config) Err(state::AppStateInitError::DependencyUnavailable(
"SpacetimeDB 启动恢复认证快照超时".to_string(),
))
} }
} }
} }

View File

@@ -1,9 +1,8 @@
use std::{ use std::{
collections::HashMap, collections::HashMap,
error::Error, error::Error,
fmt, fs, fmt,
sync::{Arc, Mutex}, sync::{Arc, Mutex},
time::{SystemTime, UNIX_EPOCH},
}; };
use axum::extract::FromRef; use axum::extract::FromRef;
@@ -300,6 +299,7 @@ pub enum AppStateInitError {
Jwt(JwtError), Jwt(JwtError),
RefreshCookie(RefreshCookieError), RefreshCookie(RefreshCookieError),
AuthStore(String), AuthStore(String),
DependencyUnavailable(String),
SmsProvider(SmsProviderError), SmsProvider(SmsProviderError),
WechatPay(String), WechatPay(String),
Oss(OssError), Oss(OssError),
@@ -308,12 +308,12 @@ pub enum AppStateInitError {
impl AppState { impl AppState {
pub fn new(config: AppConfig) -> Result<Self, AppStateInitError> { pub fn new(config: AppConfig) -> Result<Self, AppStateInitError> {
#[cfg(test)] Self::new_with_empty_auth_store(config)
let auth_store = InMemoryAuthStore::default(); }
#[cfg(not(test))]
let auth_store = InMemoryAuthStore::from_persistence_path(config.auth_store_path.clone()) pub fn new_with_empty_auth_store(config: AppConfig) -> Result<Self, AppStateInitError> {
.map_err(AppStateInitError::AuthStore)?; // 中文注释api-server 不再把本地 auth-store.json 当作用户认证真相源,启动恢复只允许来自 SpacetimeDB。
Self::new_with_auth_store(config, auth_store) Self::new_with_auth_store(config, InMemoryAuthStore::default())
} }
fn new_with_auth_store( fn new_with_auth_store(
@@ -549,8 +549,8 @@ impl AppState {
OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000, OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000,
) )
.map_err(|_| SpacetimeClientError::Runtime("认证快照更新时间超出 i64 范围".to_string()))?; .map_err(|_| SpacetimeClientError::Runtime("认证快照更新时间超出 i64 范围".to_string()))?;
// 本地 auth_store 是当前认证请求的即时真相源SpacetimeDB 正式认证表用于跨进程恢复。 // 当前进程内 auth_store 是认证请求的即时工作集SpacetimeDB 正式认证表用于跨进程恢复。
// 远端数据库挂起或网络异常时,只降级远端恢复能力,不能让已成功的登录/刷新/退出回滚为失败。 // 远端数据库挂起或网络异常时,只降级后续恢复能力,不能让已成功的登录/刷新/退出回滚为失败。
#[cfg(not(test))] #[cfg(not(test))]
if let Err(error) = self if let Err(error) = self
.spacetime_client .spacetime_client
@@ -577,64 +577,42 @@ impl AppState {
pool_size: config.spacetime_pool_size, pool_size: config.spacetime_pool_size,
procedure_timeout: config.spacetime_procedure_timeout, procedure_timeout: config.spacetime_procedure_timeout,
}); });
let mut candidates = Vec::new(); let mut spacetime_restore_available = false;
let mut restore_errors = Vec::new();
match spacetime_client match spacetime_client
.export_auth_store_snapshot_from_tables() .export_auth_store_snapshot_from_tables()
.await .await
{ {
Ok(snapshot) => { Ok(snapshot) => {
spacetime_restore_available = true;
if let Some(candidate) = auth_store_candidate_from_snapshot_record( if let Some(candidate) = auth_store_candidate_from_snapshot_record(
snapshot, snapshot,
AuthStoreRestoreSource::SpacetimeTables, AuthStoreRestoreSource::SpacetimeTables,
)? { )? {
candidates.push(candidate); 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) => { Err(error) => {
warn!(error = %error, "从 SpacetimeDB 表恢复认证快照失败"); warn!(error = %error, "从 SpacetimeDB 表恢复认证快照失败");
restore_errors.push(error.to_string());
} }
} }
match spacetime_client.get_auth_store_snapshot().await { if !spacetime_restore_available {
Ok(snapshot) => { return Err(AppStateInitError::DependencyUnavailable(format!(
if let Some(candidate) = auth_store_candidate_from_snapshot_record( "SpacetimeDB 认证恢复不可用:{}",
snapshot, restore_errors.join("; ")
AuthStoreRestoreSource::SpacetimeSnapshot, )));
)? {
candidates.push(candidate);
}
}
Err(error) => {
warn!(error = %error, "从 SpacetimeDB 快照记录恢复认证快照失败");
}
} }
if let Some(candidate) = auth_store_candidate_from_local_file(&config)? { Self::new_with_empty_auth_store(config)
candidates.push(candidate);
}
if let Some(candidate) = select_auth_store_restore_candidate(candidates) {
let source = candidate.source;
let should_sync_to_spacetime = source == AuthStoreRestoreSource::LocalFile;
let state = Self::new_with_auth_store(config, candidate.auth_store)?;
info!(
source = source.as_str(),
updated_at_micros = candidate.updated_at_micros,
"已恢复认证快照"
);
if should_sync_to_spacetime {
if let Err(error) = state.sync_auth_store_snapshot_to_spacetime().await {
warn!(
error = %error,
"本地认证快照回写 SpacetimeDB 失败,当前启动继续"
);
}
}
return Ok(state);
}
Self::new(config)
} }
pub fn refresh_session_service(&self) -> &RefreshSessionService { pub fn refresh_session_service(&self) -> &RefreshSessionService {
@@ -988,16 +966,12 @@ impl AppState {
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum AuthStoreRestoreSource { enum AuthStoreRestoreSource {
SpacetimeTables, SpacetimeTables,
SpacetimeSnapshot,
LocalFile,
} }
impl AuthStoreRestoreSource { impl AuthStoreRestoreSource {
fn as_str(self) -> &'static str { fn as_str(self) -> &'static str {
match self { match self {
Self::SpacetimeTables => "spacetime_tables", Self::SpacetimeTables => "spacetime_tables",
Self::SpacetimeSnapshot => "spacetime_snapshot",
Self::LocalFile => "local_file",
} }
} }
} }
@@ -1029,57 +1003,14 @@ fn auth_store_candidate_from_snapshot_record(
})) }))
} }
fn auth_store_candidate_from_local_file(
config: &AppConfig,
) -> Result<Option<AuthStoreRestoreCandidate>, AppStateInitError> {
if !config.auth_store_path.is_file() {
return Ok(None);
}
let updated_at_micros = fs::metadata(&config.auth_store_path)
.ok()
.and_then(|metadata| metadata.modified().ok())
.and_then(system_time_to_unix_micros);
let auth_store = InMemoryAuthStore::from_persistence_path(config.auth_store_path.clone())
.map_err(AppStateInitError::AuthStore)?;
Ok(Some(AuthStoreRestoreCandidate {
source: AuthStoreRestoreSource::LocalFile,
updated_at_micros,
auth_store,
}))
}
fn system_time_to_unix_micros(system_time: SystemTime) -> Option<i64> {
let duration = system_time.duration_since(UNIX_EPOCH).ok()?;
i64::try_from(duration.as_micros()).ok()
}
fn select_auth_store_restore_candidate(
candidates: Vec<AuthStoreRestoreCandidate>,
) -> Option<AuthStoreRestoreCandidate> {
candidates.into_iter().max_by_key(|candidate| {
(
candidate.updated_at_micros.unwrap_or(i64::MIN),
auth_store_restore_source_priority(candidate.source),
)
})
}
fn auth_store_restore_source_priority(source: AuthStoreRestoreSource) -> u8 {
match source {
AuthStoreRestoreSource::SpacetimeTables => 3,
AuthStoreRestoreSource::SpacetimeSnapshot => 2,
AuthStoreRestoreSource::LocalFile => 1,
}
}
impl fmt::Display for AppStateInitError { impl fmt::Display for AppStateInitError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { match self {
Self::Jwt(error) => write!(f, "{error}"), Self::Jwt(error) => write!(f, "{error}"),
Self::RefreshCookie(error) => write!(f, "{error}"), Self::RefreshCookie(error) => write!(f, "{error}"),
Self::AuthStore(error) | Self::WechatPay(error) => write!(f, "{error}"), Self::AuthStore(error) | Self::DependencyUnavailable(error) | Self::WechatPay(error) => {
write!(f, "{error}")
}
Self::SmsProvider(error) => write!(f, "{error}"), Self::SmsProvider(error) => write!(f, "{error}"),
Self::Oss(error) => write!(f, "{error}"), Self::Oss(error) => write!(f, "{error}"),
Self::Llm(error) => write!(f, "{error}"), Self::Llm(error) => write!(f, "{error}"),

View File

@@ -12,8 +12,6 @@ pub use events::*;
use std::{ use std::{
collections::HashMap, collections::HashMap,
fs,
path::{Path, PathBuf},
sync::{Arc, Mutex}, sync::{Arc, Mutex},
}; };
@@ -33,7 +31,6 @@ use tracing::{info, warn};
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct InMemoryAuthStore { pub struct InMemoryAuthStore {
inner: Arc<Mutex<InMemoryAuthStoreState>>, inner: Arc<Mutex<InMemoryAuthStoreState>>,
persistence_path: Option<Arc<PathBuf>>,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -887,7 +884,6 @@ impl Default for InMemoryAuthStore {
fn default() -> Self { fn default() -> Self {
Self { Self {
inner: Arc::new(Mutex::new(InMemoryAuthStoreState::default())), inner: Arc::new(Mutex::new(InMemoryAuthStoreState::default())),
persistence_path: None,
} }
} }
} }
@@ -936,14 +932,6 @@ impl InMemoryAuthStoreState {
} }
} }
fn build_temp_persistence_path(path: &Path) -> PathBuf {
let file_name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("auth-store.json");
path.with_file_name(format!("{file_name}.tmp"))
}
impl InMemoryAuthStore { impl InMemoryAuthStore {
pub fn from_snapshot_json(snapshot_json: &str) -> Result<Self, String> { pub fn from_snapshot_json(snapshot_json: &str) -> Result<Self, String> {
let snapshot = serde_json::from_str::<PersistentAuthStoreSnapshot>(snapshot_json) let snapshot = serde_json::from_str::<PersistentAuthStoreSnapshot>(snapshot_json)
@@ -952,25 +940,6 @@ impl InMemoryAuthStore {
inner: Arc::new(Mutex::new( inner: Arc::new(Mutex::new(
InMemoryAuthStoreState::from_persistent_snapshot(snapshot), InMemoryAuthStoreState::from_persistent_snapshot(snapshot),
)), )),
persistence_path: None,
})
}
pub fn from_persistence_path(path: impl Into<PathBuf>) -> Result<Self, String> {
let path = path.into();
let state = if path.is_file() {
let raw_text =
fs::read_to_string(&path).map_err(|error| format!("读取认证快照失败:{error}"))?;
let snapshot = serde_json::from_str::<PersistentAuthStoreSnapshot>(&raw_text)
.map_err(|error| format!("解析认证快照失败:{error}"))?;
InMemoryAuthStoreState::from_persistent_snapshot(snapshot)
} else {
InMemoryAuthStoreState::default()
};
Ok(Self {
inner: Arc::new(Mutex::new(state)),
persistence_path: Some(Arc::new(path)),
}) })
} }
@@ -985,30 +954,8 @@ impl InMemoryAuthStore {
} }
fn persist_state(&self, state: &InMemoryAuthStoreState) -> Result<(), String> { fn persist_state(&self, state: &InMemoryAuthStoreState) -> Result<(), String> {
let Some(path) = self.persistence_path.as_deref() else { let _ = state;
return Ok(()); Ok(())
};
if let Some(parent_dir) = path.parent() {
fs::create_dir_all(parent_dir).map_err(|error| {
format!(
"创建认证快照目录失败:{},路径:{}",
error,
parent_dir.display()
)
})?;
}
let snapshot = state.to_persistent_snapshot();
let raw_text = serde_json::to_string_pretty(&snapshot)
.map_err(|error| format!("序列化认证快照失败:{error}"))?;
let temp_path = build_temp_persistence_path(path);
fs::write(&temp_path, raw_text)
.map_err(|error| format!("写入认证快照临时文件失败:{error}"))?;
fs::rename(&temp_path, path).map_err(|error| {
let _ = fs::remove_file(&temp_path);
format!("替换认证快照文件失败:{error}")
})
} }
fn persist_password_state( fn persist_password_state(
@@ -2545,15 +2492,8 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn persistent_store_restores_user_and_refresh_session_after_restart() { async fn snapshot_json_restores_user_and_refresh_session_after_roundtrip() {
let store_path = std::env::temp_dir().join(format!( let store = InMemoryAuthStore::default();
"genarrative-auth-store-{}.json",
new_uuid_simple_string()
));
let _ = std::fs::remove_file(&store_path);
let store = InMemoryAuthStore::from_persistence_path(store_path.clone())
.expect("persistent store should initialize");
let user = create_phone_login_user(store.clone(), "13800138003").await; let user = create_phone_login_user(store.clone(), "13800138003").await;
let password_service = build_password_service(store.clone()); let password_service = build_password_service(store.clone());
let refresh_service = build_refresh_service(store.clone()); let refresh_service = build_refresh_service(store.clone());
@@ -2576,10 +2516,12 @@ mod tests {
OffsetDateTime::now_utc(), OffsetDateTime::now_utc(),
) )
.expect("refresh session should be persisted"); .expect("refresh session should be persisted");
drop(store);
let restored_store = InMemoryAuthStore::from_persistence_path(store_path.clone()) let snapshot_json = store
.expect("persistent store should restore"); .export_snapshot_json()
.expect("snapshot export should succeed");
let restored_store = InMemoryAuthStore::from_snapshot_json(&snapshot_json)
.expect("snapshot json should restore");
let restored_user = build_password_service(restored_store.clone()) let restored_user = build_password_service(restored_store.clone())
.get_user_by_id(&user.id) .get_user_by_id(&user.id)
.expect("restored user query should succeed") .expect("restored user query should succeed")
@@ -2597,8 +2539,6 @@ mod tests {
) )
.expect("restored refresh session should rotate"); .expect("restored refresh session should rotate");
assert_eq!(rotated.user.id, user.id); assert_eq!(rotated.user.id, user.id);
let _ = std::fs::remove_file(&store_path);
} }
#[tokio::test] #[tokio::test]

View File

@@ -20,46 +20,6 @@ impl SpacetimeClient {
.await .await
} }
pub async fn get_auth_store_snapshot(
&self,
) -> Result<AuthStoreSnapshotRecord, SpacetimeClientError> {
self.call_after_connect("get_auth_store_snapshot", move |connection, sender| {
connection
.procedures()
.get_auth_store_snapshot_then(move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_auth_store_snapshot_procedure_result);
send_once(&sender, mapped);
});
})
.await
}
pub async fn upsert_auth_store_snapshot(
&self,
snapshot_json: String,
updated_at_micros: i64,
) -> Result<AuthStoreSnapshotRecord, SpacetimeClientError> {
let procedure_input = AuthStoreSnapshotUpsertInput {
snapshot_json,
updated_at_micros,
};
self.call_after_connect("upsert_auth_store_snapshot", move |connection, sender| {
connection.procedures().upsert_auth_store_snapshot_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_auth_store_snapshot_procedure_result);
send_once(&sender, mapped);
},
);
})
.await
}
pub async fn import_auth_store_snapshot_json( pub async fn import_auth_store_snapshot_json(
&self, &self,
snapshot_json: String, snapshot_json: String,
@@ -85,20 +45,4 @@ impl SpacetimeClient {
) )
.await .await
} }
pub async fn import_auth_store_snapshot(
&self,
) -> Result<AuthStoreSnapshotImportRecord, SpacetimeClientError> {
self.call_after_connect("import_auth_store_snapshot", move |connection, sender| {
connection
.procedures()
.import_auth_store_snapshot_then(move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_auth_store_snapshot_import_procedure_result);
send_once(&sender, mapped);
});
})
.await
}
} }

View File

@@ -344,7 +344,6 @@ pub mod finish_match_3_d_time_up_procedure;
pub mod finish_square_hole_time_up_procedure; pub mod finish_square_hole_time_up_procedure;
pub mod finish_wooden_fish_run_procedure; pub mod finish_wooden_fish_run_procedure;
pub mod generate_big_fish_asset_procedure; pub mod generate_big_fish_asset_procedure;
pub mod get_auth_store_snapshot_procedure;
pub mod get_bark_battle_run_procedure; pub mod get_bark_battle_run_procedure;
pub mod get_bark_battle_runtime_config_procedure; pub mod get_bark_battle_runtime_config_procedure;
pub mod get_battle_state_procedure; pub mod get_battle_state_procedure;
@@ -393,7 +392,6 @@ pub mod grant_new_user_registration_wallet_reward_procedure;
pub mod grant_player_progression_experience_and_return_procedure; pub mod grant_player_progression_experience_and_return_procedure;
pub mod grant_player_progression_experience_reducer; pub mod grant_player_progression_experience_reducer;
pub mod import_auth_store_snapshot_json_procedure; pub mod import_auth_store_snapshot_json_procedure;
pub mod import_auth_store_snapshot_procedure;
pub mod import_database_migration_from_chunks_procedure; pub mod import_database_migration_from_chunks_procedure;
pub mod import_database_migration_from_file_procedure; pub mod import_database_migration_from_file_procedure;
pub mod import_database_migration_incremental_from_chunks_procedure; pub mod import_database_migration_incremental_from_chunks_procedure;
@@ -942,7 +940,6 @@ pub mod update_puzzle_work_procedure;
pub mod update_square_hole_work_procedure; pub mod update_square_hole_work_procedure;
pub mod update_visual_novel_work_procedure; pub mod update_visual_novel_work_procedure;
pub mod update_wooden_fish_work_procedure; pub mod update_wooden_fish_work_procedure;
pub mod upsert_auth_store_snapshot_procedure;
pub mod upsert_chapter_progression_and_return_procedure; pub mod upsert_chapter_progression_and_return_procedure;
pub mod upsert_chapter_progression_reducer; pub mod upsert_chapter_progression_reducer;
pub mod upsert_creation_entry_type_config_procedure; pub mod upsert_creation_entry_type_config_procedure;
@@ -1379,7 +1376,6 @@ pub use finish_match_3_d_time_up_procedure::finish_match_3_d_time_up;
pub use finish_square_hole_time_up_procedure::finish_square_hole_time_up; pub use finish_square_hole_time_up_procedure::finish_square_hole_time_up;
pub use finish_wooden_fish_run_procedure::finish_wooden_fish_run; pub use finish_wooden_fish_run_procedure::finish_wooden_fish_run;
pub use generate_big_fish_asset_procedure::generate_big_fish_asset; pub use generate_big_fish_asset_procedure::generate_big_fish_asset;
pub use get_auth_store_snapshot_procedure::get_auth_store_snapshot;
pub use get_bark_battle_run_procedure::get_bark_battle_run; pub use get_bark_battle_run_procedure::get_bark_battle_run;
pub use get_bark_battle_runtime_config_procedure::get_bark_battle_runtime_config; pub use get_bark_battle_runtime_config_procedure::get_bark_battle_runtime_config;
pub use get_battle_state_procedure::get_battle_state; pub use get_battle_state_procedure::get_battle_state;
@@ -1428,7 +1424,6 @@ pub use grant_new_user_registration_wallet_reward_procedure::grant_new_user_regi
pub use grant_player_progression_experience_and_return_procedure::grant_player_progression_experience_and_return; 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 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_auth_store_snapshot_json_procedure::import_auth_store_snapshot_json;
pub use import_auth_store_snapshot_procedure::import_auth_store_snapshot;
pub use import_database_migration_from_chunks_procedure::import_database_migration_from_chunks; 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_from_file_procedure::import_database_migration_from_file;
pub use import_database_migration_incremental_from_chunks_procedure::import_database_migration_incremental_from_chunks; pub use import_database_migration_incremental_from_chunks_procedure::import_database_migration_incremental_from_chunks;
@@ -1977,7 +1972,6 @@ pub use update_puzzle_work_procedure::update_puzzle_work;
pub use update_square_hole_work_procedure::update_square_hole_work; pub use update_square_hole_work_procedure::update_square_hole_work;
pub use update_visual_novel_work_procedure::update_visual_novel_work; pub use update_visual_novel_work_procedure::update_visual_novel_work;
pub use update_wooden_fish_work_procedure::update_wooden_fish_work; pub use update_wooden_fish_work_procedure::update_wooden_fish_work;
pub use upsert_auth_store_snapshot_procedure::upsert_auth_store_snapshot;
pub use upsert_chapter_progression_and_return_procedure::upsert_chapter_progression_and_return; pub use upsert_chapter_progression_and_return_procedure::upsert_chapter_progression_and_return;
pub use upsert_chapter_progression_reducer::upsert_chapter_progression; pub use upsert_chapter_progression_reducer::upsert_chapter_progression;
pub use upsert_creation_entry_type_config_procedure::upsert_creation_entry_type_config; pub use upsert_creation_entry_type_config_procedure::upsert_creation_entry_type_config;

View File

@@ -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 GetAuthStoreSnapshotArgs {}
impl __sdk::InModule for GetAuthStoreSnapshotArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `get_auth_store_snapshot`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait get_auth_store_snapshot {
fn get_auth_store_snapshot(&self) {
self.get_auth_store_snapshot_then(|_, _| {});
}
fn get_auth_store_snapshot_then(
&self,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<AuthStoreSnapshotProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl get_auth_store_snapshot for super::RemoteProcedures {
fn get_auth_store_snapshot_then(
&self,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<AuthStoreSnapshotProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, AuthStoreSnapshotProcedureResult>(
"get_auth_store_snapshot",
GetAuthStoreSnapshotArgs {},
__callback,
);
}
}

View File

@@ -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_import_procedure_result_type::AuthStoreSnapshotImportProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct ImportAuthStoreSnapshotArgs {}
impl __sdk::InModule for ImportAuthStoreSnapshotArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `import_auth_store_snapshot`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait import_auth_store_snapshot {
fn import_auth_store_snapshot(&self) {
self.import_auth_store_snapshot_then(|_, _| {});
}
fn import_auth_store_snapshot_then(
&self,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<AuthStoreSnapshotImportProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl import_auth_store_snapshot for super::RemoteProcedures {
fn import_auth_store_snapshot_then(
&self,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<AuthStoreSnapshotImportProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, AuthStoreSnapshotImportProcedureResult>(
"import_auth_store_snapshot",
ImportAuthStoreSnapshotArgs {},
__callback,
);
}
}

View File

@@ -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_procedure_result_type::AuthStoreSnapshotProcedureResult;
use super::auth_store_snapshot_upsert_input_type::AuthStoreSnapshotUpsertInput;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct UpsertAuthStoreSnapshotArgs {
pub input: AuthStoreSnapshotUpsertInput,
}
impl __sdk::InModule for UpsertAuthStoreSnapshotArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `upsert_auth_store_snapshot`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait upsert_auth_store_snapshot {
fn upsert_auth_store_snapshot(&self, input: AuthStoreSnapshotUpsertInput) {
self.upsert_auth_store_snapshot_then(input, |_, _| {});
}
fn upsert_auth_store_snapshot_then(
&self,
input: AuthStoreSnapshotUpsertInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<AuthStoreSnapshotProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl upsert_auth_store_snapshot for super::RemoteProcedures {
fn upsert_auth_store_snapshot_then(
&self,
input: AuthStoreSnapshotUpsertInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<AuthStoreSnapshotProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, AuthStoreSnapshotProcedureResult>(
"upsert_auth_store_snapshot",
UpsertAuthStoreSnapshotArgs { input },
__callback,
);
}
}

View File

@@ -1,3 +1,5 @@
use serde::Serialize;
use crate::{ProcedureContext, ReducerContext, SpacetimeType, Table, Timestamp}; use crate::{ProcedureContext, ReducerContext, SpacetimeType, Table, Timestamp};
use super::{ use super::{
@@ -13,8 +15,14 @@ use super::{
}, },
}; };
const AUTH_STORE_SNAPSHOT_ID: &str = "default";
const AUTH_STORE_PROJECTION_META_ID: &str = "default"; const AUTH_STORE_PROJECTION_META_ID: &str = "default";
const AUTH_STORE_SNAPSHOT_META_NEXT_USER_ID: &str = "meta/next_user_id";
const AUTH_STORE_SNAPSHOT_USER_PREFIX: &str = "user/";
const AUTH_STORE_SNAPSHOT_PHONE_PREFIX: &str = "phone/";
const AUTH_STORE_SNAPSHOT_SESSION_PREFIX: &str = "session/";
const AUTH_STORE_SNAPSHOT_SESSION_HASH_PREFIX: &str = "session_hash/";
const AUTH_STORE_SNAPSHOT_WECHAT_PREFIX: &str = "wechat/";
const AUTH_STORE_SNAPSHOT_UNION_PREFIX: &str = "union/";
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct AuthStoreSnapshotRecord { pub struct AuthStoreSnapshotRecord {
@@ -41,6 +49,74 @@ fn normalize_user_account_tags(
module_runtime::normalize_profile_user_tags(tags.unwrap_or_default()) module_runtime::normalize_profile_user_tags(tags.unwrap_or_default())
} }
fn prefixed_snapshot_id(prefix: &str, value: &str) -> String {
format!("{prefix}{}", sanitize_identity_component(value))
}
fn upsert_auth_snapshot_row(
ctx: &ReducerContext,
snapshot_id: String,
snapshot_json: String,
updated_at: Timestamp,
) {
if ctx
.db
.auth_store_snapshot()
.snapshot_id()
.find(&snapshot_id)
.is_some()
{
ctx.db.auth_store_snapshot().snapshot_id().delete(&snapshot_id);
}
ctx.db.auth_store_snapshot().insert(AuthStoreSnapshot {
snapshot_id,
snapshot_json,
updated_at,
});
}
fn auth_store_snapshot_user_row_id(user_id: &str) -> String {
prefixed_snapshot_id(AUTH_STORE_SNAPSHOT_USER_PREFIX, user_id)
}
fn auth_store_snapshot_phone_row_id(phone_number: &str, user_id: &str) -> String {
prefixed_snapshot_id(
AUTH_STORE_SNAPSHOT_PHONE_PREFIX,
&format!("{phone_number}|{user_id}"),
)
}
fn auth_store_snapshot_session_row_id(session_id: &str) -> String {
prefixed_snapshot_id(AUTH_STORE_SNAPSHOT_SESSION_PREFIX, session_id)
}
fn auth_store_snapshot_session_hash_row_id(refresh_token_hash: &str, session_id: &str) -> String {
prefixed_snapshot_id(
AUTH_STORE_SNAPSHOT_SESSION_HASH_PREFIX,
&format!("{refresh_token_hash}|{session_id}"),
)
}
fn auth_store_snapshot_wechat_row_id(provider_uid: &str, user_id: &str) -> String {
prefixed_snapshot_id(
AUTH_STORE_SNAPSHOT_WECHAT_PREFIX,
&format!("{provider_uid}|{user_id}"),
)
}
fn auth_store_snapshot_union_row_id(union_id: &str, user_id: &str) -> String {
prefixed_snapshot_id(AUTH_STORE_SNAPSHOT_UNION_PREFIX, &format!("{union_id}|{user_id}"))
}
fn snapshot_has_user_rows(snapshot: &PersistentAuthStoreSnapshot) -> bool {
!snapshot.users_by_username.is_empty()
}
fn to_snapshot_row_json<T: Serialize>(label: &str, value: &T) -> Result<String, String> {
serde_json::to_string(value).map_err(|error| format!("{label} 序列化失败:{error}"))
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct AuthStoreSnapshotImportRecord { pub struct AuthStoreSnapshotImportRecord {
pub imported_user_count: u32, pub imported_user_count: u32,
@@ -55,44 +131,7 @@ pub struct AuthStoreSnapshotImportProcedureResult {
pub error_message: Option<String>, pub error_message: Option<String>,
} }
// Axum 启动恢复认证状态时读取当前快照;记录不存在代表尚未产生登录态 // Axum 运行期认证变更直接导入正式认证表,并把快照拆成行级记录;禁止再写 `auth_store_snapshot/default`
#[spacetimedb::procedure]
pub fn get_auth_store_snapshot(ctx: &mut ProcedureContext) -> AuthStoreSnapshotProcedureResult {
match ctx.try_with_tx(|tx| get_auth_store_snapshot_tx(tx)) {
Ok(record) => AuthStoreSnapshotProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => AuthStoreSnapshotProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
// 历史迁移入口:覆盖写入整份快照,供旧库从 `auth_store_snapshot/default` 导入正式表。
#[spacetimedb::procedure]
pub fn upsert_auth_store_snapshot(
ctx: &mut ProcedureContext,
input: AuthStoreSnapshotUpsertInput,
) -> AuthStoreSnapshotProcedureResult {
match ctx.try_with_tx(|tx| upsert_auth_store_snapshot_tx(tx, input.clone())) {
Ok(record) => AuthStoreSnapshotProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => AuthStoreSnapshotProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
// Axum 运行期认证变更直接导入正式认证表,不再继续刷新 `auth_store_snapshot/default`。
#[spacetimedb::procedure] #[spacetimedb::procedure]
pub fn import_auth_store_snapshot_json( pub fn import_auth_store_snapshot_json(
ctx: &mut ProcedureContext, ctx: &mut ProcedureContext,
@@ -112,24 +151,6 @@ pub fn import_auth_store_snapshot_json(
} }
} }
#[spacetimedb::procedure]
pub fn import_auth_store_snapshot(
ctx: &mut ProcedureContext,
) -> AuthStoreSnapshotImportProcedureResult {
match ctx.try_with_tx(|tx| import_auth_store_snapshot_tx(tx)) {
Ok(record) => AuthStoreSnapshotImportProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => AuthStoreSnapshotImportProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
// Axum 启动时可从正式表重新导出 module-auth 使用的整份认证快照。 // Axum 启动时可从正式表重新导出 module-auth 使用的整份认证快照。
#[spacetimedb::procedure] #[spacetimedb::procedure]
pub fn export_auth_store_snapshot_from_tables( pub fn export_auth_store_snapshot_from_tables(
@@ -149,78 +170,6 @@ pub fn export_auth_store_snapshot_from_tables(
} }
} }
fn get_auth_store_snapshot_tx(ctx: &ReducerContext) -> Result<AuthStoreSnapshotRecord, String> {
Ok(
match ctx
.db
.auth_store_snapshot()
.snapshot_id()
.find(&AUTH_STORE_SNAPSHOT_ID.to_string())
{
Some(row) => AuthStoreSnapshotRecord {
snapshot_json: Some(row.snapshot_json),
updated_at_micros: Some(row.updated_at.to_micros_since_unix_epoch()),
},
None => AuthStoreSnapshotRecord {
snapshot_json: None,
updated_at_micros: None,
},
},
)
}
fn upsert_auth_store_snapshot_tx(
ctx: &ReducerContext,
input: AuthStoreSnapshotUpsertInput,
) -> Result<AuthStoreSnapshotRecord, String> {
let snapshot_json = input.snapshot_json.trim().to_string();
if snapshot_json.is_empty() {
return Err("认证快照 JSON 不能为空".to_string());
}
let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros);
if ctx
.db
.auth_store_snapshot()
.snapshot_id()
.find(&AUTH_STORE_SNAPSHOT_ID.to_string())
.is_some()
{
ctx.db
.auth_store_snapshot()
.snapshot_id()
.delete(&AUTH_STORE_SNAPSHOT_ID.to_string());
}
ctx.db.auth_store_snapshot().insert(AuthStoreSnapshot {
snapshot_id: AUTH_STORE_SNAPSHOT_ID.to_string(),
snapshot_json: snapshot_json.clone(),
updated_at,
});
Ok(AuthStoreSnapshotRecord {
snapshot_json: Some(snapshot_json),
updated_at_micros: Some(input.updated_at_micros),
})
}
fn import_auth_store_snapshot_tx(
ctx: &ReducerContext,
) -> Result<AuthStoreSnapshotImportRecord, String> {
let snapshot = ctx
.db
.auth_store_snapshot()
.snapshot_id()
.find(&AUTH_STORE_SNAPSHOT_ID.to_string())
.ok_or_else(|| "认证快照不存在,无法导入正式表".to_string())?;
import_auth_store_snapshot_json_value_tx(
ctx,
&snapshot.snapshot_json,
snapshot.updated_at.to_micros_since_unix_epoch(),
)
}
fn import_auth_store_snapshot_json_tx( fn import_auth_store_snapshot_json_tx(
ctx: &ReducerContext, ctx: &ReducerContext,
input: AuthStoreSnapshotUpsertInput, input: AuthStoreSnapshotUpsertInput,
@@ -239,8 +188,11 @@ fn import_auth_store_snapshot_json_value_tx(
} }
let parsed = serde_json::from_str::<PersistentAuthStoreSnapshot>(snapshot_json) let parsed = serde_json::from_str::<PersistentAuthStoreSnapshot>(snapshot_json)
.map_err(|error| format!("认证快照 JSON 解析失败:{error}"))?; .map_err(|error| format!("认证快照 JSON 解析失败:{error}"))?;
if !snapshot_has_user_rows(&parsed) {
return Err("认证快照缺少用户记录,拒绝导入正式表".to_string());
}
clear_auth_target_tables(ctx); upsert_auth_store_snapshot_rows(ctx, &parsed, updated_at_micros)?;
upsert_auth_projection_meta(ctx, updated_at_micros); upsert_auth_projection_meta(ctx, updated_at_micros);
let mut imported_user_count = 0_u32; let mut imported_user_count = 0_u32;
@@ -249,8 +201,18 @@ fn import_auth_store_snapshot_json_value_tx(
for stored_user in parsed.users_by_username.into_values() { for stored_user in parsed.users_by_username.into_values() {
let user = stored_user.user; let user = stored_user.user;
let user_id = user.id.clone();
if ctx
.db
.user_account()
.user_id()
.find(&user_id)
.is_some()
{
ctx.db.user_account().user_id().delete(&user_id);
}
ctx.db.user_account().insert(UserAccount { ctx.db.user_account().insert(UserAccount {
user_id: user.id.clone(), user_id: user_id.clone(),
public_user_code: user.public_user_code, public_user_code: user.public_user_code,
username: user.username, username: user.username,
display_name: user.display_name, display_name: user.display_name,
@@ -271,9 +233,19 @@ fn import_auth_store_snapshot_json_value_tx(
imported_user_count += 1; imported_user_count += 1;
if let Some(phone_number) = stored_user.phone_number { 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 { ctx.db.auth_identity().insert(AuthIdentity {
identity_id: format!("authi_phone_{}", sanitize_identity_component(&phone_number)), identity_id,
user_id: user.id, user_id,
provider: "phone".to_string(), provider: "phone".to_string(),
provider_uid: phone_number.clone(), provider_uid: phone_number.clone(),
provider_union_id: None, provider_union_id: None,
@@ -286,11 +258,21 @@ fn import_auth_store_snapshot_json_value_tx(
} }
for identity in parsed.wechat_identity_by_provider_uid.into_values() { for identity in parsed.wechat_identity_by_provider_uid.into_values() {
ctx.db.auth_identity().insert(AuthIdentity { let identity_id = format!(
identity_id: format!(
"authi_wechat_{}", "authi_wechat_{}",
sanitize_identity_component(&identity.provider_uid) sanitize_identity_component(&identity.provider_uid)
), );
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: identity.user_id, user_id: identity.user_id,
provider: "wechat".to_string(), provider: "wechat".to_string(),
provider_uid: identity.provider_uid, provider_uid: identity.provider_uid,
@@ -306,6 +288,18 @@ fn import_auth_store_snapshot_json_value_tx(
let session = stored_session.session; let session = stored_session.session;
let client_info_json = serde_json::to_string(&session.client_info) let client_info_json = serde_json::to_string(&session.client_info)
.map_err(|error| format!("客户端身份序列化失败:{error}"))?; .map_err(|error| format!("客户端身份序列化失败:{error}"))?;
if ctx
.db
.refresh_session()
.session_id()
.find(&session.session_id)
.is_some()
{
ctx.db
.refresh_session()
.session_id()
.delete(&session.session_id);
}
ctx.db.refresh_session().insert(RefreshSession { ctx.db.refresh_session().insert(RefreshSession {
session_id: session.session_id, session_id: session.session_id,
user_id: session.user_id, user_id: session.user_id,
@@ -328,6 +322,120 @@ fn import_auth_store_snapshot_json_value_tx(
}) })
} }
fn upsert_auth_store_snapshot_rows(
ctx: &ReducerContext,
snapshot: &PersistentAuthStoreSnapshot,
updated_at_micros: i64,
) -> Result<(), String> {
let updated_at = Timestamp::from_micros_since_unix_epoch(updated_at_micros);
let desired_ids = auth_store_snapshot_row_ids(snapshot);
for row in ctx.db.auth_store_snapshot().iter().collect::<Vec<_>>() {
if !desired_ids.contains(&row.snapshot_id) {
ctx.db
.auth_store_snapshot()
.snapshot_id()
.delete(&row.snapshot_id);
}
}
upsert_auth_snapshot_row(
ctx,
AUTH_STORE_SNAPSHOT_META_NEXT_USER_ID.to_string(),
to_snapshot_row_json("认证快照 next_user_id", &snapshot.next_user_id)?,
updated_at,
);
for user in snapshot.users_by_username.values() {
upsert_auth_snapshot_row(
ctx,
auth_store_snapshot_user_row_id(&user.user.id),
to_snapshot_row_json("认证快照用户", user)?,
updated_at,
);
}
for (phone_number, user_id) in &snapshot.phone_to_user_id {
upsert_auth_snapshot_row(
ctx,
auth_store_snapshot_phone_row_id(phone_number, user_id),
to_snapshot_row_json("认证快照手机号索引", user_id)?,
updated_at,
);
}
for session in snapshot.sessions_by_id.values() {
upsert_auth_snapshot_row(
ctx,
auth_store_snapshot_session_row_id(&session.session.session_id),
to_snapshot_row_json("认证快照会话", session)?,
updated_at,
);
}
for (refresh_token_hash, session_id) in &snapshot.session_id_by_refresh_token_hash {
upsert_auth_snapshot_row(
ctx,
auth_store_snapshot_session_hash_row_id(refresh_token_hash, session_id),
to_snapshot_row_json("认证快照 refresh token 索引", session_id)?,
updated_at,
);
}
for identity in snapshot.wechat_identity_by_provider_uid.values() {
upsert_auth_snapshot_row(
ctx,
auth_store_snapshot_wechat_row_id(&identity.provider_uid, &identity.user_id),
to_snapshot_row_json("认证快照微信身份", identity)?,
updated_at,
);
}
for (union_id, user_id) in &snapshot.user_id_by_provider_union_id {
upsert_auth_snapshot_row(
ctx,
auth_store_snapshot_union_row_id(union_id, user_id),
to_snapshot_row_json("认证快照微信 union 索引", user_id)?,
updated_at,
);
}
Ok(())
}
fn auth_store_snapshot_row_ids(
snapshot: &PersistentAuthStoreSnapshot,
) -> std::collections::HashSet<String> {
let mut ids = std::collections::HashSet::new();
ids.insert(AUTH_STORE_SNAPSHOT_META_NEXT_USER_ID.to_string());
for user in snapshot.users_by_username.values() {
ids.insert(auth_store_snapshot_user_row_id(&user.user.id));
}
for (phone_number, user_id) in &snapshot.phone_to_user_id {
ids.insert(auth_store_snapshot_phone_row_id(phone_number, user_id));
}
for session in snapshot.sessions_by_id.values() {
ids.insert(auth_store_snapshot_session_row_id(
&session.session.session_id,
));
}
for (refresh_token_hash, session_id) in &snapshot.session_id_by_refresh_token_hash {
ids.insert(auth_store_snapshot_session_hash_row_id(
refresh_token_hash,
session_id,
));
}
for identity in snapshot.wechat_identity_by_provider_uid.values() {
ids.insert(auth_store_snapshot_wechat_row_id(
&identity.provider_uid,
&identity.user_id,
));
}
for (union_id, user_id) in &snapshot.user_id_by_provider_union_id {
ids.insert(auth_store_snapshot_union_row_id(union_id, user_id));
}
ids
}
fn export_auth_store_snapshot_from_tables_tx( fn export_auth_store_snapshot_from_tables_tx(
ctx: &ReducerContext, ctx: &ReducerContext,
) -> Result<AuthStoreSnapshotRecord, String> { ) -> Result<AuthStoreSnapshotRecord, String> {
@@ -455,6 +563,9 @@ fn export_auth_store_snapshot_from_tables_tx(
wechat_identity_by_provider_uid, wechat_identity_by_provider_uid,
user_id_by_provider_union_id, user_id_by_provider_union_id,
}; };
if let Some(updated_at_micros) = updated_at_micros {
upsert_auth_store_snapshot_rows(ctx, &snapshot, updated_at_micros)?;
}
let snapshot_json = serde_json::to_string_pretty(&snapshot) let snapshot_json = serde_json::to_string_pretty(&snapshot)
.map_err(|error| format!("序列化认证快照失败:{error}"))?; .map_err(|error| format!("序列化认证快照失败:{error}"))?;
@@ -464,24 +575,6 @@ fn export_auth_store_snapshot_from_tables_tx(
}) })
} }
fn clear_auth_target_tables(ctx: &ReducerContext) {
for row in ctx.db.refresh_session().iter().collect::<Vec<_>>() {
ctx.db
.refresh_session()
.session_id()
.delete(&row.session_id);
}
for row in ctx.db.auth_identity().iter().collect::<Vec<_>>() {
ctx.db
.auth_identity()
.identity_id()
.delete(&row.identity_id);
}
for row in ctx.db.user_account().iter().collect::<Vec<_>>() {
ctx.db.user_account().user_id().delete(&row.user_id);
}
}
fn upsert_auth_projection_meta(ctx: &ReducerContext, updated_at_micros: i64) { fn upsert_auth_projection_meta(ctx: &ReducerContext, updated_at_micros: i64) {
let meta_id = AUTH_STORE_PROJECTION_META_ID.to_string(); let meta_id = AUTH_STORE_PROJECTION_META_ID.to_string();
if ctx if ctx
@@ -503,3 +596,121 @@ fn upsert_auth_projection_meta(ctx: &ReducerContext, updated_at_micros: i64) {
updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros),
}); });
} }
#[cfg(test)]
mod tests {
use super::*;
fn sample_snapshot() -> PersistentAuthStoreSnapshot {
let user = StoredPasswordUserSnapshot {
user: AuthUserSnapshot {
id: "user_00000042".to_string(),
public_user_code: "GN-000042".to_string(),
username: "phone_42".to_string(),
display_name: "测试玩家".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: 3,
user_tags: vec!["early".to_string()],
},
password_hash: "hash-42".to_string(),
password_login_enabled: true,
phone_number: Some("+8613800008000".to_string()),
};
let session = StoredRefreshSessionSnapshot {
session: RefreshSessionSnapshot {
session_id: "usess_42".to_string(),
user_id: "user_00000042".to_string(),
refresh_token_hash: "refresh-hash-42".to_string(),
issued_by_provider: "phone".to_string(),
client_info: serde_json::json!({"clientType":"web"}),
expires_at: "2026-06-01T00:00:00Z".to_string(),
revoked_at: None,
created_at: "2026-05-27T00:00:00Z".to_string(),
updated_at: "2026-05-27T00:00:00Z".to_string(),
last_seen_at: "2026-05-27T00:00:00Z".to_string(),
},
};
let identity = StoredWechatIdentitySnapshot {
user_id: "user_00000042".to_string(),
provider_uid: "wx-openid-42".to_string(),
provider_union_id: Some("wx-union-42".to_string()),
display_name: Some("微信玩家".to_string()),
avatar_url: None,
};
PersistentAuthStoreSnapshot {
next_user_id: 43,
users_by_username: std::collections::HashMap::from([(
"phone_42".to_string(),
user,
)]),
phone_to_user_id: std::collections::HashMap::from([(
"+8613800008000".to_string(),
"user_00000042".to_string(),
)]),
sessions_by_id: std::collections::HashMap::from([("usess_42".to_string(), session)]),
session_id_by_refresh_token_hash: std::collections::HashMap::from([(
"refresh-hash-42".to_string(),
"usess_42".to_string(),
)]),
wechat_identity_by_provider_uid: std::collections::HashMap::from([(
"wx-openid-42".to_string(),
identity,
)]),
user_id_by_provider_union_id: std::collections::HashMap::from([(
"wx-union-42".to_string(),
"user_00000042".to_string(),
)]),
}
}
#[test]
fn auth_store_snapshot_row_ids_are_row_level_without_default_aggregate() {
let ids = auth_store_snapshot_row_ids(&sample_snapshot());
assert!(!ids.contains("default"));
assert!(ids.contains(AUTH_STORE_SNAPSHOT_META_NEXT_USER_ID));
assert!(ids.contains(&auth_store_snapshot_user_row_id("user_00000042")));
assert!(ids.contains(&auth_store_snapshot_phone_row_id(
"+8613800008000",
"user_00000042"
)));
assert!(ids.contains(&auth_store_snapshot_session_row_id("usess_42")));
assert!(ids.contains(&auth_store_snapshot_session_hash_row_id(
"refresh-hash-42",
"usess_42"
)));
assert!(ids.contains(&auth_store_snapshot_wechat_row_id(
"wx-openid-42",
"user_00000042"
)));
assert!(ids.contains(&auth_store_snapshot_union_row_id(
"wx-union-42",
"user_00000042"
)));
}
#[test]
fn auth_store_snapshot_user_row_key_is_stable_after_username_change() {
let mut before = sample_snapshot();
let mut after = sample_snapshot();
after.users_by_username.clear();
let mut renamed_user = before
.users_by_username
.remove("phone_42")
.expect("sample user exists");
renamed_user.user.username = "renamed_42".to_string();
after
.users_by_username
.insert("renamed_42".to_string(), renamed_user);
assert_eq!(
auth_store_snapshot_row_ids(&before),
auth_store_snapshot_row_ids(&after)
);
}
}

View File

@@ -3745,6 +3745,12 @@ mod tests {
ui_background_prompt: None, ui_background_prompt: None,
ui_background_image_src: None, ui_background_image_src: None,
ui_background_image_object_key: None, ui_background_image_object_key: None,
level_scene_image_src: None,
level_scene_image_object_key: None,
ui_spritesheet_image_src: None,
ui_spritesheet_image_object_key: None,
level_background_image_src: None,
level_background_image_object_key: None,
background_music: None, background_music: None,
candidates: candidates.clone(), candidates: candidates.clone(),
selected_candidate_id: None, selected_candidate_id: None,