feat(payments): 增加兑换码与支付适配框架 (#299)

* feat(payments): 增加兑换码与支付适配框架

* fix(ci): 对齐 Rust 1.95 lint 与格式要求

* fix(payments): harden redeem code wallet credits

---------

Co-authored-by: fawney19 <elky0401@gmail.com>
This commit is contained in:
Entropy.Xu
2026-04-17 10:07:52 +08:00
committed by GitHub
parent 54d77598ae
commit 6964729cb7
36 changed files with 5129 additions and 274 deletions

View File

@@ -8,7 +8,7 @@ use tracing::{error, info, warn};
static MIGRATOR: Migrator = sqlx::migrate!("./migrations");
static BASELINE_V2_SQL: &str = include_str!("../bootstrap/20260413020000_baseline_v2.sql");
const BASELINE_V2_CUTOFF_VERSION: i64 = 20260413030000;
const BASELINE_V2_CUTOFF_VERSION: i64 = 20260415000000;
const MIGRATIONS_TABLE_EXISTS_SQL: &str =
"SELECT to_regclass('public._sqlx_migrations') IS NOT NULL";
const PUBLIC_BASE_TABLE_COUNT_SQL: &str = r#"
@@ -597,6 +597,7 @@ mod tests {
20260410000000,
20260413020000,
20260413030000,
20260415000000,
]
);
}
@@ -687,7 +688,12 @@ mod tests {
assert_eq!(
pending_versions,
vec![20260410000000, 20260413020000, 20260413030000]
vec![
20260410000000,
20260413020000,
20260413030000,
20260415000000
]
);
}

View File

@@ -68,7 +68,7 @@ impl RequestCandidateReadRepository for InMemoryRequestCandidateRepository {
.values()
.cloned()
.collect::<Vec<_>>();
rows.sort_by_key(|right| std::cmp::Reverse(right.created_at_unix_ms));
rows.sort_by_key(|entry| std::cmp::Reverse(entry.created_at_unix_ms));
rows.truncate(limit);
Ok(rows)
}
@@ -90,7 +90,7 @@ impl RequestCandidateReadRepository for InMemoryRequestCandidateRepository {
.filter(|row| row.provider_id.as_deref() == Some(provider_id))
.cloned()
.collect::<Vec<_>>();
rows.sort_by_key(|right| std::cmp::Reverse(right.created_at_unix_ms));
rows.sort_by_key(|entry| std::cmp::Reverse(entry.created_at_unix_ms));
rows.truncate(limit);
Ok(rows)
}
@@ -125,7 +125,7 @@ impl RequestCandidateReadRepository for InMemoryRequestCandidateRepository {
})
.cloned()
.collect::<Vec<_>>();
rows.sort_by_key(|right| std::cmp::Reverse(right.created_at_unix_ms));
rows.sort_by_key(|entry| std::cmp::Reverse(entry.created_at_unix_ms));
rows.truncate(limit);
Ok(rows)
}

View File

@@ -117,7 +117,7 @@ impl VideoTaskReadRepository for InMemoryVideoTaskRepository {
.filter(|task| task.status.is_active())
.cloned()
.collect::<Vec<_>>();
tasks.sort_by_key(|right| std::cmp::Reverse(right.updated_at_unix_secs));
tasks.sort_by_key(|entry| std::cmp::Reverse(entry.updated_at_unix_secs));
tasks.truncate(limit);
Ok(tasks)
}

File diff suppressed because it is too large Load Diff

View File

