修复鉴权跨节点投影恢复
Project CI / Frontend tests (pull_request) Successful in 4m51s
Project CI / Repository checks (pull_request) Successful in 5m55s
Project CI / Backend tests (pull_request) Successful in 6m28s
Project CI / Native shell tests (pull_request) Successful in 17m1s

刷新登录、会话、密码与微信认证主链路的正式投影

补齐支付查单、过期补偿和订阅消息的跨节点身份读取

修复同步失败后的 pending 状态恢复并更新鉴权运维文档
This commit is contained in:
2026-08-27 20:48:49 +08:00
parent 874aa09b9a
commit 2b8d44b369
19 changed files with 190 additions and 18 deletions
@@ -26,7 +26,7 @@
## 2026-08-27 短期认证状态进入共享 typed projection
- 背景:短信验证码和微信 OAuth state 仍只存在 API 进程内 HashMap,多节点请求或 API 重启会直接丢失,无法满足无粘性会话的鉴权恢复要求。
- 决策:`AuthStoreProjectionView` 增加 `phone_codes``wechat_states` typed 字段,由 `auth_store_projection_meta` 以 JSON 投影持久化;启动恢复、CAS 同步和失败后的权威刷新都覆盖这两类短期状态。验证码哈希使用部署级稳定盐(当前复用 `GENARRATIVE_JWT_SECRET`),各 API 节点必须一致;发码前先刷新权威投影并用占位验证码记录做一次 projection CAS,只有占用成功才调用短信 provider,避免跨节点冷却竞态;认证 handler 在发码、消费验证码、创建/消费微信 state 后都要完成 projection sync,失败即返回服务错误;短期状态首次未命中时允许从正式投影做一次受 CAS 保护的只读刷新,再重试读取,不能依赖粘性会话。微信 OAuth state 设置有界活动数量,避免单个 JSON 投影无界膨胀。短期状态仍由 `module-auth` 内存工作集执行领域校验,但不再把本机 HashMap 当作持久化或跨节点真相。
- 决策:`AuthStoreProjectionView` 增加 `phone_codes``wechat_states` typed 字段,由 `auth_store_projection_meta` 以 JSON 投影持久化;启动恢复、CAS 同步和失败后的权威刷新都覆盖这两类短期状态。验证码哈希使用部署级稳定盐(当前复用 `GENARRATIVE_JWT_SECRET`),各 API 节点必须一致;发码前先刷新权威投影并用占位验证码记录做一次 projection CAS,只有占用成功才调用短信 provider,避免跨节点冷却竞态;认证 handler 在发码、消费验证码、创建/消费微信 state 后都要完成 projection sync,失败即返回服务错误;所有会读取或变更本机认证工作集的认证主链路(登录、刷新、`/me`、会话管理、密码、绑定和微信 state)在领域操作前先从正式投影做一次受 CAS 保护的只读刷新,刷新失败时 fail closed,不能依赖粘性会话;同步遇到 CAS 冲突时,若本次尝试期间没有新的本地变更则恢复正式快照,若仍有待同步 revision 则由后续认证请求重试,避免节点永久卡在 pending。微信 OAuth state 设置有界活动数量,避免单个 JSON 投影无界膨胀。短期状态仍由 `module-auth` 内存工作集执行领域校验,但不再把本机 HashMap 当作持久化或跨节点真相。
- 影响范围:`module-auth` projection、`spacetime-module` auth schema/procedure、`spacetime-client` bindings/facade、api-server 手机号 / 微信 handler、认证架构与运维文档。
- 验证方式:运行 module-auth projection roundtrip(验证码可跨恢复校验、微信 state 可跨恢复消费)、SpacetimeDB schema/runtime/DDD 门禁、api-server 定向测试、编码和 diff 检查。
File diff suppressed because one or more lines are too long
@@ -777,7 +777,7 @@ node scripts/test-ve-llm.mjs
### 手机验证码短信
手机验证码发送走阿里云普通短信 `SendSms`,验证码由 `module-auth` 在当前 `api-server` 进程内生成哈希存储和校验不再调用阿里云托管验证码的 `SendSmsVerifyCode` / `CheckSmsVerifyCode`。因此 `api-server` 重启后,已发送但未校验的验证码失效。
手机验证码发送走阿里云普通短信 `SendSms`,验证码由 `module-auth` 在当前 `api-server` 进程内生成哈希,短期验证码投影随 `auth_store_projection_meta` 同步到 SpacetimeDB 后由任一 API 节点恢复和校验不再调用阿里云托管验证码的 `SendSmsVerifyCode` / `CheckSmsVerifyCode`。因此只要 SpacetimeDB 正常,`api-server` 重启不会使已发送但未过期的验证码失效。
生产默认短信配置:
@@ -866,7 +866,7 @@ systemctl restart genarrative-api.service
journalctl -u genarrative-api.service --since '30 seconds ago' --no-pager | grep -E 'tracking outbox|Permission denied|os error 13'
```
`Genarrative-Server-Provision``Genarrative-Api-Deploy` 会在保留旧 `/etc/genarrative/api-server.env` 的前提下补齐缺失的 tracking outbox 运行态路径,并确保 `/var/lib/genarrative/tracking-outbox` 归属 `genarrative:genarrative`。用户认证真相源只允许从 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)和 `auth_store_projection_meta` 中的短期验证码 / 微信 state 投影恢复;请求首次未命中短期状态时会从正式投影做一次只读刷新,不依赖粘性会话。不要再配置或依赖 `GENARRATIVE_AUTH_STORE_PATH` / `auth-store.json``module-auth` 也不再维护本地文件持久化;`auth_store_snapshot` 不再作为备查或运行期恢复源,只在正式认证表为空时一次性转移最新旧快照并清空,且旧 `get_auth_store_snapshot` / `upsert_auth_store_snapshot` / `import_auth_store_snapshot` 入口已经删除。所有 API 节点必须使用相同的 `GENARRATIVE_JWT_SECRET`,它也作为验证码哈希盐;轮换后尚未消费的验证码会失效。如果 `api-server` 启动时连不上 SpacetimeDB,会持续重试启动恢复,直到认证工作集从 SpacetimeDB 正式表和短期投影恢复成功后才开始监听 HTTP,以避免用空本地状态或旧快照覆盖认证表。
`Genarrative-Server-Provision``Genarrative-Api-Deploy` 会在保留旧 `/etc/genarrative/api-server.env` 的前提下补齐缺失的 tracking outbox 运行态路径,并确保 `/var/lib/genarrative/tracking-outbox` 归属 `genarrative:genarrative`。用户认证真相源只允许从 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)和 `auth_store_projection_meta` 中的短期验证码 / 微信 state 投影恢复;所有会读取或变更本机认证工作集的认证主链路在领域操作前都会从正式投影做一次只读刷新,刷新失败即 fail closed,不依赖粘性会话。不要再配置或依赖 `GENARRATIVE_AUTH_STORE_PATH` / `auth-store.json``module-auth` 也不再维护本地文件持久化;`auth_store_snapshot` 不再作为备查或运行期恢复源,只在正式认证表为空时一次性转移最新旧快照并清空,且旧 `get_auth_store_snapshot` / `upsert_auth_store_snapshot` / `import_auth_store_snapshot` 入口已经删除。所有 API 节点必须使用相同的 `GENARRATIVE_JWT_SECRET`,它也作为验证码哈希盐;轮换后尚未消费的验证码会失效。如果 `api-server` 启动时连不上 SpacetimeDB,会持续重试启动恢复,直到认证工作集从 SpacetimeDB 正式表和短期投影恢复成功后才开始监听 HTTP,以避免用空本地状态或旧快照覆盖认证表。
发短信运维门禁:handler 会先刷新正式认证投影,再通过 projection CAS 写入不可消费的占位验证码来占用跨节点冷却窗口;占用失败时不得调用短信 provider。微信 OAuth state 活动数量有上限,命中上限应返回服务错误并触发限流 / 入口告警;不要通过调大单个 `auth_store_projection_meta` JSON 字段来绕过该保护。
@@ -17,6 +17,15 @@ pub async fn auth_me(
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<serde_json::Value>, AppError> {
let user_id = authenticated.claims().user_id().to_string();
// Bearer 已由 SpacetimeDB 校验;/me 返回的资料也必须先从同一正式投影恢复,
// 否则节点间会因为本机工作集滞后把有效用户返回为 401 或旧资料。
state
.refresh_auth_store_from_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("刷新认证状态失败:{error}"))
})?;
let user = state
.password_entry_service()
.get_user_by_id(&user_id)
@@ -15,6 +15,13 @@ pub async fn get_public_user_by_code(
Extension(request_context): Extension<RequestContext>,
Path(code): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
state
.refresh_auth_store_from_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("刷新认证状态失败:{error}"))
})?;
let user = state
.password_entry_service()
.get_user_by_public_user_code(&code)
@@ -41,6 +48,14 @@ pub async fn get_public_user_by_id(
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("用户 ID 不能为空"));
}
state
.refresh_auth_store_from_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("刷新认证状态失败:{error}"))
})?;
let user = state
.auth_user_service()
.get_user_by_id(user_id)
@@ -29,6 +29,13 @@ pub async fn auth_sessions(
) -> Result<Json<serde_json::Value>, AppError> {
// 当前设备识别仍然依赖 refresh cookie 命中的原始 token,对旧前端行为保持兼容。
let user_id = authenticated.claims().user_id().to_string();
state
.refresh_auth_store_from_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("刷新认证状态失败:{error}"))
})?;
let current_refresh_token_hash = maybe_refresh_token.and_then(|token| {
let token = token.0.token().trim();
if token.is_empty() {
@@ -78,6 +85,14 @@ pub async fn revoke_auth_session(
);
}
state
.refresh_auth_store_from_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("刷新认证状态失败:{error}"))
})?;
let revoke_result = state
.refresh_session_service()
.revoke_session_by_user_and_session(
@@ -25,6 +25,13 @@ pub async fn logout(
Extension(authenticated): Extension<AuthenticatedAccessToken>,
maybe_refresh_token: Option<Extension<RefreshSessionToken>>,
) -> Result<impl IntoResponse, AppError> {
state
.refresh_auth_store_from_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("刷新认证状态失败:{error}"))
})?;
let refresh_token_hash = maybe_refresh_token.and_then(|token| {
let token = token.0.token().trim().to_string();
if token.is_empty() {
@@ -23,6 +23,13 @@ pub async fn logout_all(
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<impl IntoResponse, AppError> {
state
.refresh_auth_store_from_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("刷新认证状态失败:{error}"))
})?;
state
.auth_user_service()
.logout_all_sessions(
@@ -27,6 +27,13 @@ pub async fn password_entry(
headers: HeaderMap,
Json(payload): Json<PasswordEntryRequest>,
) -> Result<impl IntoResponse, AppError> {
state
.refresh_auth_store_from_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("刷新认证状态失败:{error}"))
})?;
let input = PasswordEntryInput {
country_code: payload.country_code,
pure_phone_number: payload.pure_phone_number,
@@ -33,6 +33,13 @@ pub async fn change_password(
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<PasswordChangeRequest>,
) -> Result<impl IntoResponse, AppError> {
state
.refresh_auth_store_from_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("刷新认证状态失败:{error}"))
})?;
let result = state
.password_entry_service()
.change_password_and_revoke_all_sessions(
@@ -82,6 +89,16 @@ pub async fn reset_password(
);
}
// reset_password 消费的是跨节点共享的短期验证码;先恢复正式投影,
// 避免发码节点与消费节点的本机工作集不一致。
state
.refresh_auth_store_from_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("刷新短信验证码状态失败:{error}"))
})?;
let result = match state
.phone_auth_service()
.reset_password(
@@ -139,6 +139,13 @@ pub async fn phone_login(
AppError::from_status(StatusCode::BAD_REQUEST).with_message("手机号登录暂未启用")
);
}
state
.refresh_auth_store_from_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("刷新短信验证码状态失败:{error}"))
})?;
let invite_code = payload.invite_code.clone();
let login_input = PhoneLoginInput {
country_code: payload.country_code,
@@ -23,6 +23,13 @@ pub async fn update_profile_identity(
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<ProfileUpdateRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
state
.refresh_auth_store_from_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("刷新认证状态失败:{error}"))
})?;
if let Some(avatar_data_url) = payload.avatar_data_url.as_deref() {
validate_avatar_data_url(avatar_data_url)?;
}
@@ -265,6 +265,14 @@ async fn process_expired_virtual_payment_order(
state: &AppState,
order: &RuntimeProfileRechargeOrderRecord,
) -> Result<(), ExpirationCompensationError> {
state
.refresh_auth_store_from_spacetime()
.await
.map_err(|error| {
ExpirationCompensationError::Runtime(format!(
"failed to refresh auth projection for virtual payment query: {error}"
))
})?;
let identity = state
.wechat_auth_service()
.get_identity_by_user_id(&order.user_id)
@@ -39,6 +39,16 @@ pub async fn refresh_session(
let next_refresh_token = platform_auth::create_refresh_session_token();
let next_refresh_token_hash = hash_refresh_session_token(&next_refresh_token);
// refresh_session 是跨节点的正式认证入口;先加载最新投影,再在本机工作集执行领域轮换,
// 避免请求落到旧节点时把合法 refresh cookie 误判为不存在。
state
.refresh_auth_store_from_spacetime()
.await
.map_err(|error| {
AppError::from_status(axum::http::StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("刷新认证状态失败:{error}"))
})?;
let rotated = match state.refresh_session_service().rotate_session(
RotateRefreshSessionInput {
refresh_token_hash: refresh_token_hash.clone(),
@@ -1650,6 +1650,13 @@ async fn resolve_wechat_identity_for_payment(
state: &AppState,
user_id: &str,
) -> Result<String, AppError> {
state
.refresh_auth_store_from_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("刷新微信认证状态失败:{error}"))
})?;
if let Some(identity) = state
.wechat_auth_service()
.get_identity_by_user_id(user_id)
+17 -14
View File
@@ -618,6 +618,10 @@ impl AppState {
let editor_oss_http_client = build_editor_oss_http_client()?;
let http_request_permit_pools = HttpRequestPermitPools::from_config(&config);
let (profile_recharge_order_updates, _) = broadcast::channel(128);
// `ensure_orphan_work_owner_user` 只为公开作品作者回退提供进程内占位账号,
// 不属于正式认证投影;将当前工作集 revision 作为已同步起点,首次认证请求会
// 先按正式投影刷新并自然丢弃该占位账号,避免把它误当成待提交认证变更。
let initial_auth_store_revision = auth_store.revision();
Ok(Self(Arc::new(AppStateInner {
config,
@@ -646,7 +650,7 @@ impl AppState {
oss_client,
auth_store,
auth_projection_version: AtomicI64::new(auth_projection_version),
auth_projection_synced_revision: AtomicU64::new(0),
auth_projection_synced_revision: AtomicU64::new(initial_auth_store_revision),
auth_projection_sync_lock: AsyncMutex::new(()),
password_entry_service,
refresh_session_service,
@@ -1310,11 +1314,6 @@ impl AppState {
#[cfg(not(test))]
let _sync_guard = self.auth_projection_sync_lock.lock().await;
#[cfg(not(test))]
let sync_start_revision = self.auth_store.revision();
#[cfg(not(test))]
let mut local_changed_during_sync =
self.auth_projection_synced_revision.load(Ordering::Acquire) != sync_start_revision;
#[cfg(not(test))]
for attempt in 0..3 {
let base_updated_at_micros = self.auth_projection_version.load(Ordering::Acquire);
let now_updated_at_micros =
@@ -1332,9 +1331,6 @@ impl AppState {
.auth_store
.export_projection_view_with_revision(updated_at_micros)
.map_err(SpacetimeClientError::Runtime)?;
if attempted_revision != sync_start_revision {
local_changed_during_sync = true;
}
projection.base_updated_at_micros = base_updated_at_micros;
// 当前仍由 module-auth 的进程内工作集执行业务规则;这里只用 typed projection 同步正式认证表。
@@ -1351,7 +1347,6 @@ impl AppState {
.store(attempted_revision, Ordering::Release);
return Ok(());
}
local_changed_during_sync = true;
warn!(
attempt,
"认证投影同步期间工作集发生变化,将继续同步最新工作集"
@@ -1363,8 +1358,9 @@ impl AppState {
error = %error,
"认证投影同步 SpacetimeDB 正式表失败,当前认证流程中止"
);
if local_changed_during_sync || self.auth_store.revision() != attempted_revision
{
// 当前请求已经失败;只要同步尝试期间没有新的本地变更,恢复为
// 数据库快照,避免一次 CAS 冲突把本节点永久留在“待同步”状态。
if self.auth_store.revision() != attempted_revision {
warn!(
"认证投影同步失败期间工作集发生并发变化,跳过自动恢复以避免覆盖未提交变更"
);
@@ -1406,14 +1402,21 @@ impl AppState {
))
}
/// 在只读短期认证状态未命中时,从正式投影刷新一次本地工作集,避免 OAuth / 短信
/// 请求落到另一节点后必须依赖粘性会话才能成功。
/// 在认证主链路执行前,从正式投影刷新一次本地工作集,避免请求落到另一节点后
/// 因本机工作集滞后而必须依赖粘性会话才能成功。
pub async fn refresh_auth_store_from_spacetime(&self) -> Result<(), SpacetimeClientError> {
#[cfg(test)]
return Ok(());
#[cfg(not(test))]
{
// 上一次业务操作可能已经改了工作集,但在返回响应前遇到数据库暂时不可用。
// 先重试提交这份待同步变更,避免只读请求把节点永久卡在 pending 状态。
if self.auth_projection_synced_revision.load(Ordering::Acquire)
!= self.auth_store.revision()
{
self.sync_auth_store_tables_to_spacetime().await?;
}
let _sync_guard = self.auth_projection_sync_lock.lock().await;
let expected_revision = self.auth_store.revision();
if self.auth_projection_synced_revision.load(Ordering::Acquire) != expected_revision {
@@ -43,6 +43,13 @@ pub async fn start_wechat_login(
if !state.config.wechat_auth_enabled {
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用"));
}
state
.refresh_auth_store_from_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("刷新微信认证状态失败:{error}"))
})?;
let user_agent = headers
.get("user-agent")
.and_then(|value| value.to_str().ok())
@@ -95,6 +102,13 @@ pub async fn start_wechat_bind(
if !state.config.wechat_auth_enabled {
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用"));
}
state
.refresh_auth_store_from_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("刷新微信认证状态失败:{error}"))
})?;
let user_agent = headers
.get("user-agent")
.and_then(|value| value.to_str().ok())
@@ -164,6 +178,20 @@ pub async fn handle_wechat_callback(
.into_response());
}
state
.refresh_auth_store_from_spacetime()
.await
.map_err(|error| {
warn!(
request_id = request_context.request_id(),
operation = request_context.operation(),
error = %error,
"微信回调前刷新认证投影失败"
);
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message("刷新微信认证状态失败")
})?;
let consume_result = state
.wechat_auth_state_service()
.consume_state(&state_token, OffsetDateTime::now_utc());
@@ -320,6 +348,13 @@ pub async fn bind_wechat_phone(
if !state.config.wechat_auth_enabled {
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用"));
}
state
.refresh_auth_store_from_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("刷新微信认证状态失败:{error}"))
})?;
let result = if let Some(wechat_phone_code) = payload
.wechat_phone_code
.as_deref()
@@ -440,6 +475,13 @@ pub async fn login_wechat_mini_program(
if !state.config.wechat_auth_enabled {
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用"));
}
state
.refresh_auth_store_from_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("刷新微信认证状态失败:{error}"))
})?;
let code = payload.code.trim();
if code.is_empty() {
return Err(
@@ -239,6 +239,10 @@ async fn confirm_virtual_payment_recharge_order(
));
}
state
.refresh_auth_store_from_spacetime()
.await
.map_err(|error| WechatPayError::Upstream(format!("刷新认证状态失败:{error}")))?;
let identity = state
.wechat_auth_service()
.get_identity_by_user_id(&order.user_id)
@@ -56,6 +56,13 @@ async fn send_generation_result_subscribe_message(
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
.with_message("微信订阅消息模板 ID 未配置")
})?;
state
.refresh_auth_store_from_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("刷新微信认证状态失败:{error}"))
})?;
let user = state
.auth_user_service()
.get_user_by_id(&message.owner_user_id)