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