修复鉴权投影并发与退款 outbox 冲突

绑定认证工作集投影版本并在 SpacetimeDB 内执行 CAS

为认证 procedure 增加 runtime service identity 校验

防止退款 outbox 跨进程覆盖并校验临时文件事实

更新后端契约与项目决策记录
This commit is contained in:
2026-08-27 14:47:22 +08:00
parent 72d7214646
commit 36a4d37d24
10 changed files with 441 additions and 52 deletions
@@ -187,6 +187,9 @@ pub struct AuthStoreProjectionView {
pub users: Vec<AuthStoreProjectionUser>,
pub identities: Vec<AuthStoreProjectionIdentity>,
pub refresh_sessions: Vec<AuthStoreProjectionRefreshSession>,
/// 当前进程工作集所基于的正式投影版本,用于事务内 CAS。
#[serde(default)]
pub base_updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+75 -1
View File
@@ -12,7 +12,10 @@ pub use events::*;
use std::{
collections::{HashMap, HashSet},
sync::{Arc, Mutex},
sync::{
Arc, Mutex,
atomic::{AtomicU64, Ordering},
},
};
use platform_auth::{
@@ -31,6 +34,7 @@ use tracing::{info, warn};
#[derive(Clone, Debug)]
pub struct InMemoryAuthStore {
inner: Arc<Mutex<InMemoryAuthStoreState>>,
revision: Arc<AtomicU64>,
}
#[derive(Debug)]
@@ -987,6 +991,7 @@ impl Default for InMemoryAuthStore {
fn default() -> Self {
Self {
inner: Arc::new(Mutex::new(InMemoryAuthStoreState::default())),
revision: Arc::new(AtomicU64::new(0)),
}
}
}
@@ -1164,9 +1169,14 @@ impl InMemoryAuthStore {
inner: Arc::new(Mutex::new(InMemoryAuthStoreState::from_projection_view(
view,
)?)),
revision: Arc::new(AtomicU64::new(0)),
})
}
pub fn revision(&self) -> u64 {
self.revision.load(Ordering::Acquire)
}
pub fn refresh_from_projection_view(
&self,
view: AuthStoreProjectionView,
@@ -1177,10 +1187,30 @@ impl InMemoryAuthStore {
.lock()
.map_err(|_| "认证仓储锁已中毒".to_string())?;
state.apply_persistent_state(next_state);
self.revision.fetch_add(1, Ordering::Release);
Ok(())
}
pub fn refresh_from_projection_view_if_revision(
&self,
view: AuthStoreProjectionView,
expected_revision: u64,
) -> Result<bool, String> {
let next_state = InMemoryAuthStoreState::from_projection_view(view)?;
let mut state = self
.inner
.lock()
.map_err(|_| "认证仓储锁已中毒".to_string())?;
if self.revision.load(Ordering::Acquire) != expected_revision {
return Ok(false);
}
state.apply_persistent_state(next_state);
self.revision.fetch_add(1, Ordering::Release);
Ok(true)
}
pub fn export_projection_view(
&self,
updated_at_micros: i64,
@@ -1255,6 +1285,7 @@ impl InMemoryAuthStore {
.collect::<Result<Vec<_>, String>>()?;
Ok(AuthStoreProjectionView {
base_updated_at_micros: 0,
updated_at_micros,
users,
identities,
@@ -1262,8 +1293,24 @@ impl InMemoryAuthStore {
})
}
pub fn export_projection_view_with_revision(
&self,
updated_at_micros: i64,
) -> Result<(AuthStoreProjectionView, u64), String> {
for _ in 0..3 {
let before = self.revision.load(Ordering::Acquire);
let view = self.export_projection_view(updated_at_micros)?;
let after = self.revision.load(Ordering::Acquire);
if before == after {
return Ok((view, after));
}
}
Err("认证工作集在导出期间持续发生变化".to_string())
}
fn persist_state(&self, state: &InMemoryAuthStoreState) -> Result<(), String> {
let _ = state;
self.revision.fetch_add(1, Ordering::Release);
Ok(())
}
@@ -2783,6 +2830,7 @@ mod tests {
fn empty_projection_store() -> InMemoryAuthStore {
InMemoryAuthStore::from_projection_view(AuthStoreProjectionView {
base_updated_at_micros: 0,
updated_at_micros: 0,
users: vec![],
identities: vec![],
@@ -3208,6 +3256,7 @@ mod tests {
async fn phone_login_reuses_user_restored_from_projection() {
let phone_service = build_phone_service(
InMemoryAuthStore::from_projection_view(AuthStoreProjectionView {
base_updated_at_micros: 0,
updated_at_micros: 1,
users: vec![projection_user(
"user_existing_phone",
@@ -3359,6 +3408,30 @@ mod tests {
));
}
#[test]
fn conditional_projection_refresh_rejects_stale_revision() {
let store = InMemoryAuthStore::default();
let projection = AuthStoreProjectionView {
base_updated_at_micros: 0,
updated_at_micros: 1,
users: vec![],
identities: vec![],
refresh_sessions: vec![],
};
assert_eq!(store.revision(), 0);
store
.refresh_from_projection_view(projection.clone())
.expect("initial projection refresh should succeed");
assert_eq!(store.revision(), 1);
assert!(
!store
.refresh_from_projection_view_if_revision(projection, 0)
.expect("stale projection refresh should be checked without error")
);
assert_eq!(store.revision(), 1);
}
#[tokio::test]
async fn empty_projection_restore_does_not_block_phone_login() {
let phone_service = build_phone_service(empty_projection_store());
@@ -4248,6 +4321,7 @@ mod tests {
#[tokio::test]
async fn bind_wechat_phone_merges_when_existing_phone_restored_from_projection() {
let store = InMemoryAuthStore::from_projection_view(AuthStoreProjectionView {
base_updated_at_micros: 0,
updated_at_micros: 1,
users: vec![projection_user(
"user_existing_phone_bind",