修复认证快照回灌导致重复账号

切断运行中从 SpacetimeDB 整包快照回灌内存的路径

清空旧 auth_store_snapshot 并仅保留一次性迁移

修复手机号字段只在 AuthUser 中时重复创建账号

同步认证排障与后端数据契约文档
This commit is contained in:
2026-06-30 20:58:33 +08:00
parent 3530e9251e
commit e0e3348f79
17 changed files with 355 additions and 502 deletions
@@ -1891,6 +1891,13 @@
- 影响范围:`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-06-30 auth_store_snapshot 只做一次性迁移并切断运行中回灌
- 背景:同手机号重复账号暴露出认证工作集、正式认证表和旧 `auth_store_snapshot` 之间仍有互刷路径;运行中 Bearer / refresh session 未命中后再导出整包状态刷新内存,会把旧手机号索引或旧会话重新带回进程。
- 决策:`auth_store_snapshot` 不再保留行级备查;正式认证表为空时才从最新旧快照转移一次到 `user_account` / `auth_identity` / `refresh_session`,随后清空旧表。`api-server` 运行中不再因 Bearer 用户、token version、session 或 refresh token 未命中而从 SpacetimeDB 导出整包状态刷新 `InMemoryAuthStore`;启动恢复暂保留从正式表构建工作集,直到认证仓储改为直接读写正式表。
- 影响范围:`server-rs/crates/spacetime-module/src/auth/procedures.rs``server-rs/crates/api-server/src/state.rs``server-rs/crates/api-server/src/auth.rs``server-rs/crates/api-server/src/refresh_session.rs`、认证排障记忆与后端架构文档。
- 验证方式:`cargo test -p spacetime-module auth_export -- --nocapture``cargo test -p module-auth phone_only_exists -- --nocapture``cargo test -p module-auth bind_wechat_phone_merges -- --nocapture``npm run check:encoding``git diff --check`
## 2026-05-13 微信小程序支付以后端通知为唯一入账事实
- 背景:“我的”账户充值需要接入微信小程序支付,同时保留本地 / H5 mock 支付联调能力。
+10 -2
View File
@@ -86,6 +86,14 @@
- 验证:`http://127.0.0.1:3101/v1/ping` 可访问、`http://127.0.0.1:8082/healthz` 返回 200、`http://127.0.0.1:3000/``http://127.0.0.1:3102/admin/` 可打开。
- 关联:`scripts/dev.mjs``.app/dev-stack.json``docs/project-memory/shared-memory/development-workflow.md`
## 私有兑换码不适用先查同手机号重复账号
- 现象:后台把私有兑换码配给某个陶泥号或手机号后,用户用同一手机号登录兑换仍提示 `该兑换码不适用于当前账号`
- 原因:认证表里可能存在同一手机号的多条 `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`,不要只看手机号展示值。
- 验证:`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`
## API Build / Deploy 归档清单不能漏掉随包 Pingora 脚本
- 现象:`Genarrative-Api-Deploy` 在发布阶段报 `发布产物缺少 Pingora TLS 证书同步脚本: build/<version>/scripts/deploy/pingora-tls-cert-sync.mjs`
@@ -1191,8 +1199,8 @@
## 忘记密码后仍提示手机号或密码错误先查认证快照同步
- 现象:用户通过“忘记密码”重设密码后,接口返回成功或页面进入登录态,但再次使用新密码登录仍提示“手机号或密码错误”;重启后还可能出现 `Bearer JWT 版本已失效`,日志里的 token version 与本地快照不一致。
- 原因:重置/修改密码会更新 `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` 成功后必须同步认证快照。2026-05-27 起,启动恢复只允许从 SpacetimeDB 正式认证表恢复;`auth_store_snapshot` 保留行级记录,不再写 `default` 聚合单行,也不再把本地文件 `auth-store.json` / `GENARRATIVE_AUTH_STORE_PATH` 当作恢复源。认证创建、登录会话、刷新、退出、改密、重置密码、绑定和资料变更等写操作必须在返回客户端前成功同步 SpacetimeDB;同步失败时接口返回错误,不允许把只存在于当前进程内存的账号或会话当成成功结果。新用户注册奖励、邀请码绑定和登录埋点必须排在认证同步成功之后,避免认证没落库时先写出钱包或邀请关系。若启动时连不上 SpacetimeDB`api-server` 等待启动恢复超时后进入依赖不可用模式,所有请求返回 `503 SERVICE_UNAVAILABLE``details.reason = "spacetime_startup_unavailable"`
- 原因:重置/修改密码会更新 `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;同步失败时接口返回错误,不允许把只存在于当前进程内存的账号或会话当成成功结果。新用户注册奖励、邀请码绑定和登录埋点必须排在认证同步成功之后,避免认证没落库时先写出钱包或邀请关系。
- 验证:执行 `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`
@@ -294,9 +294,9 @@ npm run check:server-rs-ddd
- 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 正式认证表按需刷新一次认证工作集再复查,避免多实例或滚动重启时新登录设备只被签发它的进程认识。`auth_store_snapshot` 只保留行级快照备查,不再作为启动兜底来源`module-auth` 只保留内存工作集和 JSON 导入 / 导出能力,不再写本地持久化文件;`auth-store.json` / `GENARRATIVE_AUTH_STORE_PATH` 不再是兼容恢复源,也不得在启动时回写覆盖 `auth_identity` / `user_account`。认证创建、登录会话、刷新、退出、改密、重置密码、绑定和资料变更等写操作必须在返回客户端前成功同步 SpacetimeDB 正式认证表;同步失败时接口返回错误,不允许把只存在于当前进程内存的账号或会话当成成功结果。新用户注册奖励、邀请码绑定和登录埋点必须排在认证同步成功之后,避免认证没落库时先写出钱包或邀请关系。若启动恢复阶段 SpacetimeDB 不可连接或超时,`api-server` 会按固定间隔持续重试认证工作集恢复,恢复成功后才开始监听 HTTP,避免一次短超时让进程永久停留在依赖不可用状态。
认证恢复策略:`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。认证同步入口收到 `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 本次快照包含的用户、身份和会话,避免过期快照把其他用户整表删除。
`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 快照恢复时也要二次清理这些孤儿索引,避免历史坏快照导致密码登录提示错误、短信登录又提示手机号已存在。
@@ -550,7 +550,7 @@ systemctl restart genarrative-api.service
journalctl -u genarrative-api.service --since '30 seconds ago' --no-pager | grep -E 'tracking outbox|Permission denied|os error 13'
```
`Genarrative-Server-Provision``Genarrative-Api-Deploy` 会在保留旧 `/etc/genarrative/api-server.env` 的前提下补齐缺失的 tracking outbox 运行态路径,并确保 `/var/lib/genarrative/tracking-outbox` 归属 `genarrative:genarrative`。用户认证真相源只允许在 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)恢复;不要再配置或依赖 `GENARRATIVE_AUTH_STORE_PATH` / `auth-store.json``module-auth` 也不再维护本地文件持久化;`auth_store_snapshot` 只保留行级记录,不再保存为单行 `default` 聚合快照,且旧 `get_auth_store_snapshot` / `upsert_auth_store_snapshot` / `import_auth_store_snapshot` 入口已经删除。如果 `api-server` 启动时连不上 SpacetimeDB,会持续重试启动恢复,直到认证工作集从 SpacetimeDB 正式表恢复成功后才开始监听 HTTP,以避免用空本地状态或旧快照覆盖认证表。
`Genarrative-Server-Provision``Genarrative-Api-Deploy` 会在保留旧 `/etc/genarrative/api-server.env` 的前提下补齐缺失的 tracking outbox 运行态路径,并确保 `/var/lib/genarrative/tracking-outbox` 归属 `genarrative:genarrative`。用户认证真相源只允许在 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)恢复;不要再配置或依赖 `GENARRATIVE_AUTH_STORE_PATH` / `auth-store.json``module-auth` 也不再维护本地文件持久化;`auth_store_snapshot` 不再作为备查或运行期恢复源,只在正式认证表为空时一次性转移最新旧快照并清空,且旧 `get_auth_store_snapshot` / `upsert_auth_store_snapshot` / `import_auth_store_snapshot` 入口已经删除。如果 `api-server` 启动时连不上 SpacetimeDB,会持续重试启动恢复,直到认证工作集从 SpacetimeDB 正式表恢复成功后才开始监听 HTTP,以避免用空本地状态或旧快照覆盖认证表。
前端登录态恢复只把 `/api/auth/refresh``401` / `403` 当成权威失效信号;服务器重启窗口里的 `502` / `503` / `504`、浏览器 `Failed to fetch` 或 refresh 响应契约异常都必须保留已有本地 access token,不触发全局 auth 变化。refresh 成功响应以共享契约 `RefreshSessionResponse { token }` 为准,前端不要额外要求业务 `ok` 字段。排查“重启后用户都掉线”时,先区分前端是否被暂时不可用清掉本地 token,再检查 SpacetimeDB 正式认证表是否缺 `user_account` / `refresh_session` 数据。
+3 -80
View File
@@ -231,7 +231,7 @@ async fn authenticate_request(
);
AppError::from_status(StatusCode::UNAUTHORIZED)
})?;
let mut current_user = state
let current_user = state
.auth_user_service()
.get_user_by_id(claims.user_id())
.map_err(|error| {
@@ -242,27 +242,7 @@ async fn authenticate_request(
);
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
})?;
if current_user.is_none() {
warn!(
%request_id,
user_id = %claims.user_id(),
"Bearer JWT 对应用户不存在,准备刷新认证工作集后复查"
);
if refresh_auth_store_for_stale_bearer(state, &request_id, claims.user_id()).await {
current_user = state
.auth_user_service()
.get_user_by_id(claims.user_id())
.map_err(|error| {
warn!(
%request_id,
error = %error,
"Bearer JWT 用户快照刷新后读取失败"
);
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
})?;
}
}
let Some(mut current_user) = current_user else {
let Some(current_user) = current_user else {
warn!(
%request_id,
user_id = %claims.user_id(),
@@ -270,23 +250,6 @@ async fn authenticate_request(
);
return Err(AppError::from_status(StatusCode::UNAUTHORIZED));
};
if current_user.token_version != claims.token_version() {
if refresh_auth_store_for_stale_bearer(state, &request_id, claims.user_id()).await
&& let Some(refreshed_user) = state
.auth_user_service()
.get_user_by_id(claims.user_id())
.map_err(|error| {
warn!(
%request_id,
error = %error,
"Bearer JWT 用户版本刷新后读取失败"
);
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
})?
{
current_user = refreshed_user;
}
}
if current_user.token_version != claims.token_version() {
warn!(
%request_id,
@@ -299,7 +262,7 @@ async fn authenticate_request(
.with_message("当前登录态已失效,请重新登录"));
}
let mut session_is_active = state
let session_is_active = state
.refresh_session_service()
.is_session_active_for_user(
claims.user_id(),
@@ -316,27 +279,6 @@ async fn authenticate_request(
);
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
})?;
if !session_is_active
&& refresh_auth_store_for_stale_bearer(state, &request_id, claims.user_id()).await
{
session_is_active = state
.refresh_session_service()
.is_session_active_for_user(
claims.user_id(),
claims.session_id(),
OffsetDateTime::now_utc(),
)
.map_err(|error| {
warn!(
%request_id,
user_id = %claims.user_id(),
session_id = %claims.session_id(),
error = %error,
"Bearer JWT refresh session 刷新后状态读取失败"
);
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
})?;
}
if !session_is_active {
warn!(
%request_id,
@@ -359,25 +301,6 @@ fn request_id_from_request(request: &Request) -> String {
.unwrap_or_else(|| "unknown".to_string())
}
async fn refresh_auth_store_for_stale_bearer(
state: &AppState,
request_id: &str,
user_id: &str,
) -> bool {
match state.refresh_auth_store_from_spacetime().await {
Ok(refreshed) => refreshed,
Err(error) => {
warn!(
%request_id,
user_id = %user_id,
error = %error,
"刷新认证工作集失败,继续按本进程现有状态处理"
);
false
}
}
}
pub async fn inspect_auth_claims(
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
@@ -94,11 +94,11 @@ pub async fn revoke_auth_session(
);
}
state
.sync_auth_store_snapshot_to_spacetime()
.sync_auth_store_tables_to_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
.with_message(format!("同步认证状态失败:{error}"))
})?;
Ok(json_success_body(
+2 -2
View File
@@ -46,11 +46,11 @@ pub async fn logout(
)
.map_err(map_logout_error)?;
state
.sync_auth_store_snapshot_to_spacetime()
.sync_auth_store_tables_to_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
.with_message(format!("同步认证状态失败:{error}"))
})?;
let mut headers = HeaderMap::new();
@@ -33,11 +33,11 @@ pub async fn logout_all(
)
.map_err(map_logout_error)?;
state
.sync_auth_store_snapshot_to_spacetime()
.sync_auth_store_tables_to_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
.with_message(format!("同步认证状态失败:{error}"))
})?;
let mut headers = HeaderMap::new();
@@ -43,11 +43,11 @@ pub async fn password_entry(
let session_client = resolve_session_client_context(&headers);
let signed_session = create_password_auth_session(&state, &result.user, &session_client)?;
state
.sync_auth_store_snapshot_to_spacetime()
.sync_auth_store_tables_to_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
.with_message(format!("同步认证状态失败:{error}"))
})?;
if result.created {
crate::registration_reward::grant_new_user_registration_wallet_reward(
@@ -45,11 +45,11 @@ pub async fn change_password(
.await
.map_err(map_password_management_error)?;
state
.sync_auth_store_snapshot_to_spacetime()
.sync_auth_store_tables_to_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
.with_message(format!("同步认证状态失败:{error}"))
})?;
let mut headers = HeaderMap::new();
@@ -101,11 +101,11 @@ pub async fn reset_password(
module_auth::AuthLoginMethod::Password,
)?;
state
.sync_auth_store_snapshot_to_spacetime()
.sync_auth_store_tables_to_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
.with_message(format!("同步认证状态失败:{error}"))
})?;
record_daily_login_tracking_event_after_auth_success(
&state,
@@ -159,11 +159,11 @@ pub async fn phone_login(
AuthLoginMethod::Phone,
)?;
state
.sync_auth_store_snapshot_to_spacetime()
.sync_auth_store_tables_to_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
.with_message(format!("同步认证状态失败:{error}"))
})?;
if created {
crate::registration_reward::grant_new_user_registration_wallet_reward(
@@ -37,7 +37,7 @@ pub async fn update_profile_identity(
.map_err(map_profile_update_error)?;
state
.sync_auth_store_snapshot_to_spacetime()
.sync_auth_store_tables_to_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
@@ -7,7 +7,6 @@ use module_auth::{RefreshSessionError, RotateRefreshSessionInput};
use platform_auth::hash_refresh_session_token;
use shared_contracts::auth::RefreshSessionResponse;
use time::OffsetDateTime;
use tracing::warn;
use crate::{
api_response::json_success_body,
@@ -49,36 +48,10 @@ pub async fn refresh_session(
) {
Ok(rotated) => rotated,
Err(RefreshSessionError::SessionNotFound) => {
match state.refresh_auth_store_from_spacetime().await {
Ok(true) => {}
Ok(false) => {
return Err(map_refresh_error_with_clear_cookie(
&state,
RefreshSessionError::SessionNotFound,
));
}
Err(error) => {
warn!(
request_id = request_context.request_id(),
error = %error,
"refresh session 本地未命中后刷新认证工作集失败"
);
return Err(map_refresh_error_with_clear_cookie(
&state,
RefreshSessionError::SessionNotFound,
));
}
}
state
.refresh_session_service()
.rotate_session(
RotateRefreshSessionInput {
refresh_token_hash,
next_refresh_token_hash,
},
OffsetDateTime::now_utc(),
)
.map_err(|error| map_refresh_error_with_clear_cookie(&state, error))?
return Err(map_refresh_error_with_clear_cookie(
&state,
RefreshSessionError::SessionNotFound,
));
}
Err(error) => return Err(map_refresh_error_with_clear_cookie(&state, error)),
};
@@ -90,11 +63,11 @@ pub async fn refresh_session(
Some(&rotated.session.client_info),
)?;
state
.sync_auth_store_snapshot_to_spacetime()
.sync_auth_store_tables_to_spacetime()
.await
.map_err(|error| {
AppError::from_status(axum::http::StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
.with_message(format!("同步认证状态失败:{error}"))
})?;
record_daily_login_tracking_event_after_auth_success(
&state,
+5 -47
View File
@@ -11,7 +11,6 @@ use std::{
use axum::extract::FromRef;
use module_ai::{AiTaskService, InMemoryAiTaskStore};
#[cfg(not(test))]
use module_auth::RefreshAuthStoreSnapshotResult;
use module_auth::{
AuthUserService, InMemoryAuthStore, PasswordEntryService, PhoneAuthService,
RefreshSessionService, WechatAuthService, WechatAuthStateService,
@@ -779,7 +778,7 @@ impl AppState {
&self.password_entry_service
}
pub async fn sync_auth_store_snapshot_to_spacetime(&self) -> Result<(), SpacetimeClientError> {
pub async fn sync_auth_store_tables_to_spacetime(&self) -> Result<(), SpacetimeClientError> {
#[cfg(test)]
return Ok(());
@@ -792,9 +791,9 @@ impl AppState {
let updated_at_micros = i64::try_from(
OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000,
)
.map_err(|_| SpacetimeClientError::Runtime("认证快照更新时间超出 i64 范围".to_string()))?;
// 当前进程内 auth_store 是认证请求的即时工作集;SpacetimeDB 正式认证表用于跨进程恢复。
// 认证变更必须在返回客户端前写入 SpacetimeDB,避免只在本进程内成功、重启后丢失账号或会话
.map_err(|_| SpacetimeClientError::Runtime("认证状态更新时间超出 i64 范围".to_string()))?;
// 当前仍由 module-auth 的进程内工作集执行业务规则;这里只同步到 SpacetimeDB 正式认证表
// 不再读写 auth_store_snapshot 行镜像
#[cfg(not(test))]
if let Err(error) = self
.spacetime_client
@@ -803,7 +802,7 @@ impl AppState {
{
warn!(
error = %error,
"认证快照导入 SpacetimeDB 正式表失败,当前认证流程中止"
"认证状态导入 SpacetimeDB 正式表失败,当前认证流程中止"
);
return Err(error);
}
@@ -811,47 +810,6 @@ impl AppState {
Ok(())
}
#[cfg(not(test))]
pub fn refresh_auth_store_from_snapshot_json(
&self,
snapshot_json: &str,
) -> Result<RefreshAuthStoreSnapshotResult, SpacetimeClientError> {
self.auth_store
.refresh_from_snapshot_json(snapshot_json)
.map_err(SpacetimeClientError::Runtime)
}
pub async fn refresh_auth_store_from_spacetime(&self) -> Result<bool, SpacetimeClientError> {
#[cfg(test)]
{
return Ok(false);
}
#[cfg(not(test))]
{
let snapshot = self
.spacetime_client
.export_auth_store_snapshot_from_tables()
.await?;
let Some(snapshot_json) = snapshot
.snapshot_json
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return Ok(false);
};
let result = self.refresh_auth_store_from_snapshot_json(snapshot_json)?;
info!(
user_count = result.user_count,
session_count = result.session_count,
updated_at_micros = snapshot.updated_at_micros,
"已按需刷新本进程认证工作集"
);
Ok(true)
}
}
pub async fn try_restore_auth_store_from_spacetime(
config: AppConfig,
) -> Result<Self, AppStateInitError> {
@@ -147,11 +147,11 @@ pub async fn handle_wechat_callback(
AuthLoginMethod::Wechat,
)?;
state
.sync_auth_store_snapshot_to_spacetime()
.sync_auth_store_tables_to_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
.with_message(format!("同步认证状态失败:{error}"))
})?;
record_daily_login_tracking_event_after_auth_success(
&state,
@@ -252,11 +252,11 @@ pub async fn bind_wechat_phone(
AuthLoginMethod::Wechat,
)?;
state
.sync_auth_store_snapshot_to_spacetime()
.sync_auth_store_tables_to_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
.with_message(format!("同步认证状态失败:{error}"))
})?;
if result.activated_new_user {
crate::registration_reward::grant_new_user_registration_wallet_reward(
@@ -328,11 +328,11 @@ pub async fn login_wechat_mini_program(
AuthLoginMethod::Wechat,
)?;
state
.sync_auth_store_snapshot_to_spacetime()
.sync_auth_store_tables_to_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
.with_message(format!("同步认证状态失败:{error}"))
})?;
let mut response_headers = HeaderMap::new();
+169 -1
View File
@@ -120,6 +120,13 @@ fn hydrate_private_auth_fields(
hydrated
}
fn stored_user_phone_number(stored_user: &StoredPasswordUser) -> Option<&str> {
stored_user
.phone_number
.as_deref()
.or(stored_user.user.phone_number.as_deref())
}
#[derive(Clone, Debug)]
pub struct PasswordEntryService {
store: InMemoryAuthStore,
@@ -1100,7 +1107,7 @@ impl InMemoryAuthStore {
let Some(stored_user) = state
.users_by_username
.values()
.find(|stored_user| stored_user.phone_number.as_deref() == Some(phone_number))
.find(|stored_user| stored_user_phone_number(stored_user) == Some(phone_number))
.cloned()
else {
return None;
@@ -2837,6 +2844,70 @@ 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");
let phone_service = build_phone_service(
InMemoryAuthStore::from_snapshot_json(&snapshot_json).expect("snapshot should restore"),
);
let now = OffsetDateTime::now_utc();
phone_service
.send_code(
SendPhoneCodeInput {
phone_number: "13800138009".to_string(),
scene: PhoneAuthScene::Login,
},
now,
)
.await
.expect("phone code should send");
let reused = phone_service
.login(
PhoneLoginInput {
phone_number: "13800138009".to_string(),
verify_code: "123456".to_string(),
},
now + Duration::seconds(1),
)
.await
.expect("phone login should reuse existing user");
assert!(!reused.created);
assert_eq!(reused.user.id, "user_existing_phone");
}
#[tokio::test]
async fn snapshot_json_restores_user_and_refresh_session_after_roundtrip() {
let store = InMemoryAuthStore::default();
@@ -3773,6 +3844,103 @@ mod tests {
);
}
#[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");
let phone_service = build_phone_service(store.clone());
let wechat_service = WechatAuthService::new(store);
let now = OffsetDateTime::now_utc();
let wechat_user = wechat_service
.resolve_login(ResolveWechatLoginInput {
profile: WechatIdentityProfile {
provider_uid: "wx-openid-existing-phone-bind".to_string(),
provider_union_id: Some("wx-union-existing-phone-bind".to_string()),
display_name: Some("待绑定微信用户".to_string()),
avatar_url: None,
session_key: None,
},
})
.await
.expect("wechat login should succeed")
.user;
phone_service
.send_code(
SendPhoneCodeInput {
phone_number: "13800138032".to_string(),
scene: PhoneAuthScene::BindPhone,
},
now,
)
.await
.expect("bind phone code should send");
let merged = phone_service
.bind_wechat_phone(
BindWechatPhoneInput {
user_id: wechat_user.id,
phone_number: "13800138032".to_string(),
verify_code: "123456".to_string(),
wechat_display_name: None,
},
now + Duration::seconds(1),
)
.await
.expect("bind phone should merge existing phone user");
assert_eq!(merged.user.id, "user_existing_phone_bind");
assert!(merged.user.wechat_bound);
assert_eq!(
wechat_service
.resolve_login(ResolveWechatLoginInput {
profile: WechatIdentityProfile {
provider_uid: "wx-openid-existing-phone-bind".to_string(),
provider_union_id: Some("wx-union-existing-phone-bind".to_string()),
display_name: Some("已归并微信用户".to_string()),
avatar_url: None,
session_key: None,
},
})
.await
.expect("wechat login should reuse merged user")
.user
.id,
"user_existing_phone_bind"
);
}
#[tokio::test]
async fn bind_wechat_phone_keeps_account_marker_when_identity_has_no_display_name() {
let store = build_store();
File diff suppressed because it is too large Load Diff