修复鉴权投影并发与退款 outbox 冲突
绑定认证工作集投影版本并在 SpacetimeDB 内执行 CAS 为认证 procedure 增加 runtime service identity 校验 防止退款 outbox 跨进程覆盖并校验临时文件事实 更新后端契约与项目决策记录
This commit is contained in:
@@ -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)]
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user