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

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);
}
}