修复鉴权投影并发与退款 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
+146 -24
View File
@@ -8,7 +8,7 @@ use std::{
fmt,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering},
},
};
@@ -35,7 +35,7 @@ use spacetime_client::{
SpacetimeClient, SpacetimeClientConfig, SpacetimeClientError, SpacetimeClientHealthSnapshot,
};
use time::OffsetDateTime;
use tokio::sync::{Semaphore, broadcast};
use tokio::sync::{Mutex as AsyncMutex, Semaphore, broadcast};
use tracing::{info, warn};
use crate::config::AppConfig;
@@ -273,6 +273,14 @@ pub struct AppStateInner {
oss_client: Option<OssClient>,
#[cfg_attr(test, allow(dead_code))]
auth_store: InMemoryAuthStore,
/// 当前进程工作集所基于的正式认证投影版本;跨节点写入使用它做 CAS。
#[cfg_attr(test, allow(dead_code))]
auth_projection_version: AtomicI64,
/// 最近一次确认写入正式投影时对应的工作集 revision;不一致表示有待重试的本地变更。
#[cfg_attr(test, allow(dead_code))]
auth_projection_synced_revision: AtomicU64,
#[cfg_attr(test, allow(dead_code))]
auth_projection_sync_lock: AsyncMutex<()>,
password_entry_service: PasswordEntryService,
refresh_session_service: RefreshSessionService,
auth_user_service: AuthUserService,
@@ -505,12 +513,13 @@ impl AppState {
pub fn new_with_empty_auth_store(config: AppConfig) -> Result<Self, AppStateInitError> {
// 中文注释:api-server 不再把本地 auth-store.json 当作用户认证真相源,启动恢复只允许来自 SpacetimeDB。
Self::new_with_auth_store(config, InMemoryAuthStore::default())
Self::new_with_auth_store(config, InMemoryAuthStore::default(), 0)
}
fn new_with_auth_store(
config: AppConfig,
auth_store: InMemoryAuthStore,
auth_projection_version: i64,
) -> Result<Self, AppStateInitError> {
let auth_jwt_config = JwtConfig::new(
config.jwt_issuer.clone(),
@@ -629,6 +638,9 @@ impl AppState {
test_external_background_removal_enqueue: Arc::new(Mutex::new(None)),
oss_client,
auth_store,
auth_projection_version: AtomicI64::new(auth_projection_version),
auth_projection_synced_revision: AtomicU64::new(0),
auth_projection_sync_lock: AsyncMutex::new(()),
password_entry_service,
refresh_session_service,
auth_user_service,
@@ -1288,30 +1300,102 @@ impl AppState {
return Ok(());
#[cfg(not(test))]
let updated_at_micros = i64::try_from(
OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000,
)
.map_err(|_| SpacetimeClientError::Runtime("认证状态更新时间超出 i64 范围".to_string()))?;
let _sync_guard = self.auth_projection_sync_lock.lock().await;
#[cfg(not(test))]
let projection = self
.auth_store
.export_projection_view(updated_at_micros)
.map_err(SpacetimeClientError::Runtime)?;
// 当前仍由 module-auth 的进程内工作集执行业务规则;这里只用 typed projection 同步正式认证表。
let sync_start_revision = self.auth_store.revision();
#[cfg(not(test))]
if let Err(error) = self
.spacetime_client
.sync_auth_store_projection(projection)
.await
{
warn!(
error = %error,
"认证投影同步 SpacetimeDB 正式表失败,当前认证流程中止"
);
return Err(error);
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 =
i64::try_from(OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000).map_err(
|_| SpacetimeClientError::Runtime("认证状态更新时间超出 i64 范围".to_string()),
)?;
let updated_at_micros = if now_updated_at_micros > base_updated_at_micros {
now_updated_at_micros
} else {
base_updated_at_micros.checked_add(1).ok_or_else(|| {
SpacetimeClientError::Runtime("认证状态版本超出 i64 范围".to_string())
})?
};
let (mut projection, attempted_revision) = self
.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 同步正式认证表。
match self
.spacetime_client
.sync_auth_store_projection(projection)
.await
{
Ok(_) => {
self.auth_projection_version
.store(updated_at_micros, Ordering::Release);
if self.auth_store.revision() == attempted_revision {
self.auth_projection_synced_revision
.store(attempted_revision, Ordering::Release);
return Ok(());
}
local_changed_during_sync = true;
warn!(
attempt,
"认证投影同步期间工作集发生变化,将继续同步最新工作集"
);
continue;
}
Err(error) => {
warn!(
error = %error,
"认证投影同步 SpacetimeDB 正式表失败,当前认证流程中止"
);
if local_changed_during_sync || self.auth_store.revision() != attempted_revision
{
warn!(
"认证投影同步失败期间工作集发生并发变化,跳过自动恢复以避免覆盖未提交变更"
);
} else if let Ok(current_projection) = self
.spacetime_client
.export_auth_store_projection_from_tables()
.await
{
match self.auth_store.refresh_from_projection_view_if_revision(
current_projection.clone(),
attempted_revision,
) {
Ok(true) => {
self.auth_projection_version
.store(current_projection.updated_at_micros, Ordering::Release);
self.auth_projection_synced_revision
.store(self.auth_store.revision(), Ordering::Release);
}
Ok(false) => {
warn!(
"认证投影同步冲突期间工作集发生并发变化,跳过自动恢复以避免覆盖未提交变更"
);
}
Err(refresh_error) => {
warn!(
error = %refresh_error,
"认证投影同步冲突后恢复进程内工作集失败"
);
}
}
}
return Err(error);
}
}
}
#[cfg(not(test))]
Ok(())
Err(SpacetimeClientError::Runtime(
"认证工作集在同步期间持续发生变化,未能完成投影同步".to_string(),
))
}
pub async fn try_restore_auth_store_from_spacetime(
@@ -1319,6 +1403,11 @@ impl AppState {
) -> Result<Self, AppStateInitError> {
let spacetime_client =
SpacetimeClient::new(spacetime_client_config_for_startup_restore(&config));
initialize_editor_generation_runtime_service_identity_for_startup(
&config,
&spacetime_client,
)
.await?;
let mut spacetime_restore_available = false;
let mut restore_errors = Vec::new();
@@ -1332,7 +1421,11 @@ impl AppState {
projection,
AuthStoreRestoreSource::SpacetimeTables,
)? {
let state = Self::new_with_auth_store(config, candidate.auth_store)?;
let state = Self::new_with_auth_store(
config,
candidate.auth_store,
candidate.updated_at_micros.unwrap_or_default(),
)?;
info!(
source = candidate.source.as_str(),
updated_at_micros = candidate.updated_at_micros,
@@ -1845,6 +1938,7 @@ fn auth_store_candidate_from_projection_view(
if projection.users.is_empty()
&& projection.identities.is_empty()
&& projection.refresh_sessions.is_empty()
&& projection.updated_at_micros == 0
{
return Ok(None);
}
@@ -1886,6 +1980,34 @@ fn spacetime_client_config_for_startup_restore(config: &AppConfig) -> SpacetimeC
}
}
async fn initialize_editor_generation_runtime_service_identity_for_startup(
config: &AppConfig,
spacetime_client: &SpacetimeClient,
) -> Result<(), AppStateInitError> {
let pricing_store =
EditorGenerationPricingStore::load(config.editor_generation_pricing_override_path.clone())
.map_err(|error| AppStateInitError::DependencyUnavailable(error.to_string()))?;
let fallback = pricing_store
.snapshot()
.map_err(|error| AppStateInitError::DependencyUnavailable(error.to_string()))?;
let models = editor_generation_pricing_to_records(&fallback)
.map_err(|error| AppStateInitError::DependencyUnavailable(error.to_string()))?;
spacetime_client
.initialize_editor_generation_pricing_config_if_missing(
editor_generation_pricing_upsert_input(
config,
"system:editor-generation-pricing".to_string(),
models,
crate::editor_project::current_utc_micros(),
),
)
.await
.map_err(|error| {
AppStateInitError::DependencyUnavailable(format!("初始化模型定价服务身份失败:{error}"))
})?;
Ok(())
}
impl fmt::Display for AppStateInitError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
@@ -34,7 +34,7 @@ pub struct WalletRefundOutbox {
flush_notify: Arc<Notify>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub(crate) struct WalletRefundOutboxRecord {
pub owner_user_id: String,
pub amount: u64,
@@ -85,7 +85,7 @@ impl WalletRefundOutbox {
let pending_path = self.pending_path_for_ledger(&record.ledger_id);
if self
.reuse_existing_pending_file(&pending_path, &record.ledger_id)
.reuse_existing_pending_file(&pending_path, &record)
.await?
{
self.flush_notify.notify_one();
@@ -112,23 +112,48 @@ impl WalletRefundOutbox {
file.sync_data().await?;
drop(file);
if self
.reuse_existing_pending_file(&pending_path, &record.ledger_id)
.reuse_existing_pending_file(&pending_path, &record)
.await?
{
let _ = fs::remove_file(&temp_path).await;
self.flush_notify.notify_one();
return Ok(WalletRefundOutboxEnqueueOutcome::Enqueued);
}
fs::rename(&temp_path, &pending_path).await?;
sync_directory_metadata(&self.dir).await?;
self.flush_notify.notify_one();
Ok(WalletRefundOutboxEnqueueOutcome::Enqueued)
for _ in 0..2 {
match fs::hard_link(&temp_path, &pending_path).await {
Ok(()) => {
sync_directory_metadata(&self.dir).await?;
remove_file_and_sync(&temp_path, &self.dir).await?;
self.flush_notify.notify_one();
return Ok(WalletRefundOutboxEnqueueOutcome::Enqueued);
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
if self
.reuse_existing_pending_file(&pending_path, &record)
.await?
{
remove_file_and_sync(&temp_path, &self.dir).await?;
self.flush_notify.notify_one();
return Ok(WalletRefundOutboxEnqueueOutcome::Enqueued);
}
}
Err(error) => return Err(error.into()),
}
}
Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
format!(
"refund pending path could not be installed: {}",
pending_path.display()
),
)
.into())
}
async fn reuse_existing_pending_file(
&self,
pending_path: &Path,
ledger_id: &str,
expected: &WalletRefundOutboxRecord,
) -> Result<bool, WalletRefundOutboxError> {
let metadata = match fs::metadata(pending_path).await {
Ok(metadata) => metadata,
@@ -147,7 +172,16 @@ impl WalletRefundOutbox {
}
match read_refund_record(pending_path).await {
Ok(existing) if existing.ledger_id == ledger_id => Ok(true),
Ok(existing) if existing == *expected => Ok(true),
Ok(existing) if existing.ledger_id == expected.ledger_id => Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
format!(
"refund ledger {} 已存在但退款事实不一致: {}",
expected.ledger_id,
pending_path.display()
),
)
.into()),
Ok(_) => Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
format!("refund ledger hash collision at {}", pending_path.display()),
@@ -272,7 +306,7 @@ impl WalletRefundOutbox {
match fs::metadata(&pending_path).await {
Ok(metadata) if metadata.is_file() => {
match read_refund_record(&pending_path).await {
Ok(existing) if existing.ledger_id == record.ledger_id => {
Ok(existing) if existing == record => {
remove_file_and_sync(&path, &self.dir).await?;
debug!(
ledger_id = %record.ledger_id,
@@ -282,6 +316,16 @@ impl WalletRefundOutbox {
);
continue;
}
Ok(existing) if existing.ledger_id == record.ledger_id => {
self.quarantine_file(&path).await?;
warn!(
ledger_id = %record.ledger_id,
source = %path.display(),
target = %pending_path.display(),
"wallet refund outbox 临时文件与已有幂等文件事实不一致,已隔离临时文件"
);
continue;
}
Err(error) if error.is_data_corruption() => {
self.quarantine_file(&pending_path).await?;
}
@@ -324,9 +368,51 @@ impl WalletRefundOutbox {
);
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
// Another writer won the same ledger id. Keep the first durable file and
// remove only this duplicate temporary link.
remove_file_and_sync(&path, &self.dir).await?;
match self
.reuse_existing_pending_file(&pending_path, &record)
.await
{
Ok(true) => {
// Another writer won the same ledger id with identical facts. Keep
// the first durable file and remove only this duplicate temporary
// link.
remove_file_and_sync(&path, &self.dir).await?;
}
Ok(false) => match fs::hard_link(&path, &pending_path).await {
Ok(()) => {
sync_directory_metadata(&self.dir).await?;
remove_file_and_sync(&path, &self.dir).await?;
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
// The file may have been completed by another process after the
// scan.
continue;
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
self.quarantine_file(&path).await?;
warn!(
ledger_id = %record.ledger_id,
source = %path.display(),
target = %pending_path.display(),
"wallet refund outbox 临时文件无法与现有幂等文件合并,已隔离临时文件"
);
}
Err(error) => return Err(error.into()),
},
Err(WalletRefundOutboxError::Io(error))
if error.kind() == std::io::ErrorKind::AlreadyExists =>
{
self.quarantine_file(&path).await?;
warn!(
ledger_id = %record.ledger_id,
source = %path.display(),
target = %pending_path.display(),
error = %error,
"wallet refund outbox 临时文件与现有幂等文件事实冲突,已隔离临时文件"
);
}
Err(error) => return Err(error),
}
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
// The file may have been completed by another process after the scan.
@@ -588,6 +674,25 @@ mod tests {
let _ = std::fs::remove_dir_all(dir);
}
#[tokio::test]
async fn enqueue_rejects_conflicting_existing_ledger_file() {
let dir = test_dir("conflicting-ledger");
let outbox = test_outbox(dir.clone(), 1024 * 1024);
outbox.enqueue(sample_record("ledger-1")).await.unwrap();
let mut conflicting = sample_record("ledger-1");
conflicting.amount += 1;
let error = outbox
.enqueue(conflicting)
.await
.expect_err("conflicting refund must fail");
assert!(
matches!(error, WalletRefundOutboxError::Io(error) if error.kind() == std::io::ErrorKind::AlreadyExists)
);
let _ = std::fs::remove_dir_all(dir);
}
#[tokio::test]
async fn enqueue_drops_when_outbox_exceeds_max_bytes() {
let dir = test_dir("max-bytes");
@@ -702,6 +807,42 @@ mod tests {
let _ = std::fs::remove_dir_all(dir);
}
#[tokio::test]
async fn flush_quarantines_conflicting_crash_left_temp_file() {
let dir = test_dir("recover-conflicting-temp");
std::fs::create_dir_all(&dir).unwrap();
let outbox = test_outbox(dir.clone(), 1024 * 1024);
let record = sample_record("ledger-temp-conflict");
let mut conflicting = record.clone();
conflicting.amount += 1;
let pending_path = outbox.pending_path_for_ledger(&record.ledger_id);
let temp_path = outbox.temp_path();
std::fs::write(&pending_path, serde_json::to_vec(&conflicting).unwrap()).unwrap();
std::fs::write(&temp_path, serde_json::to_vec(&record).unwrap()).unwrap();
let result = outbox.flush_pending_files_once().await;
assert!(matches!(result, Err(WalletRefundOutboxError::Spacetime(_))));
assert!(!temp_path.exists());
assert_eq!(
read_refund_record(&pending_path).await.unwrap(),
conflicting
);
let corrupt_count = std::fs::read_dir(&dir)
.unwrap()
.filter_map(Result::ok)
.filter(|entry| {
entry
.file_name()
.to_str()
.is_some_and(|name| name.starts_with(CORRUPT_FILE_PREFIX))
})
.count();
assert_eq!(corrupt_count, 1);
let _ = std::fs::remove_dir_all(dir);
}
#[tokio::test]
async fn flush_quarantines_corrupt_crash_left_temp_file() {
let dir = test_dir("recover-corrupt-temp");
@@ -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",
@@ -52,6 +52,7 @@ pub(crate) fn map_auth_store_projection_view_input(
view: module_auth::AuthStoreProjectionView,
) -> crate::module_bindings::AuthStoreProjectionView {
crate::module_bindings::AuthStoreProjectionView {
base_updated_at_micros: view.base_updated_at_micros,
updated_at_micros: view.updated_at_micros,
users: view
.users
@@ -111,6 +112,7 @@ fn map_auth_store_projection_view(
view: crate::module_bindings::AuthStoreProjectionView,
) -> module_auth::AuthStoreProjectionView {
module_auth::AuthStoreProjectionView {
base_updated_at_micros: view.base_updated_at_micros,
updated_at_micros: view.updated_at_micros,
users: view
.users
@@ -35,6 +35,7 @@ pub(crate) fn map_auth_store_projection_view_input(
view: module_auth::AuthStoreProjectionView,
) -> crate::module_bindings::AuthStoreProjectionView {
crate::module_bindings::AuthStoreProjectionView {
base_updated_at_micros: view.base_updated_at_micros,
updated_at_micros: view.updated_at_micros,
users: view
.users
@@ -94,6 +95,7 @@ fn map_auth_store_projection_view(
view: crate::module_bindings::AuthStoreProjectionView,
) -> module_auth::AuthStoreProjectionView {
module_auth::AuthStoreProjectionView {
base_updated_at_micros: view.base_updated_at_micros,
updated_at_micros: view.updated_at_micros,
users: view
.users
@@ -15,6 +15,7 @@ pub struct AuthStoreProjectionView {
pub users: Vec<AuthStoreProjectionUser>,
pub identities: Vec<AuthStoreProjectionIdentity>,
pub refresh_sessions: Vec<AuthStoreProjectionRefreshSession>,
pub base_updated_at_micros: i64,
}
impl __sdk::InModule for AuthStoreProjectionView {
@@ -15,6 +15,7 @@ pub struct AuthStoreProjectionView {
pub users: Vec<AuthStoreProjectionUser>,
pub identities: Vec<AuthStoreProjectionIdentity>,
pub refresh_sessions: Vec<AuthStoreProjectionRefreshSession>,
pub base_updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
@@ -97,7 +98,11 @@ pub fn validate_auth_session(
ctx: &mut ProcedureContext,
input: AuthSessionValidationInput,
) -> AuthSessionValidationProcedureResult {
match ctx.try_with_tx(|tx| validate_auth_session_tx(tx, input.clone())) {
let caller = ctx.sender();
match ctx.try_with_tx(|tx| {
require_auth_service_identity(tx, caller)?;
validate_auth_session_tx(tx, input.clone())
}) {
Ok(active) => AuthSessionValidationProcedureResult {
active,
error_message: None,
@@ -146,7 +151,11 @@ pub fn sync_auth_store_projection(
ctx: &mut ProcedureContext,
input: AuthStoreProjectionView,
) -> AuthStoreProjectionSyncProcedureResult {
match ctx.try_with_tx(|tx| sync_auth_store_projection_tx(tx, input.clone())) {
let caller = ctx.sender();
match ctx.try_with_tx(|tx| {
require_auth_service_identity(tx, caller)?;
sync_auth_store_projection_tx(tx, input.clone())
}) {
Ok(record) => AuthStoreProjectionSyncProcedureResult {
ok: true,
record: Some(record),
@@ -164,7 +173,11 @@ pub fn sync_auth_store_projection(
pub fn export_auth_store_projection_from_tables(
ctx: &mut ProcedureContext,
) -> AuthStoreProjectionProcedureResult {
match ctx.try_with_tx(|tx| export_auth_store_projection_from_tables_tx(tx)) {
let caller = ctx.sender();
match ctx.try_with_tx(|tx| {
require_auth_service_identity(tx, caller)?;
export_auth_store_projection_from_tables_tx(tx)
}) {
Ok(record) => AuthStoreProjectionProcedureResult {
ok: true,
record: Some(record),
@@ -178,6 +191,14 @@ pub fn export_auth_store_projection_from_tables(
}
}
fn require_auth_service_identity(
ctx: &ReducerContext,
caller: crate::Identity,
) -> Result<(), String> {
crate::editor_project_storage::require_editor_generation_runtime_service_identity(ctx, caller)
.map_err(|_| "当前 identity 无权调用认证服务".to_string())
}
fn sync_auth_store_projection_tx(
ctx: &ReducerContext,
input: AuthStoreProjectionView,
@@ -188,6 +209,10 @@ fn sync_auth_store_projection_tx(
.meta_id()
.find(&AUTH_STORE_PROJECTION_META_ID.to_string())
.map(|row| row.updated_at.to_micros_since_unix_epoch());
ensure_auth_projection_base_version(
input.base_updated_at_micros,
current_updated_at_micros.unwrap_or_default(),
)?;
ensure_newer_auth_projection_version(current_updated_at_micros, input.updated_at_micros)?;
let user_ids = input
@@ -327,20 +352,31 @@ fn sync_auth_store_projection_tx(
})
}
/// Full projections are emitted by API-local auth worksets. The metadata row
/// is the monotonic watermark that keeps a delayed snapshot from deleting or
/// replacing data written by a newer snapshot on another API instance. The
/// check and the subsequent writes run in the same procedure transaction, so
/// concurrent calls are serialized by SpacetimeDB. Replaying the same
/// version remains idempotent.
/// Full projections are emitted by API-local auth worksets. The base version
/// is read immediately before the write and checked in this same transaction,
/// so a stale API node cannot replace data written by another node meanwhile.
/// The timestamp remains a diagnostic/monotonic watermark for accepted writes.
fn ensure_auth_projection_base_version(
expected_updated_at_micros: i64,
current_updated_at_micros: i64,
) -> Result<(), String> {
if expected_updated_at_micros != current_updated_at_micros {
return Err(format!(
"认证投影基线版本冲突:请求基线 {expected_updated_at_micros} 不等于当前版本 {current_updated_at_micros}"
));
}
Ok(())
}
fn ensure_newer_auth_projection_version(
current_updated_at_micros: Option<i64>,
incoming_updated_at_micros: i64,
) -> Result<(), String> {
if let Some(current_updated_at_micros) = current_updated_at_micros {
if incoming_updated_at_micros < current_updated_at_micros {
if incoming_updated_at_micros <= current_updated_at_micros {
return Err(format!(
"认证投影版本冲突:请求版本 {incoming_updated_at_micros} 于当前版本 {current_updated_at_micros}"
"认证投影版本冲突:请求版本 {incoming_updated_at_micros} 不晚于当前版本 {current_updated_at_micros}"
));
}
}
@@ -410,6 +446,7 @@ fn export_auth_store_projection_from_tables_tx(
.collect();
Ok(AuthStoreProjectionView {
base_updated_at_micros: updated_at_micros,
updated_at_micros,
users,
identities,
@@ -563,7 +600,14 @@ mod tests {
fn auth_projection_version_must_advance_monotonically() {
assert!(ensure_newer_auth_projection_version(None, 1).is_ok());
assert!(ensure_newer_auth_projection_version(Some(10), 11).is_ok());
assert!(ensure_newer_auth_projection_version(Some(10), 10).is_ok());
assert!(ensure_newer_auth_projection_version(Some(10), 10).is_err());
assert!(ensure_newer_auth_projection_version(Some(10), 9).is_err());
}
#[test]
fn auth_projection_base_version_must_match_current_version() {
assert!(ensure_auth_projection_base_version(0, 0).is_ok());
assert!(ensure_auth_projection_base_version(10, 10).is_ok());
assert!(ensure_auth_projection_base_version(9, 10).is_err());
}
}