feat(gateway): 重构 usage 数据层、迁移系统与系统导入

数据库迁移:
- 引入 baseline v2 bootstrap,空库首次启动自动初始化
- 服务启动不再自动执行迁移,需显式 `--migrate` 运行
- 新增 pending migration 检测,schema 落后时拒绝启动

Usage 数据层:
- usage body 存储外部化为独立 blob 表
- 新增 HTTP audit 表拆分存储请求/响应头与 body ref
- 后台清理任务支持 legacy body ref 元数据迁移
- usage runtime 写入迁移到专用 tokio runtime(独立线程池, 8MB 栈)

系统导入/导出:
- 支持用户、API Keys、钱包数据的完整导入
- 兼容 legacy 与 v1.3+ 两种导出格式

其他改进:
- executor outcome 增加 runtime miss 诊断上下文
- 主 tokio runtime 栈大小调整为 8MB
- 前端 provider 管理支持 base URL 配置
- dev.sh 支持 --migrate 参数
This commit is contained in:
fawney19
2026-04-13 14:01:22 +08:00
parent 3698e5a833
commit 5bb08e6aa4
106 changed files with 21736 additions and 1529 deletions

View File

@@ -252,6 +252,28 @@ impl AppState {
Ok(true)
}
pub async fn pending_postgres_migrations(
&self,
) -> Result<Option<Vec<aether_data::migrate::PendingMigrationInfo>>, sqlx::migrate::MigrateError>
{
let Some(pool) = self.postgres_pool() else {
return Ok(None);
};
Ok(Some(aether_data::migrate::pending_migrations(&pool).await?))
}
pub async fn prepare_postgres_for_startup(
&self,
) -> Result<Option<Vec<aether_data::migrate::PendingMigrationInfo>>, sqlx::migrate::MigrateError>
{
let Some(pool) = self.postgres_pool() else {
return Ok(None);
};
Ok(Some(
aether_data::migrate::prepare_database_for_startup(&pool).await?,
))
}
pub fn with_video_task_poller_config(mut self, interval: Duration, batch_size: usize) -> Self {
self.video_task_poller = Some(VideoTaskPollerConfig {
interval,
@@ -559,10 +581,17 @@ impl AppState {
trace_id: &str,
diagnostic: LocalExecutionRuntimeMissDiagnostic,
) {
self.local_execution_runtime_miss_diagnostics
let mut diagnostics = self
.local_execution_runtime_miss_diagnostics
.lock()
.expect("local execution runtime miss diagnostics should lock")
.insert(trace_id.to_string(), diagnostic);
.expect("local execution runtime miss diagnostics should lock");
if diagnostics
.get(trace_id)
.is_some_and(|existing| should_preserve_runtime_miss_diagnostic(existing, &diagnostic))
{
return;
}
diagnostics.insert(trace_id.to_string(), diagnostic);
}
pub(crate) fn mutate_local_execution_runtime_miss_diagnostic<F>(
@@ -581,6 +610,17 @@ impl AppState {
}
}
pub(crate) fn local_execution_runtime_miss_diagnostic_has_candidate_signal(
&self,
trace_id: &str,
) -> bool {
self.local_execution_runtime_miss_diagnostics
.lock()
.expect("local execution runtime miss diagnostics should lock")
.get(trace_id)
.is_some_and(runtime_miss_diagnostic_has_candidate_signal)
}
pub(crate) fn take_local_execution_runtime_miss_diagnostic(
&self,
trace_id: &str,
@@ -735,3 +775,19 @@ impl AppState {
tasks
}
}
fn should_preserve_runtime_miss_diagnostic(
existing: &LocalExecutionRuntimeMissDiagnostic,
next: &LocalExecutionRuntimeMissDiagnostic,
) -> bool {
runtime_miss_diagnostic_has_candidate_signal(existing)
&& !runtime_miss_diagnostic_has_candidate_signal(next)
}
fn runtime_miss_diagnostic_has_candidate_signal(
diagnostic: &LocalExecutionRuntimeMissDiagnostic,
) -> bool {
diagnostic.candidate_count.unwrap_or(0) > 0
|| diagnostic.skipped_candidate_count.unwrap_or(0) > 0
|| !diagnostic.skip_reasons.is_empty()
}

View File

@@ -1,8 +1,108 @@
use crate::AppState;
use std::collections::{BTreeMap, BTreeSet};
use aether_data::repository::auth::{AuthApiKeyLookupKey, ResolvedAuthApiKeySnapshotReader};
use crate::{AppState, GatewayError};
use super::super::super::{AUTH_API_KEY_LAST_USED_MAX_ENTRIES, AUTH_API_KEY_LAST_USED_TTL};
impl AppState {
pub(crate) async fn resolve_auth_api_key_snapshots_by_ids(
&self,
api_key_ids: &[String],
) -> Result<Vec<aether_data::repository::auth::StoredAuthApiKeySnapshot>, GatewayError> {
if !self.has_auth_api_key_data_reader() {
return Ok(Vec::new());
}
let api_key_ids = api_key_ids
.iter()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.collect::<BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>();
if api_key_ids.is_empty() {
return Ok(Vec::new());
}
let mut snapshots = self
.data
.list_auth_api_key_snapshots_by_ids(&api_key_ids)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?
.into_iter()
.map(|snapshot| (snapshot.api_key_id.clone(), snapshot))
.collect::<BTreeMap<_, _>>();
for api_key_id in &api_key_ids {
if snapshots.contains_key(api_key_id) {
continue;
}
let snapshot = self
.data
.find_stored_auth_api_key_snapshot(AuthApiKeyLookupKey::ApiKeyId(api_key_id))
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
if let Some(snapshot) = snapshot {
snapshots.insert(api_key_id.clone(), snapshot);
}
}
Ok(snapshots.into_values().collect())
}
pub(crate) async fn resolve_auth_api_key_names_by_ids(
&self,
api_key_ids: &[String],
) -> Result<BTreeMap<String, String>, GatewayError> {
if !self.has_auth_api_key_data_reader() {
return Ok(BTreeMap::new());
}
let api_key_ids = api_key_ids
.iter()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.collect::<BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>();
if api_key_ids.is_empty() {
return Ok(BTreeMap::new());
}
let mut names = self
.data
.list_auth_api_key_snapshots_by_ids(&api_key_ids)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?
.into_iter()
.filter_map(|snapshot| {
snapshot
.api_key_name
.map(|name| (snapshot.api_key_id, name))
})
.collect::<BTreeMap<_, _>>();
for api_key_id in &api_key_ids {
if names.contains_key(api_key_id) {
continue;
}
let snapshot = self
.data
.find_stored_auth_api_key_snapshot(AuthApiKeyLookupKey::ApiKeyId(api_key_id))
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
if let Some(name) = snapshot.and_then(|snapshot| snapshot.api_key_name) {
names.insert(api_key_id.clone(), name);
}
}
Ok(names)
}
pub(crate) async fn touch_auth_api_key_last_used_best_effort(&self, api_key_id: &str) {
let api_key_id = api_key_id.trim();
if api_key_id.is_empty() || !self.data.has_auth_api_key_writer() {
@@ -24,3 +124,192 @@ impl AppState {
}
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::sync::Arc;
use aether_data::repository::auth::{
AuthApiKeyExportSummary, AuthApiKeyLookupKey, AuthApiKeyReadRepository,
InMemoryAuthApiKeySnapshotRepository, StandaloneApiKeyExportListQuery,
StoredAuthApiKeyExportRecord, StoredAuthApiKeySnapshot,
};
use async_trait::async_trait;
use crate::AppState;
#[derive(Debug)]
struct PartialListAuthApiKeyRepository {
lookup: InMemoryAuthApiKeySnapshotRepository,
}
#[async_trait]
impl AuthApiKeyReadRepository for PartialListAuthApiKeyRepository {
async fn find_api_key_snapshot(
&self,
key: AuthApiKeyLookupKey<'_>,
) -> Result<Option<StoredAuthApiKeySnapshot>, aether_data::DataLayerError> {
self.lookup.find_api_key_snapshot(key).await
}
async fn list_api_key_snapshots_by_ids(
&self,
_api_key_ids: &[String],
) -> Result<Vec<StoredAuthApiKeySnapshot>, aether_data::DataLayerError> {
Ok(Vec::new())
}
async fn list_export_api_keys_by_user_ids(
&self,
user_ids: &[String],
) -> Result<Vec<StoredAuthApiKeyExportRecord>, aether_data::DataLayerError> {
self.lookup.list_export_api_keys_by_user_ids(user_ids).await
}
async fn list_export_api_keys_by_ids(
&self,
api_key_ids: &[String],
) -> Result<Vec<StoredAuthApiKeyExportRecord>, aether_data::DataLayerError> {
self.lookup.list_export_api_keys_by_ids(api_key_ids).await
}
async fn list_export_standalone_api_keys_page(
&self,
query: &StandaloneApiKeyExportListQuery,
) -> Result<Vec<StoredAuthApiKeyExportRecord>, aether_data::DataLayerError> {
self.lookup
.list_export_standalone_api_keys_page(query)
.await
}
async fn count_export_standalone_api_keys(
&self,
is_active: Option<bool>,
) -> Result<u64, aether_data::DataLayerError> {
self.lookup
.count_export_standalone_api_keys(is_active)
.await
}
async fn summarize_export_api_keys_by_user_ids(
&self,
user_ids: &[String],
now_unix_secs: u64,
) -> Result<AuthApiKeyExportSummary, aether_data::DataLayerError> {
self.lookup
.summarize_export_api_keys_by_user_ids(user_ids, now_unix_secs)
.await
}
async fn summarize_export_non_standalone_api_keys(
&self,
now_unix_secs: u64,
) -> Result<AuthApiKeyExportSummary, aether_data::DataLayerError> {
self.lookup
.summarize_export_non_standalone_api_keys(now_unix_secs)
.await
}
async fn summarize_export_standalone_api_keys(
&self,
now_unix_secs: u64,
) -> Result<AuthApiKeyExportSummary, aether_data::DataLayerError> {
self.lookup
.summarize_export_standalone_api_keys(now_unix_secs)
.await
}
async fn find_export_standalone_api_key_by_id(
&self,
api_key_id: &str,
) -> Result<Option<StoredAuthApiKeyExportRecord>, aether_data::DataLayerError> {
self.lookup
.find_export_standalone_api_key_by_id(api_key_id)
.await
}
async fn list_export_standalone_api_keys(
&self,
) -> Result<Vec<StoredAuthApiKeyExportRecord>, aether_data::DataLayerError> {
self.lookup.list_export_standalone_api_keys().await
}
}
fn sample_usage_auth_snapshot(
api_key_id: &str,
user_id: &str,
api_key_name: &str,
) -> StoredAuthApiKeySnapshot {
StoredAuthApiKeySnapshot::new(
user_id.to_string(),
"alice".to_string(),
Some("alice@example.com".to_string()),
"user".to_string(),
"local".to_string(),
true,
false,
None,
None,
None,
api_key_id.to_string(),
Some(api_key_name.to_string()),
true,
false,
false,
Some(60),
Some(5),
None,
None,
None,
None,
)
.expect("auth api key snapshot should build")
}
#[tokio::test]
async fn resolve_auth_api_key_names_by_ids_falls_back_to_single_lookup_for_missing_list_rows() {
let repository = Arc::new(PartialListAuthApiKeyRepository {
lookup: InMemoryAuthApiKeySnapshotRepository::seed(vec![(
None,
sample_usage_auth_snapshot("key-1", "user-1", "fresh-default"),
)]),
});
let state = AppState::new()
.expect("state should build")
.with_auth_api_key_data_reader_for_tests(repository);
let names = state
.resolve_auth_api_key_names_by_ids(&["key-1".to_string()])
.await
.expect("api key name resolution should succeed");
assert_eq!(
names,
BTreeMap::from([("key-1".to_string(), "fresh-default".to_string())])
);
}
#[tokio::test]
async fn resolve_auth_api_key_snapshots_by_ids_falls_back_to_single_lookup_for_missing_list_rows(
) {
let repository = Arc::new(PartialListAuthApiKeyRepository {
lookup: InMemoryAuthApiKeySnapshotRepository::seed(vec![(
None,
sample_usage_auth_snapshot("key-1", "user-1", "fresh-default"),
)]),
});
let state = AppState::new()
.expect("state should build")
.with_auth_api_key_data_reader_for_tests(repository);
let snapshots = state
.resolve_auth_api_key_snapshots_by_ids(&["key-1".to_string()])
.await
.expect("api key snapshot resolution should succeed");
assert_eq!(snapshots.len(), 1);
assert_eq!(snapshots[0].api_key_id, "key-1");
assert_eq!(snapshots[0].api_key_name.as_deref(), Some("fresh-default"));
}
}

View File

@@ -1,6 +1,48 @@
use std::collections::{BTreeMap, BTreeSet};
use crate::{AppState, GatewayError};
impl AppState {
pub(crate) async fn resolve_auth_user_summaries_by_ids(
&self,
user_ids: &[String],
) -> Result<BTreeMap<String, aether_data::repository::users::StoredUserSummary>, GatewayError>
{
let user_ids = user_ids
.iter()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.collect::<BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>();
if user_ids.is_empty() {
return Ok(BTreeMap::new());
}
let mut users = BTreeMap::new();
if self.has_user_data_reader() {
for user in self.list_users_by_ids(&user_ids).await? {
users.insert(user.id.clone(), user);
}
}
for user_id in &user_ids {
if users.contains_key(user_id) {
continue;
}
let Some(user) = self.find_user_auth_by_id(user_id).await? else {
continue;
};
let summary = user
.to_summary()
.map_err(|err| GatewayError::Internal(err.to_string()))?;
users.insert(summary.id.clone(), summary);
}
Ok(users)
}
pub(crate) async fn find_user_auth_by_id(
&self,
user_id: &str,

View File

@@ -243,6 +243,56 @@ impl AppState {
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn initialize_auth_api_key_wallet(
&self,
api_key_id: &str,
initial_gift_usd: f64,
unlimited: bool,
) -> Result<Option<aether_data::repository::wallet::StoredWalletSnapshot>, GatewayError> {
#[cfg(test)]
if let Some(store) = self.auth_wallet_store.as_ref() {
let gift_balance = if unlimited {
0.0
} else {
initial_gift_usd.max(0.0)
};
let now_unix_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let wallet = aether_data::repository::wallet::StoredWalletSnapshot::new(
uuid::Uuid::new_v4().to_string(),
None,
Some(api_key_id.to_string()),
0.0,
gift_balance,
if unlimited {
"unlimited".to_string()
} else {
"finite".to_string()
},
"USD".to_string(),
"active".to_string(),
0.0,
0.0,
0.0,
gift_balance,
now_unix_secs,
)
.map_err(|err| GatewayError::Internal(err.to_string()))?;
store
.lock()
.expect("auth wallet store should lock")
.insert(wallet.id.clone(), wallet.clone());
return Ok(Some(wallet));
}
self.data
.initialize_auth_api_key_wallet(api_key_id, initial_gift_usd, unlimited)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn update_auth_user_wallet_limit_mode(
&self,
user_id: &str,
@@ -268,4 +318,144 @@ impl AppState {
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn update_auth_api_key_wallet_limit_mode(
&self,
api_key_id: &str,
limit_mode: &str,
) -> Result<Option<aether_data::repository::wallet::StoredWalletSnapshot>, GatewayError> {
#[cfg(test)]
if let Some(store) = self.auth_wallet_store.as_ref() {
let mut guard = store.lock().expect("auth wallet store should lock");
let Some((wallet_id, wallet)) = guard
.iter_mut()
.find(|(_, wallet)| wallet.api_key_id.as_deref() == Some(api_key_id))
else {
return Ok(None);
};
let _ = wallet_id;
wallet.limit_mode = limit_mode.to_string();
wallet.updated_at_unix_secs = chrono::Utc::now().timestamp().max(0) as u64;
return Ok(Some(wallet.clone()));
}
self.data
.update_auth_api_key_wallet_limit_mode(api_key_id, limit_mode)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn update_auth_user_wallet_snapshot(
&self,
user_id: &str,
balance: f64,
gift_balance: f64,
limit_mode: &str,
currency: &str,
status: &str,
total_recharged: f64,
total_consumed: f64,
total_refunded: f64,
total_adjusted: f64,
updated_at_unix_secs: Option<u64>,
) -> Result<Option<aether_data::repository::wallet::StoredWalletSnapshot>, GatewayError> {
#[cfg(test)]
if let Some(store) = self.auth_wallet_store.as_ref() {
let mut guard = store.lock().expect("auth wallet store should lock");
let Some((_, wallet)) = guard
.iter_mut()
.find(|(_, wallet)| wallet.user_id.as_deref() == Some(user_id))
else {
return Ok(None);
};
wallet.balance = balance;
wallet.gift_balance = gift_balance;
wallet.limit_mode = limit_mode.to_string();
wallet.currency = currency.to_string();
wallet.status = status.to_string();
wallet.total_recharged = total_recharged;
wallet.total_consumed = total_consumed;
wallet.total_refunded = total_refunded;
wallet.total_adjusted = total_adjusted;
if let Some(updated_at_unix_secs) = updated_at_unix_secs {
wallet.updated_at_unix_secs = updated_at_unix_secs;
}
return Ok(Some(wallet.clone()));
}
self.data
.update_auth_user_wallet_snapshot(
user_id,
balance,
gift_balance,
limit_mode,
currency,
status,
total_recharged,
total_consumed,
total_refunded,
total_adjusted,
updated_at_unix_secs,
)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn update_auth_api_key_wallet_snapshot(
&self,
api_key_id: &str,
balance: f64,
gift_balance: f64,
limit_mode: &str,
currency: &str,
status: &str,
total_recharged: f64,
total_consumed: f64,
total_refunded: f64,
total_adjusted: f64,
updated_at_unix_secs: Option<u64>,
) -> Result<Option<aether_data::repository::wallet::StoredWalletSnapshot>, GatewayError> {
#[cfg(test)]
if let Some(store) = self.auth_wallet_store.as_ref() {
let mut guard = store.lock().expect("auth wallet store should lock");
let Some((_, wallet)) = guard
.iter_mut()
.find(|(_, wallet)| wallet.api_key_id.as_deref() == Some(api_key_id))
else {
return Ok(None);
};
wallet.balance = balance;
wallet.gift_balance = gift_balance;
wallet.limit_mode = limit_mode.to_string();
wallet.currency = currency.to_string();
wallet.status = status.to_string();
wallet.total_recharged = total_recharged;
wallet.total_consumed = total_consumed;
wallet.total_refunded = total_refunded;
wallet.total_adjusted = total_adjusted;
if let Some(updated_at_unix_secs) = updated_at_unix_secs {
wallet.updated_at_unix_secs = updated_at_unix_secs;
}
return Ok(Some(wallet.clone()));
}
self.data
.update_auth_api_key_wallet_snapshot(
api_key_id,
balance,
gift_balance,
limit_mode,
currency,
status,
total_recharged,
total_consumed,
total_refunded,
total_adjusted,
updated_at_unix_secs,
)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
}

View File

@@ -53,6 +53,15 @@ impl AppState {
self.data.has_user_reader()
}
pub fn has_auth_user_data_reader(&self) -> bool {
#[cfg(test)]
if self.auth_user_store.is_some() {
return true;
}
self.data.has_user_reader()
}
pub fn has_usage_data_writer(&self) -> bool {
self.data.has_usage_writer()
}