Add multi-database data layer

Introduce aether-data-schema and driver-specific schema generation for Postgres, MySQL, and SQLite.

Split data backends, lifecycle, repositories, and gateway runtime integration across database drivers.

Verified with cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings, and cargo test --workspace.
This commit is contained in:
fawney19
2026-05-05 18:27:36 +08:00
parent 099653f732
commit fce7e959e5
372 changed files with 86217 additions and 21160 deletions
@@ -39,6 +39,17 @@ pub struct InMemoryWalletRepository {
redeem_code_hash_to_id: RwLock<BTreeMap<String, String>>,
}
#[derive(Debug, Default)]
pub(crate) struct WalletReadSeed {
pub(crate) wallets: Vec<StoredWalletSnapshot>,
pub(crate) payment_orders: Vec<StoredAdminPaymentOrder>,
pub(crate) payment_callbacks: Vec<StoredAdminPaymentCallback>,
pub(crate) wallet_transactions: Vec<StoredAdminWalletTransaction>,
pub(crate) refunds: Vec<StoredAdminWalletRefund>,
pub(crate) redeem_batches: Vec<StoredAdminRedeemCodeBatch>,
pub(crate) redeem_codes: Vec<StoredAdminRedeemCode>,
}
impl InMemoryWalletRepository {
pub fn seed<I>(items: I) -> Self
where
@@ -60,6 +71,48 @@ impl InMemoryWalletRepository {
}
}
pub(crate) fn seed_read_model(seed: WalletReadSeed) -> Self {
let mut wallets_by_id = BTreeMap::new();
for item in seed.wallets {
wallets_by_id.insert(item.id.clone(), item);
}
let mut payment_orders_by_id = BTreeMap::new();
for item in seed.payment_orders {
payment_orders_by_id.insert(item.id.clone(), item);
}
let mut payment_callbacks_by_id = BTreeMap::new();
for item in seed.payment_callbacks {
payment_callbacks_by_id.insert(item.id.clone(), item);
}
let mut wallet_transactions_by_id = BTreeMap::new();
for item in seed.wallet_transactions {
wallet_transactions_by_id.insert(item.id.clone(), item);
}
let mut refunds_by_id = BTreeMap::new();
for item in seed.refunds {
refunds_by_id.insert(item.id.clone(), item);
}
let mut redeem_batches_by_id = BTreeMap::new();
for item in seed.redeem_batches {
redeem_batches_by_id.insert(item.id.clone(), item);
}
let mut redeem_codes_by_id = BTreeMap::new();
for item in seed.redeem_codes {
redeem_codes_by_id.insert(item.id.clone(), item);
}
Self {
wallets_by_id: RwLock::new(wallets_by_id),
payment_orders_by_id: RwLock::new(payment_orders_by_id),
payment_callbacks_by_id: RwLock::new(payment_callbacks_by_id),
wallet_transactions_by_id: RwLock::new(wallet_transactions_by_id),
refunds_by_id: RwLock::new(refunds_by_id),
redeem_batches_by_id: RwLock::new(redeem_batches_by_id),
redeem_codes_by_id: RwLock::new(redeem_codes_by_id),
redeem_code_hash_to_id: RwLock::new(BTreeMap::new()),
}
}
pub(crate) fn with_wallets_mut<R>(
&self,
f: impl FnOnce(&mut BTreeMap<String, StoredWalletSnapshot>) -> R,
@@ -77,6 +130,122 @@ fn current_unix_ms() -> u64 {
chrono::Utc::now().timestamp_millis().max(0) as u64
}
struct WalletSnapshotUpdate<'a> {
balance: f64,
gift_balance: f64,
limit_mode: &'a str,
currency: &'a str,
status: &'a str,
total_recharged: f64,
total_consumed: f64,
total_refunded: f64,
total_adjusted: f64,
updated_at_unix_secs: Option<u64>,
}
fn update_wallet_by_owner(
wallets_by_id: &RwLock<BTreeMap<String, StoredWalletSnapshot>>,
matches_owner: impl Fn(&StoredWalletSnapshot) -> bool,
update: impl FnOnce(&mut StoredWalletSnapshot),
) -> Result<Option<StoredWalletSnapshot>, DataLayerError> {
let mut wallets = wallets_by_id.write().expect("wallet repo lock");
let Some(wallet) = wallets.values_mut().find(|wallet| matches_owner(wallet)) else {
return Ok(None);
};
update(wallet);
Ok(Some(wallet.clone()))
}
fn update_wallet_snapshot_by_owner(
wallets_by_id: &RwLock<BTreeMap<String, StoredWalletSnapshot>>,
matches_owner: impl Fn(&StoredWalletSnapshot) -> bool,
update: WalletSnapshotUpdate<'_>,
) -> Result<Option<StoredWalletSnapshot>, DataLayerError> {
update_wallet_by_owner(wallets_by_id, matches_owner, |wallet| {
wallet.balance = update.balance;
wallet.gift_balance = update.gift_balance;
wallet.limit_mode = update.limit_mode.to_string();
wallet.currency = update.currency.to_string();
wallet.status = update.status.to_string();
wallet.total_recharged = update.total_recharged;
wallet.total_consumed = update.total_consumed;
wallet.total_refunded = update.total_refunded;
wallet.total_adjusted = update.total_adjusted;
wallet.updated_at_unix_secs = update
.updated_at_unix_secs
.unwrap_or_else(current_unix_secs);
})
}
fn initialize_auth_wallet_in_memory(
wallets_by_id: &RwLock<BTreeMap<String, StoredWalletSnapshot>>,
wallet_transactions_by_id: &RwLock<BTreeMap<String, StoredAdminWalletTransaction>>,
user_id: Option<&str>,
api_key_id: Option<&str>,
initial_gift_usd: f64,
unlimited: bool,
) -> Result<Option<StoredWalletSnapshot>, DataLayerError> {
let gift_amount = if unlimited {
0.0
} else {
initial_gift_usd.max(0.0)
};
let wallet = StoredWalletSnapshot::new(
uuid::Uuid::new_v4().to_string(),
user_id.map(str::to_string),
api_key_id.map(str::to_string),
0.0,
gift_amount,
if unlimited { "unlimited" } else { "finite" }.to_string(),
"USD".to_string(),
"active".to_string(),
0.0,
0.0,
0.0,
gift_amount,
current_unix_secs() as i64,
)?;
wallets_by_id
.write()
.expect("wallet repo lock")
.insert(wallet.id.clone(), wallet.clone());
if gift_amount > 0.0 {
let link_id = user_id.or(api_key_id).unwrap_or_default().to_string();
let description = if api_key_id.is_some() {
"独立余额 Key 初始赠款"
} else {
"用户初始赠款"
};
let transaction = StoredAdminWalletTransaction {
id: uuid::Uuid::new_v4().to_string(),
wallet_id: wallet.id.clone(),
category: "gift".to_string(),
reason_code: "gift_initial".to_string(),
amount: gift_amount,
balance_before: 0.0,
balance_after: gift_amount,
recharge_balance_before: 0.0,
recharge_balance_after: 0.0,
gift_balance_before: 0.0,
gift_balance_after: gift_amount,
link_type: Some("system_task".to_string()),
link_id: Some(link_id),
operator_id: None,
operator_name: None,
operator_email: None,
description: Some(description.to_string()),
created_at_unix_ms: Some(current_unix_ms()),
};
wallet_transactions_by_id
.write()
.expect("wallet repo lock")
.insert(transaction.id.clone(), transaction);
}
Ok(Some(wallet))
}
fn normalize_redeem_code(value: &str) -> Option<String> {
let normalized = value
.chars()
@@ -138,6 +307,132 @@ impl WalletReadRepository for InMemoryWalletRepository {
})
}
async fn update_auth_user_wallet_limit_mode(
&self,
user_id: &str,
limit_mode: &str,
) -> Result<Option<StoredWalletSnapshot>, DataLayerError> {
update_wallet_by_owner(
&self.wallets_by_id,
|wallet| wallet.user_id.as_deref() == Some(user_id),
|wallet| {
wallet.limit_mode = limit_mode.to_string();
wallet.updated_at_unix_secs = current_unix_secs();
},
)
}
async fn update_auth_api_key_wallet_limit_mode(
&self,
api_key_id: &str,
limit_mode: &str,
) -> Result<Option<StoredWalletSnapshot>, DataLayerError> {
update_wallet_by_owner(
&self.wallets_by_id,
|wallet| wallet.api_key_id.as_deref() == Some(api_key_id),
|wallet| {
wallet.limit_mode = limit_mode.to_string();
wallet.updated_at_unix_secs = current_unix_secs();
},
)
}
async fn initialize_auth_user_wallet(
&self,
user_id: &str,
initial_gift_usd: f64,
unlimited: bool,
) -> Result<Option<StoredWalletSnapshot>, DataLayerError> {
initialize_auth_wallet_in_memory(
&self.wallets_by_id,
&self.wallet_transactions_by_id,
Some(user_id),
None,
initial_gift_usd,
unlimited,
)
}
async fn initialize_auth_api_key_wallet(
&self,
api_key_id: &str,
initial_gift_usd: f64,
unlimited: bool,
) -> Result<Option<StoredWalletSnapshot>, DataLayerError> {
initialize_auth_wallet_in_memory(
&self.wallets_by_id,
&self.wallet_transactions_by_id,
None,
Some(api_key_id),
initial_gift_usd,
unlimited,
)
}
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<StoredWalletSnapshot>, DataLayerError> {
update_wallet_snapshot_by_owner(
&self.wallets_by_id,
|wallet| wallet.user_id.as_deref() == Some(user_id),
WalletSnapshotUpdate {
balance,
gift_balance,
limit_mode,
currency,
status,
total_recharged,
total_consumed,
total_refunded,
total_adjusted,
updated_at_unix_secs,
},
)
}
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<StoredWalletSnapshot>, DataLayerError> {
update_wallet_snapshot_by_owner(
&self.wallets_by_id,
|wallet| wallet.api_key_id.as_deref() == Some(api_key_id),
WalletSnapshotUpdate {
balance,
gift_balance,
limit_mode,
currency,
status,
total_recharged,
total_consumed,
total_refunded,
total_adjusted,
updated_at_unix_secs,
},
)
}
async fn list_wallets_by_user_ids(
&self,
user_ids: &[String],
@@ -429,6 +724,37 @@ impl WalletReadRepository for InMemoryWalletRepository {
Ok(StoredAdminPaymentOrderPage { items, total })
}
async fn count_pending_refunds_by_user_id(&self, user_id: &str) -> Result<u64, DataLayerError> {
const PENDING_REFUND_STATUSES: &[&str] = &["pending_approval", "approved", "processing"];
Ok(self
.refunds_by_id
.read()
.expect("wallet repo lock")
.values()
.filter(|refund| {
refund.user_id.as_deref() == Some(user_id)
&& PENDING_REFUND_STATUSES.contains(&refund.status.as_str())
})
.count() as u64)
}
async fn count_pending_payment_orders_by_user_id(
&self,
user_id: &str,
) -> Result<u64, DataLayerError> {
const PENDING_PAYMENT_ORDER_STATUSES: &[&str] = &["pending", "paid"];
Ok(self
.payment_orders_by_id
.read()
.expect("wallet repo lock")
.values()
.filter(|order| {
order.user_id.as_deref() == Some(user_id)
&& PENDING_PAYMENT_ORDER_STATUSES.contains(&order.status.as_str())
})
.count() as u64)
}
async fn find_wallet_payment_order_by_user_id(
&self,
user_id: &str,
@@ -1213,9 +1539,10 @@ impl WalletWriteRepository for InMemoryWalletRepository {
#[cfg(test)]
mod tests {
use super::InMemoryWalletRepository;
use super::{InMemoryWalletRepository, WalletReadSeed};
use crate::repository::wallet::{
AdminWalletListQuery, StoredWalletSnapshot, WalletLookupKey, WalletReadRepository,
AdminWalletListQuery, StoredAdminPaymentOrder, StoredAdminWalletRefund,
StoredWalletSnapshot, WalletLookupKey, WalletReadRepository,
};
fn sample_wallet() -> StoredWalletSnapshot {
@@ -1237,6 +1564,122 @@ mod tests {
.expect("wallet should build")
}
#[tokio::test]
async fn updates_auth_wallet_limit_mode_and_snapshot_in_memory() {
let repository = InMemoryWalletRepository::seed(vec![sample_wallet()]);
let limit_updated = repository
.update_auth_user_wallet_limit_mode("user-1", "unlimited")
.await
.expect("limit mode update should succeed")
.expect("wallet should update");
assert_eq!(limit_updated.limit_mode, "unlimited");
let snapshot_updated = repository
.update_auth_api_key_wallet_snapshot(
"key-1",
20.0,
4.0,
"finite",
"USD",
"active",
30.0,
5.0,
1.0,
2.0,
Some(777),
)
.await
.expect("snapshot update should succeed")
.expect("wallet should update");
assert_eq!(snapshot_updated.balance, 20.0);
assert_eq!(snapshot_updated.gift_balance, 4.0);
assert_eq!(snapshot_updated.total_recharged, 30.0);
assert_eq!(snapshot_updated.total_consumed, 5.0);
assert_eq!(snapshot_updated.total_refunded, 1.0);
assert_eq!(snapshot_updated.total_adjusted, 2.0);
assert_eq!(snapshot_updated.updated_at_unix_secs, 777);
assert!(repository
.update_auth_user_wallet_limit_mode("missing-user", "finite")
.await
.expect("missing limit mode update should succeed")
.is_none());
let user_wallet = repository
.initialize_auth_user_wallet("user-2", 7.0, false)
.await
.expect("user wallet init should succeed")
.expect("user wallet should initialize");
assert_eq!(user_wallet.user_id.as_deref(), Some("user-2"));
assert_eq!(user_wallet.gift_balance, 7.0);
assert_eq!(user_wallet.total_adjusted, 7.0);
let api_key_wallet = repository
.initialize_auth_api_key_wallet("key-2", 7.0, true)
.await
.expect("api key wallet init should succeed")
.expect("api key wallet should initialize");
assert_eq!(api_key_wallet.api_key_id.as_deref(), Some("key-2"));
assert_eq!(api_key_wallet.limit_mode, "unlimited");
assert_eq!(api_key_wallet.gift_balance, 0.0);
}
fn sample_payment_order(
id: &str,
user_id: Option<&str>,
status: &str,
) -> StoredAdminPaymentOrder {
StoredAdminPaymentOrder {
id: id.to_string(),
order_no: format!("order-no-{id}"),
wallet_id: "wallet-1".to_string(),
user_id: user_id.map(str::to_string),
amount_usd: 10.0,
pay_amount: None,
pay_currency: None,
exchange_rate: None,
refunded_amount_usd: 0.0,
refundable_amount_usd: 10.0,
payment_method: "stripe".to_string(),
gateway_order_id: None,
gateway_response: None,
status: status.to_string(),
created_at_unix_ms: 100,
paid_at_unix_secs: None,
credited_at_unix_secs: None,
expires_at_unix_secs: None,
}
}
fn sample_refund(id: &str, user_id: Option<&str>, status: &str) -> StoredAdminWalletRefund {
StoredAdminWalletRefund {
id: id.to_string(),
refund_no: format!("refund-no-{id}"),
wallet_id: "wallet-1".to_string(),
user_id: user_id.map(str::to_string),
payment_order_id: None,
source_type: "wallet_balance".to_string(),
source_id: None,
refund_mode: "offline_payout".to_string(),
amount_usd: 3.0,
status: status.to_string(),
reason: None,
failure_reason: None,
gateway_refund_id: None,
payout_method: None,
payout_reference: None,
payout_proof: None,
requested_by: None,
approved_by: None,
processed_by: None,
created_at_unix_ms: 100,
updated_at_unix_secs: 100,
processed_at_unix_secs: None,
completed_at_unix_secs: None,
}
}
#[tokio::test]
async fn finds_wallet_by_owner() {
let repository = InMemoryWalletRepository::seed(vec![sample_wallet()]);
@@ -1318,4 +1761,42 @@ mod tests {
assert_eq!(history.total, 0);
assert!(history.items.is_empty());
}
#[tokio::test]
async fn counts_pending_user_refunds_and_payment_orders() {
let repository = InMemoryWalletRepository::seed_read_model(WalletReadSeed {
wallets: vec![sample_wallet()],
payment_orders: vec![
sample_payment_order("order-1", Some("user-1"), "pending"),
sample_payment_order("order-2", Some("user-1"), "paid"),
sample_payment_order("order-3", Some("user-1"), "credited"),
sample_payment_order("order-4", Some("user-2"), "pending"),
],
payment_callbacks: Vec::new(),
wallet_transactions: Vec::new(),
refunds: vec![
sample_refund("refund-1", Some("user-1"), "pending_approval"),
sample_refund("refund-2", Some("user-1"), "processing"),
sample_refund("refund-3", Some("user-1"), "completed"),
sample_refund("refund-4", Some("user-2"), "approved"),
],
redeem_batches: Vec::new(),
redeem_codes: Vec::new(),
});
assert_eq!(
repository
.count_pending_payment_orders_by_user_id("user-1")
.await
.expect("payment order count should succeed"),
2
);
assert_eq!(
repository
.count_pending_refunds_by_user_id("user-1")
.await
.expect("refund count should succeed"),
2
);
}
}
@@ -1,9 +1,13 @@
mod memory;
mod sql;
mod mysql;
mod postgres;
mod sqlite;
mod types;
pub use memory::InMemoryWalletRepository;
pub use sql::SqlxWalletRepository;
pub use mysql::MysqlWalletReadRepository;
pub use postgres::SqlxWalletRepository;
pub use sqlite::SqliteWalletReadRepository;
pub use types::{
AdjustWalletBalanceInput, AdminPaymentCallbackRecord, AdminPaymentOrderListQuery,
AdminRedeemCodeBatchListQuery, AdminRedeemCodeListQuery, AdminWalletLedgerQuery,
File diff suppressed because it is too large Load Diff
@@ -28,8 +28,8 @@ use super::types::{
WalletReadRepository, WalletWriteRepository,
};
use crate::{
driver::postgres::PostgresTransactionRunner,
error::{postgres_error, SqlxResultExt},
postgres::PostgresTransactionRunner,
DataLayerError,
};
@@ -370,6 +370,13 @@ FROM refund_requests
WHERE wallet_id = $1
"#;
const COUNT_PENDING_REFUNDS_BY_USER_SQL: &str = r#"
SELECT COUNT(*) AS total
FROM refund_requests
WHERE user_id = $1
AND status = ANY($2::TEXT[])
"#;
const LIST_ADMIN_WALLET_REFUNDS_SQL: &str = r#"
SELECT
id,
@@ -484,6 +491,13 @@ FROM payment_orders
WHERE user_id = $1
"#;
const COUNT_PENDING_PAYMENT_ORDERS_BY_USER_SQL: &str = r#"
SELECT COUNT(*) AS total
FROM payment_orders
WHERE user_id = $1
AND status = ANY($2::TEXT[])
"#;
const LIST_WALLET_PAYMENT_ORDERS_BY_USER_SQL: &str = r#"
SELECT
id,
@@ -638,6 +652,143 @@ impl WalletReadRepository for SqlxWalletRepository {
.map_postgres_err()?;
row.as_ref().map(map_wallet_row).transpose()
}
async fn update_auth_user_wallet_limit_mode(
&self,
user_id: &str,
limit_mode: &str,
) -> Result<Option<StoredWalletSnapshot>, DataLayerError> {
let result = sqlx::query(
"UPDATE wallets SET limit_mode = $2, updated_at = NOW() WHERE user_id = $1",
)
.bind(user_id)
.bind(limit_mode)
.execute(&self.pool)
.await
.map_postgres_err()?;
if result.rows_affected() == 0 {
return Ok(None);
}
self.find(WalletLookupKey::UserId(user_id)).await
}
async fn update_auth_api_key_wallet_limit_mode(
&self,
api_key_id: &str,
limit_mode: &str,
) -> Result<Option<StoredWalletSnapshot>, DataLayerError> {
let result = sqlx::query(
"UPDATE wallets SET limit_mode = $2, updated_at = NOW() WHERE api_key_id = $1",
)
.bind(api_key_id)
.bind(limit_mode)
.execute(&self.pool)
.await
.map_postgres_err()?;
if result.rows_affected() == 0 {
return Ok(None);
}
self.find(WalletLookupKey::ApiKeyId(api_key_id)).await
}
async fn initialize_auth_user_wallet(
&self,
user_id: &str,
initial_gift_usd: f64,
unlimited: bool,
) -> Result<Option<StoredWalletSnapshot>, DataLayerError> {
initialize_postgres_auth_wallet(
&self.pool,
Some(user_id),
None,
initial_gift_usd,
unlimited,
)
.await
}
async fn initialize_auth_api_key_wallet(
&self,
api_key_id: &str,
initial_gift_usd: f64,
unlimited: bool,
) -> Result<Option<StoredWalletSnapshot>, DataLayerError> {
initialize_postgres_auth_wallet(
&self.pool,
None,
Some(api_key_id),
initial_gift_usd,
unlimited,
)
.await
}
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<StoredWalletSnapshot>, DataLayerError> {
update_postgres_wallet_snapshot(
&self.pool,
"user_id",
user_id,
balance,
gift_balance,
limit_mode,
currency,
status,
total_recharged,
total_consumed,
total_refunded,
total_adjusted,
updated_at_unix_secs,
)
.await?;
self.find(WalletLookupKey::UserId(user_id)).await
}
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<StoredWalletSnapshot>, DataLayerError> {
update_postgres_wallet_snapshot(
&self.pool,
"api_key_id",
api_key_id,
balance,
gift_balance,
limit_mode,
currency,
status,
total_recharged,
total_consumed,
total_refunded,
total_adjusted,
updated_at_unix_secs,
)
.await?;
self.find(WalletLookupKey::ApiKeyId(api_key_id)).await
}
async fn list_wallets_by_user_ids(
&self,
user_ids: &[String],
@@ -896,6 +1047,37 @@ impl WalletReadRepository for SqlxWalletRepository {
Ok(StoredAdminPaymentOrderPage { items, total })
}
async fn count_pending_refunds_by_user_id(&self, user_id: &str) -> Result<u64, DataLayerError> {
let statuses = vec![
"pending_approval".to_string(),
"approved".to_string(),
"processing".to_string(),
];
read_count(
sqlx::query(COUNT_PENDING_REFUNDS_BY_USER_SQL)
.bind(user_id)
.bind(statuses)
.fetch_one(&self.pool)
.await
.map_postgres_err()?,
)
}
async fn count_pending_payment_orders_by_user_id(
&self,
user_id: &str,
) -> Result<u64, DataLayerError> {
let statuses = vec!["pending".to_string(), "paid".to_string()];
read_count(
sqlx::query(COUNT_PENDING_PAYMENT_ORDERS_BY_USER_SQL)
.bind(user_id)
.bind(statuses)
.fetch_one(&self.pool)
.await
.map_postgres_err()?,
)
}
async fn find_wallet_payment_order_by_user_id(
&self,
user_id: &str,
@@ -4596,7 +4778,7 @@ fn mask_redeem_code(prefix: &str, suffix: &str) -> String {
}
async fn update_payment_callback_failure(
tx: &mut crate::postgres::PostgresTransaction,
tx: &mut crate::driver::postgres::PostgresTransaction,
callback_id: &str,
input: &ProcessPaymentCallbackInput,
error: &str,
@@ -4629,7 +4811,7 @@ WHERE id = $1
}
async fn mark_payment_callback_processed(
tx: &mut crate::postgres::PostgresTransaction,
tx: &mut crate::driver::postgres::PostgresTransaction,
callback_id: &str,
input: &ProcessPaymentCallbackInput,
order_id: &str,
@@ -5048,10 +5230,147 @@ fn map_wallet_row(row: &sqlx::postgres::PgRow) -> Result<StoredWalletSnapshot, D
)
}
#[allow(clippy::too_many_arguments)]
async fn update_postgres_wallet_snapshot(
pool: &PgPool,
owner_column: &str,
owner_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<(), DataLayerError> {
let owner_predicate = match owner_column {
"user_id" => "user_id = $1",
"api_key_id" => "api_key_id = $1",
_ => {
return Err(DataLayerError::UnexpectedValue(format!(
"unsupported wallet owner column: {owner_column}"
)));
}
};
let sql = format!(
r#"
UPDATE wallets
SET balance = $2,
gift_balance = $3,
limit_mode = $4,
currency = $5,
status = $6,
total_recharged = $7,
total_consumed = $8,
total_refunded = $9,
total_adjusted = $10,
updated_at = COALESCE(to_timestamp($11::DOUBLE PRECISION), NOW())
WHERE {owner_predicate}
"#
);
sqlx::query(&sql)
.bind(owner_id)
.bind(balance)
.bind(gift_balance)
.bind(limit_mode)
.bind(currency)
.bind(status)
.bind(total_recharged)
.bind(total_consumed)
.bind(total_refunded)
.bind(total_adjusted)
.bind(updated_at_unix_secs.map(|value| value as i64))
.execute(pool)
.await
.map_postgres_err()?;
Ok(())
}
async fn initialize_postgres_auth_wallet(
pool: &PgPool,
user_id: Option<&str>,
api_key_id: Option<&str>,
initial_gift_usd: f64,
unlimited: bool,
) -> Result<Option<StoredWalletSnapshot>, DataLayerError> {
let gift_amount = if unlimited {
0.0
} else {
initial_gift_usd.max(0.0)
};
let mut tx = pool.begin().await.map_postgres_err()?;
let wallet_row = sqlx::query(
r#"
INSERT INTO wallets (
id, user_id, api_key_id, balance, gift_balance, limit_mode, currency,
status, total_recharged, total_consumed, total_refunded, total_adjusted,
created_at, updated_at
)
VALUES ($1, $2, $3, 0, $4, $5, 'USD', 'active', 0, 0, 0, $6, NOW(), NOW())
RETURNING
id,
user_id,
api_key_id,
CAST(balance AS DOUBLE PRECISION) AS balance,
CAST(gift_balance AS DOUBLE PRECISION) AS gift_balance,
limit_mode,
currency,
status,
CAST(total_recharged AS DOUBLE PRECISION) AS total_recharged,
CAST(total_consumed AS DOUBLE PRECISION) AS total_consumed,
CAST(total_refunded AS DOUBLE PRECISION) AS total_refunded,
CAST(total_adjusted AS DOUBLE PRECISION) AS total_adjusted,
CAST(EXTRACT(EPOCH FROM updated_at) AS BIGINT) AS updated_at_unix_secs
"#,
)
.bind(Uuid::new_v4().to_string())
.bind(user_id)
.bind(api_key_id)
.bind(gift_amount)
.bind(if unlimited { "unlimited" } else { "finite" })
.bind(gift_amount)
.fetch_one(&mut *tx)
.await
.map_postgres_err()?;
let wallet = map_wallet_row(&wallet_row)?;
if gift_amount > 0.0 {
let link_id = user_id.or(api_key_id).unwrap_or_default();
let description = if api_key_id.is_some() {
"独立余额 Key 初始赠款"
} else {
"用户初始赠款"
};
sqlx::query(
r#"
INSERT INTO wallet_transactions (
id, wallet_id, category, reason_code, amount, balance_before,
balance_after, recharge_balance_before, recharge_balance_after,
gift_balance_before, gift_balance_after, link_type, link_id, operator_id,
description, created_at
)
VALUES ($1, $2, 'gift', 'gift_initial', $3, 0, $3, 0, 0, 0, $3, 'system_task', $4, NULL, $5, NOW())
"#,
)
.bind(Uuid::new_v4().to_string())
.bind(&wallet.id)
.bind(gift_amount)
.bind(link_id)
.bind(description)
.execute(&mut *tx)
.await
.map_postgres_err()?;
}
tx.commit().await.map_err(postgres_error)?;
Ok(Some(wallet))
}
#[cfg(test)]
mod tests {
use super::SqlxWalletRepository;
use crate::postgres::{PostgresPoolConfig, PostgresPoolFactory};
use crate::driver::postgres::{PostgresPoolConfig, PostgresPoolFactory};
#[tokio::test]
async fn repository_constructs_from_lazy_pool() {
File diff suppressed because it is too large Load Diff
@@ -753,6 +753,64 @@ pub trait WalletReadRepository: Send + Sync {
key: WalletLookupKey<'_>,
) -> Result<Option<StoredWalletSnapshot>, crate::DataLayerError>;
async fn update_auth_user_wallet_limit_mode(
&self,
user_id: &str,
limit_mode: &str,
) -> Result<Option<StoredWalletSnapshot>, crate::DataLayerError>;
async fn update_auth_api_key_wallet_limit_mode(
&self,
api_key_id: &str,
limit_mode: &str,
) -> Result<Option<StoredWalletSnapshot>, crate::DataLayerError>;
async fn initialize_auth_user_wallet(
&self,
user_id: &str,
initial_gift_usd: f64,
unlimited: bool,
) -> Result<Option<StoredWalletSnapshot>, crate::DataLayerError>;
async fn initialize_auth_api_key_wallet(
&self,
api_key_id: &str,
initial_gift_usd: f64,
unlimited: bool,
) -> Result<Option<StoredWalletSnapshot>, crate::DataLayerError>;
#[allow(clippy::too_many_arguments)]
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<StoredWalletSnapshot>, crate::DataLayerError>;
#[allow(clippy::too_many_arguments)]
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<StoredWalletSnapshot>, crate::DataLayerError>;
async fn list_wallets_by_user_ids(
&self,
user_ids: &[String],
@@ -822,6 +880,16 @@ pub trait WalletReadRepository: Send + Sync {
offset: usize,
) -> Result<StoredAdminPaymentOrderPage, crate::DataLayerError>;
async fn count_pending_refunds_by_user_id(
&self,
user_id: &str,
) -> Result<u64, crate::DataLayerError>;
async fn count_pending_payment_orders_by_user_id(
&self,
user_id: &str,
) -> Result<u64, crate::DataLayerError>;
async fn find_wallet_payment_order_by_user_id(
&self,
user_id: &str,