@@ -6,16 +6,22 @@ pub use memory::InMemoryWalletRepository;
pub use sql::SqlxWalletRepository;
pub use types::{
AdjustWalletBalanceInput, AdminPaymentCallbackRecord, AdminPaymentOrderListQuery,
AdminWalletLedgerQuery, AdminWalletListQuery, AdminWalletPaymentOrderRecord,
AdminWalletRefundRecord, AdminWalletRefundRequestListQuery, AdminWalletTransactionRecord,
CompleteAdminWalletRefundInput, CreateManualWalletRechargeInput,
AdminRedeemCodeBatchListQuery, AdminRedeemCodeListQuery, AdminWalletLedgerQuery,
AdminWalletListQuery, AdminWalletPaymentOrderRecord, AdminWalletRefundRecord,
AdminWalletRefundRequestListQuery, AdminWalletTransactionRecord,
CompleteAdminWalletRefundInput, CreateAdminRedeemCodeBatchInput,
CreateAdminRedeemCodeBatchResult, CreateManualWalletRechargeInput,
CreateWalletRechargeOrderInput, CreateWalletRechargeOrderOutcome,
CreateWalletRefundRequestInput, CreateWalletRefundRequestOutcome, CreditAdminPaymentOrderInput,
FailAdminWalletRefundInput, ProcessAdminWalletRefundInput, ProcessPaymentCallbackInput,
ProcessPaymentCallbackOutcome, StoredAdminPaymentCallback, StoredAdminPaymentCallbackPage,
StoredAdminPaymentOrder, StoredAdminPaymentOrderPage, StoredAdminWalletLedgerItem,
StoredAdminWalletLedgerPage, StoredAdminWalletListItem, StoredAdminWalletListPage,
StoredAdminWalletRefund, StoredAdminWalletRefundPage, StoredAdminWalletRefundRequestItem,
CreateWalletRefundRequestInput, CreateWalletRefundRequestOutcome,
CreatedAdminRedeemCodePlaintext, CreditAdminPaymentOrderInput, DeleteAdminRedeemCodeBatchInput,
DisableAdminRedeemCodeBatchInput, DisableAdminRedeemCodeInput, FailAdminWalletRefundInput,
ProcessAdminWalletRefundInput, ProcessPaymentCallbackInput, ProcessPaymentCallbackOutcome,
RedeemWalletCodeInput, RedeemWalletCodeOutcome, StoredAdminPaymentCallback,
StoredAdminPaymentCallbackPage, StoredAdminPaymentOrder, StoredAdminPaymentOrderPage,
StoredAdminRedeemCode, StoredAdminRedeemCodeBatch, StoredAdminRedeemCodeBatchPage,
StoredAdminRedeemCodePage, StoredAdminWalletLedgerItem, StoredAdminWalletLedgerPage,
StoredAdminWalletListItem, StoredAdminWalletListPage, StoredAdminWalletRefund,
StoredAdminWalletRefundPage, StoredAdminWalletRefundRequestItem,
StoredAdminWalletRefundRequestPage, StoredAdminWalletTransaction,
StoredAdminWalletTransactionPage, StoredWalletDailyUsageLedger,
StoredWalletDailyUsageLedgerPage, StoredWalletSnapshot, WalletLookupKey, WalletMutationOutcome,

File diff suppressed because it is too large Load Diff

View File

@@ -443,6 +443,160 @@ pub struct StoredAdminPaymentCallbackPage {
pub total: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
pub struct AdminRedeemCodeBatchListQuery {
pub status: Option<String>,
pub limit: usize,
pub offset: usize,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct StoredAdminRedeemCodeBatch {
pub id: String,
pub name: String,
pub amount_usd: f64,
pub currency: String,
pub balance_bucket: String,
pub total_count: u64,
pub redeemed_count: u64,
pub active_count: u64,
pub status: String,
pub description: Option<String>,
pub created_by: Option<String>,
pub expires_at_unix_secs: Option<u64>,
pub created_at_unix_ms: u64,
pub updated_at_unix_secs: u64,
}
#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)]
pub struct StoredAdminRedeemCodeBatchPage {
pub items: Vec<StoredAdminRedeemCodeBatch>,
pub total: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
pub struct AdminRedeemCodeListQuery {
pub batch_id: String,
pub status: Option<String>,
pub limit: usize,
pub offset: usize,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct StoredAdminRedeemCode {
pub id: String,
pub batch_id: String,
pub batch_name: Option<String>,
pub code_prefix: String,
pub code_suffix: String,
pub masked_code: String,
pub status: String,
pub redeemed_by_user_id: Option<String>,
pub redeemed_by_user_name: Option<String>,
pub redeemed_wallet_id: Option<String>,
pub redeemed_payment_order_id: Option<String>,
pub redeemed_order_no: Option<String>,
pub redeemed_at_unix_secs: Option<u64>,
pub disabled_by: Option<String>,
pub expires_at_unix_secs: Option<u64>,
pub created_at_unix_ms: u64,
pub updated_at_unix_secs: u64,
}
#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)]
pub struct StoredAdminRedeemCodePage {
pub items: Vec<StoredAdminRedeemCode>,
pub total: u64,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct CreatedAdminRedeemCodePlaintext {
pub code_id: String,
pub code: String,
pub masked_code: String,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct CreateAdminRedeemCodeBatchInput {
pub name: String,
pub amount_usd: f64,
pub currency: String,
pub balance_bucket: String,
pub total_count: usize,
pub expires_at_unix_secs: Option<u64>,
pub description: Option<String>,
pub created_by: Option<String>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct CreateAdminRedeemCodeBatchResult {
pub batch: StoredAdminRedeemCodeBatch,
pub codes: Vec<CreatedAdminRedeemCodePlaintext>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct DisableAdminRedeemCodeBatchInput {
pub batch_id: String,
pub operator_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct DeleteAdminRedeemCodeBatchInput {
pub batch_id: String,
pub operator_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct DisableAdminRedeemCodeInput {
pub code_id: String,
pub operator_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct RedeemWalletCodeInput {
pub code: String,
pub user_id: String,
pub order_no: String,
}
pub(crate) fn redeem_code_credits_recharge_balance(balance_bucket: &str) -> bool {
balance_bucket.trim().eq_ignore_ascii_case("recharge")
}
pub(crate) fn redeem_code_payment_method(balance_bucket: &str) -> &'static str {
if redeem_code_credits_recharge_balance(balance_bucket) {
"card_code"
} else {
"gift_code"
}
}
pub(crate) fn redeem_code_refundable_amount(balance_bucket: &str, amount_usd: f64) -> f64 {
if redeem_code_credits_recharge_balance(balance_bucket) {
amount_usd
} else {
0.0
}
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum RedeemWalletCodeOutcome {
Redeemed {
wallet: StoredWalletSnapshot,
order: StoredAdminPaymentOrder,
amount_usd: f64,
batch_name: String,
},
InvalidCode,
CodeNotFound,
CodeDisabled,
BatchDisabled,
CodeExpired,
CodeRedeemed,
WalletInactive,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct CreateWalletRechargeOrderInput {
pub preferred_wallet_id: Option<String>,
@@ -686,6 +840,21 @@ pub trait WalletReadRepository: Send + Sync {
limit: usize,
offset: usize,
) -> Result<StoredAdminPaymentCallbackPage, crate::DataLayerError>;
async fn list_admin_redeem_code_batches(
&self,
query: &AdminRedeemCodeBatchListQuery,
) -> Result<StoredAdminRedeemCodeBatchPage, crate::DataLayerError>;
async fn find_admin_redeem_code_batch(
&self,
batch_id: &str,
) -> Result<Option<StoredAdminRedeemCodeBatch>, crate::DataLayerError>;
async fn list_admin_redeem_codes(
&self,
query: &AdminRedeemCodeListQuery,
) -> Result<StoredAdminRedeemCodePage, crate::DataLayerError>;
}
#[async_trait]
@@ -758,6 +927,31 @@ pub trait WalletWriteRepository: Send + Sync {
&self,
input: CreditAdminPaymentOrderInput,
) -> Result<WalletMutationOutcome<(StoredAdminPaymentOrder, bool)>, crate::DataLayerError>;
async fn create_admin_redeem_code_batch(
&self,
input: CreateAdminRedeemCodeBatchInput,
) -> Result<CreateAdminRedeemCodeBatchResult, crate::DataLayerError>;
async fn disable_admin_redeem_code_batch(
&self,
input: DisableAdminRedeemCodeBatchInput,
) -> Result<WalletMutationOutcome<StoredAdminRedeemCodeBatch>, crate::DataLayerError>;
async fn delete_admin_redeem_code_batch(
&self,
input: DeleteAdminRedeemCodeBatchInput,
) -> Result<WalletMutationOutcome<StoredAdminRedeemCodeBatch>, crate::DataLayerError>;
async fn disable_admin_redeem_code(
&self,
input: DisableAdminRedeemCodeInput,
) -> Result<WalletMutationOutcome<StoredAdminRedeemCode>, crate::DataLayerError>;
async fn redeem_wallet_code(
&self,
input: RedeemWalletCodeInput,
) -> Result<RedeemWalletCodeOutcome, crate::DataLayerError>;
}
pub trait WalletRepository: WalletReadRepository + WalletWriteRepository + Send + Sync {}
@@ -766,7 +960,10 @@ impl<T> WalletRepository for T where T: WalletReadRepository + WalletWriteReposi
#[cfg(test)]
mod tests {
use super::StoredWalletSnapshot;
use super::{
redeem_code_credits_recharge_balance, redeem_code_payment_method,
redeem_code_refundable_amount, StoredWalletSnapshot,
};
use crate::repository::settlement::UsageSettlementInput;
#[test]
@@ -804,4 +1001,20 @@ mod tests {
};
assert!(input.validate().is_err());
}
#[test]
fn redeem_code_bucket_defaults_to_non_refundable_gift_semantics() {
assert!(!redeem_code_credits_recharge_balance("gift"));
assert_eq!(redeem_code_payment_method("gift"), "gift_code");
assert_eq!(redeem_code_refundable_amount("gift", 8.5), 0.0);
assert_eq!(redeem_code_payment_method("mystery"), "gift_code");
}
#[test]
fn recharge_bucket_preserves_refundable_recharge_semantics() {
assert!(redeem_code_credits_recharge_balance("recharge"));
assert!(redeem_code_credits_recharge_balance(" Recharge "));
assert_eq!(redeem_code_payment_method("recharge"), "card_code");
assert_eq!(redeem_code_refundable_amount("recharge", 8.5), 8.5);
}
}