mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 09:50:21 +08:00
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:
@@ -510,6 +510,93 @@ pub(super) fn classify_admin_basic_family_route(
|
|||||||
"admin:payments",
|
"admin:payments",
|
||||||
false,
|
false,
|
||||||
))
|
))
|
||||||
|
} else if method == http::Method::GET
|
||||||
|
&& matches!(
|
||||||
|
normalized_path,
|
||||||
|
"/api/admin/payments/redeem-codes/batches"
|
||||||
|
| "/api/admin/payments/redeem-codes/batches/"
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Some(classified(
|
||||||
|
"admin_proxy",
|
||||||
|
"payments_manage",
|
||||||
|
"list_redeem_code_batches",
|
||||||
|
"admin:payments",
|
||||||
|
false,
|
||||||
|
))
|
||||||
|
} else if method == http::Method::POST
|
||||||
|
&& matches!(
|
||||||
|
normalized_path,
|
||||||
|
"/api/admin/payments/redeem-codes/batches"
|
||||||
|
| "/api/admin/payments/redeem-codes/batches/"
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Some(classified(
|
||||||
|
"admin_proxy",
|
||||||
|
"payments_manage",
|
||||||
|
"create_redeem_code_batch",
|
||||||
|
"admin:payments",
|
||||||
|
false,
|
||||||
|
))
|
||||||
|
} else if method == http::Method::GET
|
||||||
|
&& normalized_path_no_trailing.starts_with("/api/admin/payments/redeem-codes/batches/")
|
||||||
|
&& normalized_path_no_trailing.matches('/').count() == 6
|
||||||
|
{
|
||||||
|
Some(classified(
|
||||||
|
"admin_proxy",
|
||||||
|
"payments_manage",
|
||||||
|
"get_redeem_code_batch",
|
||||||
|
"admin:payments",
|
||||||
|
false,
|
||||||
|
))
|
||||||
|
} else if method == http::Method::GET
|
||||||
|
&& normalized_path_no_trailing.starts_with("/api/admin/payments/redeem-codes/batches/")
|
||||||
|
&& normalized_path_no_trailing.ends_with("/codes")
|
||||||
|
&& normalized_path_no_trailing.matches('/').count() == 7
|
||||||
|
{
|
||||||
|
Some(classified(
|
||||||
|
"admin_proxy",
|
||||||
|
"payments_manage",
|
||||||
|
"list_redeem_codes",
|
||||||
|
"admin:payments",
|
||||||
|
false,
|
||||||
|
))
|
||||||
|
} else if method == http::Method::POST
|
||||||
|
&& normalized_path_no_trailing.starts_with("/api/admin/payments/redeem-codes/batches/")
|
||||||
|
&& normalized_path_no_trailing.ends_with("/disable")
|
||||||
|
&& normalized_path_no_trailing.matches('/').count() == 7
|
||||||
|
{
|
||||||
|
Some(classified(
|
||||||
|
"admin_proxy",
|
||||||
|
"payments_manage",
|
||||||
|
"disable_redeem_code_batch",
|
||||||
|
"admin:payments",
|
||||||
|
false,
|
||||||
|
))
|
||||||
|
} else if method == http::Method::POST
|
||||||
|
&& normalized_path_no_trailing.starts_with("/api/admin/payments/redeem-codes/batches/")
|
||||||
|
&& normalized_path_no_trailing.ends_with("/delete")
|
||||||
|
&& normalized_path_no_trailing.matches('/').count() == 7
|
||||||
|
{
|
||||||
|
Some(classified(
|
||||||
|
"admin_proxy",
|
||||||
|
"payments_manage",
|
||||||
|
"delete_redeem_code_batch",
|
||||||
|
"admin:payments",
|
||||||
|
false,
|
||||||
|
))
|
||||||
|
} else if method == http::Method::POST
|
||||||
|
&& normalized_path_no_trailing.starts_with("/api/admin/payments/redeem-codes/codes/")
|
||||||
|
&& normalized_path_no_trailing.ends_with("/disable")
|
||||||
|
&& normalized_path_no_trailing.matches('/').count() == 7
|
||||||
|
{
|
||||||
|
Some(classified(
|
||||||
|
"admin_proxy",
|
||||||
|
"payments_manage",
|
||||||
|
"disable_redeem_code",
|
||||||
|
"admin:payments",
|
||||||
|
false,
|
||||||
|
))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -358,12 +358,13 @@ pub(super) fn classify_public_support_route(
|
|||||||
} else if method == http::Method::POST
|
} else if method == http::Method::POST
|
||||||
&& matches!(
|
&& matches!(
|
||||||
normalized_path,
|
normalized_path,
|
||||||
"/api/wallet/recharge" | "/api/wallet/refunds"
|
"/api/wallet/recharge" | "/api/wallet/refunds" | "/api/wallet/redeem"
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
let route_kind = match normalized_path {
|
let route_kind = match normalized_path {
|
||||||
"/api/wallet/recharge" => "create_recharge_order",
|
"/api/wallet/recharge" => "create_recharge_order",
|
||||||
"/api/wallet/refunds" => "create_refund",
|
"/api/wallet/refunds" => "create_refund",
|
||||||
|
"/api/wallet/redeem" => "redeem",
|
||||||
_ => "create_recharge_order",
|
_ => "create_recharge_order",
|
||||||
};
|
};
|
||||||
Some(classified(
|
Some(classified(
|
||||||
|
|||||||
@@ -136,3 +136,72 @@ fn classifies_admin_payments_callbacks_as_admin_proxy_route() {
|
|||||||
);
|
);
|
||||||
assert!(!decision.is_execution_runtime_candidate());
|
assert!(!decision.is_execution_runtime_candidate());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classifies_admin_payments_redeem_code_routes_as_admin_proxy_route() {
|
||||||
|
let headers = headers(&[]);
|
||||||
|
|
||||||
|
let list_batches_uri: Uri = "/api/admin/payments/redeem-codes/batches"
|
||||||
|
.parse()
|
||||||
|
.expect("uri should parse");
|
||||||
|
let list_batches = classify_control_route(&http::Method::GET, &list_batches_uri, &headers)
|
||||||
|
.expect("route should classify");
|
||||||
|
assert_eq!(
|
||||||
|
list_batches.route_family.as_deref(),
|
||||||
|
Some("payments_manage")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
list_batches.route_kind.as_deref(),
|
||||||
|
Some("list_redeem_code_batches")
|
||||||
|
);
|
||||||
|
|
||||||
|
let create_batch_uri: Uri = "/api/admin/payments/redeem-codes/batches"
|
||||||
|
.parse()
|
||||||
|
.expect("uri should parse");
|
||||||
|
let create_batch = classify_control_route(&http::Method::POST, &create_batch_uri, &headers)
|
||||||
|
.expect("route should classify");
|
||||||
|
assert_eq!(
|
||||||
|
create_batch.route_family.as_deref(),
|
||||||
|
Some("payments_manage")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
create_batch.route_kind.as_deref(),
|
||||||
|
Some("create_redeem_code_batch")
|
||||||
|
);
|
||||||
|
|
||||||
|
let list_codes_uri: Uri = "/api/admin/payments/redeem-codes/batches/batch-1/codes"
|
||||||
|
.parse()
|
||||||
|
.expect("uri should parse");
|
||||||
|
let list_codes = classify_control_route(&http::Method::GET, &list_codes_uri, &headers)
|
||||||
|
.expect("route should classify");
|
||||||
|
assert_eq!(list_codes.route_family.as_deref(), Some("payments_manage"));
|
||||||
|
assert_eq!(list_codes.route_kind.as_deref(), Some("list_redeem_codes"));
|
||||||
|
|
||||||
|
let disable_code_uri: Uri = "/api/admin/payments/redeem-codes/codes/code-1/disable"
|
||||||
|
.parse()
|
||||||
|
.expect("uri should parse");
|
||||||
|
let disable_code = classify_control_route(&http::Method::POST, &disable_code_uri, &headers)
|
||||||
|
.expect("route should classify");
|
||||||
|
assert_eq!(
|
||||||
|
disable_code.route_family.as_deref(),
|
||||||
|
Some("payments_manage")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
disable_code.route_kind.as_deref(),
|
||||||
|
Some("disable_redeem_code")
|
||||||
|
);
|
||||||
|
|
||||||
|
let delete_batch_uri: Uri = "/api/admin/payments/redeem-codes/batches/batch-1/delete"
|
||||||
|
.parse()
|
||||||
|
.expect("uri should parse");
|
||||||
|
let delete_batch = classify_control_route(&http::Method::POST, &delete_batch_uri, &headers)
|
||||||
|
.expect("route should classify");
|
||||||
|
assert_eq!(
|
||||||
|
delete_batch.route_family.as_deref(),
|
||||||
|
Some("payments_manage")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
delete_batch.route_kind.as_deref(),
|
||||||
|
Some("delete_redeem_code_batch")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -211,6 +211,22 @@ fn classifies_user_monitoring_audit_logs_as_public_support_route() {
|
|||||||
assert!(!decision.is_execution_runtime_candidate());
|
assert!(!decision.is_execution_runtime_candidate());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classifies_wallet_redeem_as_public_support_route() {
|
||||||
|
let headers = headers(&[("authorization", "Bearer sk-test")]);
|
||||||
|
let uri: Uri = "/api/wallet/redeem".parse().expect("uri should parse");
|
||||||
|
let decision =
|
||||||
|
classify_control_route(&http::Method::POST, &uri, &headers).expect("route should classify");
|
||||||
|
|
||||||
|
assert_eq!(decision.route_class.as_deref(), Some("public_support"));
|
||||||
|
assert_eq!(decision.route_family.as_deref(), Some("wallet"));
|
||||||
|
assert_eq!(decision.route_kind.as_deref(), Some("redeem"));
|
||||||
|
assert_eq!(
|
||||||
|
decision.auth_endpoint_signature.as_deref(),
|
||||||
|
Some("user:wallet")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classifies_announcement_unread_count_as_public_support_route() {
|
fn classifies_announcement_unread_count_as_public_support_route() {
|
||||||
let headers = headers(&[]);
|
let headers = headers(&[]);
|
||||||
|
|||||||
@@ -52,16 +52,22 @@ pub(crate) use aether_data::repository::users::{
|
|||||||
StoredUserPreferenceRecord, StoredUserSessionRecord,
|
StoredUserPreferenceRecord, StoredUserSessionRecord,
|
||||||
};
|
};
|
||||||
use aether_data::repository::wallet::{
|
use aether_data::repository::wallet::{
|
||||||
AdjustWalletBalanceInput, AdminPaymentOrderListQuery, AdminWalletLedgerQuery,
|
AdjustWalletBalanceInput, AdminPaymentOrderListQuery, AdminRedeemCodeBatchListQuery,
|
||||||
AdminWalletListQuery, AdminWalletRefundRequestListQuery, CompleteAdminWalletRefundInput,
|
AdminRedeemCodeListQuery, AdminWalletLedgerQuery, AdminWalletListQuery,
|
||||||
|
AdminWalletRefundRequestListQuery, CompleteAdminWalletRefundInput,
|
||||||
|
CreateAdminRedeemCodeBatchInput, CreateAdminRedeemCodeBatchResult,
|
||||||
CreateManualWalletRechargeInput, CreateWalletRechargeOrderInput,
|
CreateManualWalletRechargeInput, CreateWalletRechargeOrderInput,
|
||||||
CreateWalletRechargeOrderOutcome, CreateWalletRefundRequestInput,
|
CreateWalletRechargeOrderOutcome, CreateWalletRefundRequestInput,
|
||||||
CreateWalletRefundRequestOutcome, CreditAdminPaymentOrderInput, FailAdminWalletRefundInput,
|
CreateWalletRefundRequestOutcome, CreditAdminPaymentOrderInput,
|
||||||
ProcessAdminWalletRefundInput, ProcessPaymentCallbackInput, ProcessPaymentCallbackOutcome,
|
DeleteAdminRedeemCodeBatchInput, DisableAdminRedeemCodeBatchInput, DisableAdminRedeemCodeInput,
|
||||||
|
FailAdminWalletRefundInput, ProcessAdminWalletRefundInput, ProcessPaymentCallbackInput,
|
||||||
|
ProcessPaymentCallbackOutcome, RedeemWalletCodeInput, RedeemWalletCodeOutcome,
|
||||||
StoredAdminPaymentCallback, StoredAdminPaymentCallbackPage, StoredAdminPaymentOrder,
|
StoredAdminPaymentCallback, StoredAdminPaymentCallbackPage, StoredAdminPaymentOrder,
|
||||||
StoredAdminPaymentOrderPage, StoredAdminWalletLedgerPage, StoredAdminWalletListPage,
|
StoredAdminPaymentOrderPage, StoredAdminRedeemCode, StoredAdminRedeemCodeBatch,
|
||||||
StoredAdminWalletRefund, StoredAdminWalletRefundPage, StoredAdminWalletRefundRequestPage,
|
StoredAdminRedeemCodeBatchPage, StoredAdminRedeemCodePage, StoredAdminWalletLedgerPage,
|
||||||
StoredAdminWalletTransaction, StoredAdminWalletTransactionPage, StoredWalletDailyUsageLedger,
|
StoredAdminWalletListPage, StoredAdminWalletRefund, StoredAdminWalletRefundPage,
|
||||||
|
StoredAdminWalletRefundRequestPage, StoredAdminWalletTransaction,
|
||||||
|
StoredAdminWalletTransactionPage, StoredWalletDailyUsageLedger,
|
||||||
StoredWalletDailyUsageLedgerPage, StoredWalletSnapshot, WalletLookupKey, WalletMutationOutcome,
|
StoredWalletDailyUsageLedgerPage, StoredWalletSnapshot, WalletLookupKey, WalletMutationOutcome,
|
||||||
WalletReadRepository, WalletWriteRepository,
|
WalletReadRepository, WalletWriteRepository,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
use super::{
|
use super::{
|
||||||
read_decision_trace, read_provider_transport_snapshot, read_request_candidate_trace,
|
read_decision_trace, read_provider_transport_snapshot, read_request_candidate_trace,
|
||||||
AdjustWalletBalanceInput, AdminPaymentOrderListQuery, AdminWalletLedgerQuery,
|
AdjustWalletBalanceInput, AdminPaymentOrderListQuery, AdminRedeemCodeBatchListQuery,
|
||||||
AdminWalletListQuery, AdminWalletRefundRequestListQuery, AnnouncementListQuery,
|
AdminRedeemCodeListQuery, AdminWalletLedgerQuery, AdminWalletListQuery,
|
||||||
CompleteAdminWalletRefundInput, CreateAnnouncementRecord, CreateManualWalletRechargeInput,
|
AdminWalletRefundRequestListQuery, AnnouncementListQuery, CompleteAdminWalletRefundInput,
|
||||||
CreateWalletRechargeOrderInput, CreateWalletRechargeOrderOutcome,
|
CreateAdminRedeemCodeBatchInput, CreateAdminRedeemCodeBatchResult, CreateAnnouncementRecord,
|
||||||
CreateWalletRefundRequestInput, CreateWalletRefundRequestOutcome, CreditAdminPaymentOrderInput,
|
CreateManualWalletRechargeInput, CreateWalletRechargeOrderInput,
|
||||||
DataLayerError, DecisionTrace, FailAdminWalletRefundInput, GatewayDataState,
|
CreateWalletRechargeOrderOutcome, CreateWalletRefundRequestInput,
|
||||||
GatewayProviderTransportSnapshot, LocalVideoTaskReadResponse, ProcessAdminWalletRefundInput,
|
CreateWalletRefundRequestOutcome, CreditAdminPaymentOrderInput, DataLayerError, DecisionTrace,
|
||||||
ProcessPaymentCallbackInput, ProcessPaymentCallbackOutcome, RedisStreamRunner,
|
DeleteAdminRedeemCodeBatchInput, DisableAdminRedeemCodeBatchInput, DisableAdminRedeemCodeInput,
|
||||||
RequestAuditBundle, RequestCandidateTrace, StoredAdminPaymentCallbackPage,
|
FailAdminWalletRefundInput, GatewayDataState, GatewayProviderTransportSnapshot,
|
||||||
StoredAdminPaymentOrder, StoredAdminPaymentOrderPage, StoredAdminWalletLedgerPage,
|
LocalVideoTaskReadResponse, ProcessAdminWalletRefundInput, ProcessPaymentCallbackInput,
|
||||||
|
ProcessPaymentCallbackOutcome, RedeemWalletCodeInput, RedeemWalletCodeOutcome,
|
||||||
|
RedisStreamRunner, RequestAuditBundle, RequestCandidateTrace, StoredAdminPaymentCallbackPage,
|
||||||
|
StoredAdminPaymentOrder, StoredAdminPaymentOrderPage, StoredAdminRedeemCodeBatch,
|
||||||
|
StoredAdminRedeemCodeBatchPage, StoredAdminRedeemCodePage, StoredAdminWalletLedgerPage,
|
||||||
StoredAdminWalletListPage, StoredAdminWalletRefund, StoredAdminWalletRefundPage,
|
StoredAdminWalletListPage, StoredAdminWalletRefund, StoredAdminWalletRefundPage,
|
||||||
StoredAdminWalletRefundRequestPage, StoredAdminWalletTransaction,
|
StoredAdminWalletRefundRequestPage, StoredAdminWalletTransaction,
|
||||||
StoredAdminWalletTransactionPage, StoredAnnouncement, StoredAnnouncementPage,
|
StoredAdminWalletTransactionPage, StoredAnnouncement, StoredAnnouncementPage,
|
||||||
@@ -383,6 +387,36 @@ impl GatewayDataState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn list_admin_redeem_code_batches(
|
||||||
|
&self,
|
||||||
|
query: &AdminRedeemCodeBatchListQuery,
|
||||||
|
) -> Result<StoredAdminRedeemCodeBatchPage, DataLayerError> {
|
||||||
|
match &self.wallet_reader {
|
||||||
|
Some(repository) => repository.list_admin_redeem_code_batches(query).await,
|
||||||
|
None => Ok(StoredAdminRedeemCodeBatchPage::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn find_admin_redeem_code_batch(
|
||||||
|
&self,
|
||||||
|
batch_id: &str,
|
||||||
|
) -> Result<Option<StoredAdminRedeemCodeBatch>, DataLayerError> {
|
||||||
|
match &self.wallet_reader {
|
||||||
|
Some(repository) => repository.find_admin_redeem_code_batch(batch_id).await,
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn list_admin_redeem_codes(
|
||||||
|
&self,
|
||||||
|
query: &AdminRedeemCodeListQuery,
|
||||||
|
) -> Result<StoredAdminRedeemCodePage, DataLayerError> {
|
||||||
|
match &self.wallet_reader {
|
||||||
|
Some(repository) => repository.list_admin_redeem_codes(query).await,
|
||||||
|
None => Ok(StoredAdminRedeemCodePage::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn find_admin_payment_order(
|
pub(crate) async fn find_admin_payment_order(
|
||||||
&self,
|
&self,
|
||||||
order_id: &str,
|
order_id: &str,
|
||||||
@@ -584,6 +618,68 @@ impl GatewayDataState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn create_admin_redeem_code_batch(
|
||||||
|
&self,
|
||||||
|
input: CreateAdminRedeemCodeBatchInput,
|
||||||
|
) -> Result<Option<CreateAdminRedeemCodeBatchResult>, DataLayerError> {
|
||||||
|
match &self.wallet_writer {
|
||||||
|
Some(repository) => repository
|
||||||
|
.create_admin_redeem_code_batch(input)
|
||||||
|
.await
|
||||||
|
.map(Some),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn disable_admin_redeem_code_batch(
|
||||||
|
&self,
|
||||||
|
input: DisableAdminRedeemCodeBatchInput,
|
||||||
|
) -> Result<Option<WalletMutationOutcome<StoredAdminRedeemCodeBatch>>, DataLayerError> {
|
||||||
|
match &self.wallet_writer {
|
||||||
|
Some(repository) => repository
|
||||||
|
.disable_admin_redeem_code_batch(input)
|
||||||
|
.await
|
||||||
|
.map(Some),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn delete_admin_redeem_code_batch(
|
||||||
|
&self,
|
||||||
|
input: DeleteAdminRedeemCodeBatchInput,
|
||||||
|
) -> Result<Option<WalletMutationOutcome<StoredAdminRedeemCodeBatch>>, DataLayerError> {
|
||||||
|
match &self.wallet_writer {
|
||||||
|
Some(repository) => repository
|
||||||
|
.delete_admin_redeem_code_batch(input)
|
||||||
|
.await
|
||||||
|
.map(Some),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn disable_admin_redeem_code(
|
||||||
|
&self,
|
||||||
|
input: DisableAdminRedeemCodeInput,
|
||||||
|
) -> Result<
|
||||||
|
Option<WalletMutationOutcome<aether_data::repository::wallet::StoredAdminRedeemCode>>,
|
||||||
|
DataLayerError,
|
||||||
|
> {
|
||||||
|
match &self.wallet_writer {
|
||||||
|
Some(repository) => repository.disable_admin_redeem_code(input).await.map(Some),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn redeem_wallet_code(
|
||||||
|
&self,
|
||||||
|
input: RedeemWalletCodeInput,
|
||||||
|
) -> Result<Option<RedeemWalletCodeOutcome>, DataLayerError> {
|
||||||
|
match &self.wallet_writer {
|
||||||
|
Some(repository) => repository.redeem_wallet_code(input).await.map(Some),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn settle_usage(
|
pub(crate) async fn settle_usage(
|
||||||
&self,
|
&self,
|
||||||
input: UsageSettlementInput,
|
input: UsageSettlementInput,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ mod callbacks;
|
|||||||
mod orders;
|
mod orders;
|
||||||
#[path = "../../payment/postgres.rs"]
|
#[path = "../../payment/postgres.rs"]
|
||||||
mod payment_postgres;
|
mod payment_postgres;
|
||||||
|
mod redeem_codes;
|
||||||
mod routes;
|
mod routes;
|
||||||
mod shared;
|
mod shared;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,465 @@
|
|||||||
|
use super::{
|
||||||
|
admin_payment_operator_id, build_admin_payment_order_not_found_response,
|
||||||
|
build_admin_payments_backend_unavailable_response, build_admin_payments_bad_request_response,
|
||||||
|
parse_admin_payments_limit, parse_admin_payments_offset,
|
||||||
|
};
|
||||||
|
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||||
|
use crate::handlers::admin::shared::{
|
||||||
|
attach_admin_audit_response, query_param_value, unix_secs_to_rfc3339,
|
||||||
|
};
|
||||||
|
use crate::GatewayError;
|
||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
http,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct AdminRedeemCodeBatchCreateRequest {
|
||||||
|
pub(super) name: String,
|
||||||
|
pub(super) amount_usd: f64,
|
||||||
|
pub(super) total_count: usize,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(super) expires_at: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(super) description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_required_text(
|
||||||
|
value: &str,
|
||||||
|
field_name: &str,
|
||||||
|
max_len: usize,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err(format!("{field_name} 不能为空"));
|
||||||
|
}
|
||||||
|
if trimmed.chars().count() > max_len {
|
||||||
|
return Err(format!("{field_name} 长度不能超过 {max_len}"));
|
||||||
|
}
|
||||||
|
Ok(trimmed.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_optional_text(
|
||||||
|
value: Option<String>,
|
||||||
|
field_name: &str,
|
||||||
|
max_len: usize,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
match value {
|
||||||
|
Some(value) => {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
if trimmed.chars().count() > max_len {
|
||||||
|
return Err(format!("{field_name} 长度不能超过 {max_len}"));
|
||||||
|
}
|
||||||
|
Ok(Some(trimmed.to_string()))
|
||||||
|
}
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_batch_id_from_detail_path(path: &str) -> Option<String> {
|
||||||
|
path.trim_end_matches('/')
|
||||||
|
.strip_prefix("/api/admin/payments/redeem-codes/batches/")?
|
||||||
|
.split('/')
|
||||||
|
.next()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty() && !value.contains('/'))
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_batch_id_from_suffix_path(path: &str, suffix: &str) -> Option<String> {
|
||||||
|
path.trim_end_matches('/')
|
||||||
|
.strip_prefix("/api/admin/payments/redeem-codes/batches/")?
|
||||||
|
.strip_suffix(suffix)
|
||||||
|
.map(|value| value.trim().trim_matches('/').to_string())
|
||||||
|
.filter(|value| !value.is_empty() && !value.contains('/'))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_code_id_from_suffix_path(path: &str, suffix: &str) -> Option<String> {
|
||||||
|
path.trim_end_matches('/')
|
||||||
|
.strip_prefix("/api/admin/payments/redeem-codes/codes/")?
|
||||||
|
.strip_suffix(suffix)
|
||||||
|
.map(|value| value.trim().trim_matches('/').to_string())
|
||||||
|
.filter(|value| !value.is_empty() && !value.contains('/'))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_batch_expires_at(value: Option<String>) -> Result<Option<u64>, String> {
|
||||||
|
let Some(value) = normalize_optional_text(value, "expires_at", 64)? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let parsed = chrono::DateTime::parse_from_rfc3339(&value)
|
||||||
|
.map_err(|_| "expires_at 必须为 ISO8601 时间".to_string())?;
|
||||||
|
Ok(Some(parsed.timestamp().max(0) as u64))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_redeem_code_not_found_response(detail: &str) -> Response<Body> {
|
||||||
|
(
|
||||||
|
http::StatusCode::NOT_FOUND,
|
||||||
|
Json(json!({ "detail": detail })),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_batch_payload(
|
||||||
|
batch: &aether_data::repository::wallet::StoredAdminRedeemCodeBatch,
|
||||||
|
) -> serde_json::Value {
|
||||||
|
json!({
|
||||||
|
"id": batch.id,
|
||||||
|
"name": batch.name,
|
||||||
|
"amount_usd": batch.amount_usd,
|
||||||
|
"currency": batch.currency,
|
||||||
|
"balance_bucket": batch.balance_bucket,
|
||||||
|
"total_count": batch.total_count,
|
||||||
|
"redeemed_count": batch.redeemed_count,
|
||||||
|
"active_count": batch.active_count,
|
||||||
|
"status": batch.status,
|
||||||
|
"description": batch.description,
|
||||||
|
"created_by": batch.created_by,
|
||||||
|
"expires_at": batch.expires_at_unix_secs.and_then(unix_secs_to_rfc3339),
|
||||||
|
"created_at": unix_secs_to_rfc3339(batch.created_at_unix_ms),
|
||||||
|
"updated_at": unix_secs_to_rfc3339(batch.updated_at_unix_secs),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_code_payload(
|
||||||
|
code: &aether_data::repository::wallet::StoredAdminRedeemCode,
|
||||||
|
) -> serde_json::Value {
|
||||||
|
json!({
|
||||||
|
"id": code.id,
|
||||||
|
"batch_id": code.batch_id,
|
||||||
|
"batch_name": code.batch_name,
|
||||||
|
"code_prefix": code.code_prefix,
|
||||||
|
"code_suffix": code.code_suffix,
|
||||||
|
"masked_code": code.masked_code,
|
||||||
|
"status": code.status,
|
||||||
|
"redeemed_by_user_id": code.redeemed_by_user_id,
|
||||||
|
"redeemed_by_user_name": code.redeemed_by_user_name,
|
||||||
|
"redeemed_wallet_id": code.redeemed_wallet_id,
|
||||||
|
"redeemed_payment_order_id": code.redeemed_payment_order_id,
|
||||||
|
"redeemed_order_no": code.redeemed_order_no,
|
||||||
|
"redeemed_at": code.redeemed_at_unix_secs.and_then(unix_secs_to_rfc3339),
|
||||||
|
"disabled_by": code.disabled_by,
|
||||||
|
"expires_at": code.expires_at_unix_secs.and_then(unix_secs_to_rfc3339),
|
||||||
|
"created_at": unix_secs_to_rfc3339(code.created_at_unix_ms),
|
||||||
|
"updated_at": unix_secs_to_rfc3339(code.updated_at_unix_secs),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn maybe_build_local_admin_redeem_codes_response(
|
||||||
|
state: &AdminAppState<'_>,
|
||||||
|
request_context: &AdminRequestContext<'_>,
|
||||||
|
request_body: Option<&axum::body::Bytes>,
|
||||||
|
route_kind: Option<&str>,
|
||||||
|
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||||
|
match route_kind {
|
||||||
|
Some("list_redeem_code_batches") => Ok(Some(
|
||||||
|
build_admin_redeem_code_batches_response(state, request_context).await?,
|
||||||
|
)),
|
||||||
|
Some("create_redeem_code_batch") => Ok(Some(
|
||||||
|
build_admin_create_redeem_code_batch_response(state, request_context, request_body)
|
||||||
|
.await?,
|
||||||
|
)),
|
||||||
|
Some("get_redeem_code_batch") => Ok(Some(
|
||||||
|
build_admin_redeem_code_batch_detail_response(state, request_context).await?,
|
||||||
|
)),
|
||||||
|
Some("list_redeem_codes") => Ok(Some(
|
||||||
|
build_admin_redeem_codes_response(state, request_context).await?,
|
||||||
|
)),
|
||||||
|
Some("disable_redeem_code_batch") => Ok(Some(
|
||||||
|
build_admin_disable_redeem_code_batch_response(state, request_context).await?,
|
||||||
|
)),
|
||||||
|
Some("delete_redeem_code_batch") => Ok(Some(
|
||||||
|
build_admin_delete_redeem_code_batch_response(state, request_context).await?,
|
||||||
|
)),
|
||||||
|
Some("disable_redeem_code") => Ok(Some(
|
||||||
|
build_admin_disable_redeem_code_response(state, request_context).await?,
|
||||||
|
)),
|
||||||
|
_ => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn build_admin_redeem_code_batches_response(
|
||||||
|
state: &AdminAppState<'_>,
|
||||||
|
request_context: &AdminRequestContext<'_>,
|
||||||
|
) -> Result<Response<Body>, GatewayError> {
|
||||||
|
let query = request_context.query_string();
|
||||||
|
let limit = match parse_admin_payments_limit(query) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(detail) => return Ok(build_admin_payments_bad_request_response(detail)),
|
||||||
|
};
|
||||||
|
let offset = match parse_admin_payments_offset(query) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(detail) => return Ok(build_admin_payments_bad_request_response(detail)),
|
||||||
|
};
|
||||||
|
let status = query_param_value(query, "status");
|
||||||
|
let (items, total) = state
|
||||||
|
.list_admin_redeem_code_batches(status.as_deref(), limit, offset)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(json!({
|
||||||
|
"items": items.iter().map(build_batch_payload).collect::<Vec<_>>(),
|
||||||
|
"total": total,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
}))
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn build_admin_create_redeem_code_batch_response(
|
||||||
|
state: &AdminAppState<'_>,
|
||||||
|
request_context: &AdminRequestContext<'_>,
|
||||||
|
request_body: Option<&axum::body::Bytes>,
|
||||||
|
) -> Result<Response<Body>, GatewayError> {
|
||||||
|
let Some(body) = request_body else {
|
||||||
|
return Ok(build_admin_payments_bad_request_response("请求体不能为空"));
|
||||||
|
};
|
||||||
|
let payload = match serde_json::from_slice::<AdminRedeemCodeBatchCreateRequest>(body) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_) => {
|
||||||
|
return Ok(build_admin_payments_bad_request_response(
|
||||||
|
"请求数据验证失败",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let name = match normalize_required_text(&payload.name, "name", 120) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(detail) => return Ok(build_admin_payments_bad_request_response(detail)),
|
||||||
|
};
|
||||||
|
if !payload.amount_usd.is_finite() || payload.amount_usd <= 0.0 {
|
||||||
|
return Ok(build_admin_payments_bad_request_response(
|
||||||
|
"amount_usd 必须大于 0",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if payload.total_count == 0 || payload.total_count > 5000 {
|
||||||
|
return Ok(build_admin_payments_bad_request_response(
|
||||||
|
"total_count 必须在 1 到 5000 之间",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let description = match normalize_optional_text(payload.description, "description", 500) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(detail) => return Ok(build_admin_payments_bad_request_response(detail)),
|
||||||
|
};
|
||||||
|
let expires_at_unix_secs = match parse_batch_expires_at(payload.expires_at) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(detail) => return Ok(build_admin_payments_bad_request_response(detail)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(result) = state
|
||||||
|
.admin_create_redeem_code_batch(
|
||||||
|
aether_data::repository::wallet::CreateAdminRedeemCodeBatchInput {
|
||||||
|
name,
|
||||||
|
amount_usd: payload.amount_usd,
|
||||||
|
currency: "USD".to_string(),
|
||||||
|
balance_bucket: "gift".to_string(),
|
||||||
|
total_count: payload.total_count,
|
||||||
|
expires_at_unix_secs,
|
||||||
|
description,
|
||||||
|
created_by: admin_payment_operator_id(request_context),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
else {
|
||||||
|
return Ok(build_admin_payments_backend_unavailable_response(
|
||||||
|
"Redeem code batch backend unavailable",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(attach_admin_audit_response(
|
||||||
|
Json(json!({
|
||||||
|
"batch": build_batch_payload(&result.batch),
|
||||||
|
"codes": result
|
||||||
|
.codes
|
||||||
|
.iter()
|
||||||
|
.map(|code| json!({
|
||||||
|
"id": code.code_id,
|
||||||
|
"code": code.code,
|
||||||
|
"masked_code": code.masked_code,
|
||||||
|
}))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
}))
|
||||||
|
.into_response(),
|
||||||
|
"admin_redeem_code_batch_created",
|
||||||
|
"create_redeem_code_batch",
|
||||||
|
"redeem_code_batch",
|
||||||
|
&result.batch.id,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn build_admin_redeem_code_batch_detail_response(
|
||||||
|
state: &AdminAppState<'_>,
|
||||||
|
request_context: &AdminRequestContext<'_>,
|
||||||
|
) -> Result<Response<Body>, GatewayError> {
|
||||||
|
let Some(batch_id) = parse_batch_id_from_detail_path(request_context.path()) else {
|
||||||
|
return Ok(build_admin_payment_order_not_found_response());
|
||||||
|
};
|
||||||
|
match state.read_admin_redeem_code_batch(&batch_id).await? {
|
||||||
|
crate::AdminWalletMutationOutcome::Applied(batch) => {
|
||||||
|
Ok(Json(json!({ "batch": build_batch_payload(&batch) })).into_response())
|
||||||
|
}
|
||||||
|
crate::AdminWalletMutationOutcome::NotFound => Ok(build_redeem_code_not_found_response(
|
||||||
|
"Redeem code batch not found",
|
||||||
|
)),
|
||||||
|
crate::AdminWalletMutationOutcome::Invalid(detail) => {
|
||||||
|
Ok(build_admin_payments_bad_request_response(detail))
|
||||||
|
}
|
||||||
|
crate::AdminWalletMutationOutcome::Unavailable => {
|
||||||
|
Ok(build_admin_payments_backend_unavailable_response(
|
||||||
|
"Redeem code batch backend unavailable",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn build_admin_redeem_codes_response(
|
||||||
|
state: &AdminAppState<'_>,
|
||||||
|
request_context: &AdminRequestContext<'_>,
|
||||||
|
) -> Result<Response<Body>, GatewayError> {
|
||||||
|
let Some(batch_id) = parse_batch_id_from_suffix_path(request_context.path(), "/codes") else {
|
||||||
|
return Ok(build_admin_payment_order_not_found_response());
|
||||||
|
};
|
||||||
|
let query = request_context.query_string();
|
||||||
|
let limit = match parse_admin_payments_limit(query) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(detail) => return Ok(build_admin_payments_bad_request_response(detail)),
|
||||||
|
};
|
||||||
|
let offset = match parse_admin_payments_offset(query) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(detail) => return Ok(build_admin_payments_bad_request_response(detail)),
|
||||||
|
};
|
||||||
|
let status = query_param_value(query, "status");
|
||||||
|
match state.read_admin_redeem_code_batch(&batch_id).await? {
|
||||||
|
crate::AdminWalletMutationOutcome::Applied(batch) => {
|
||||||
|
let page = state
|
||||||
|
.list_admin_redeem_codes(&batch_id, status.as_deref(), limit, offset)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(json!({
|
||||||
|
"batch": build_batch_payload(&batch),
|
||||||
|
"items": page.items.iter().map(build_code_payload).collect::<Vec<_>>(),
|
||||||
|
"total": page.total,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
}))
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
crate::AdminWalletMutationOutcome::NotFound => Ok(build_redeem_code_not_found_response(
|
||||||
|
"Redeem code batch not found",
|
||||||
|
)),
|
||||||
|
crate::AdminWalletMutationOutcome::Invalid(detail) => {
|
||||||
|
Ok(build_admin_payments_bad_request_response(detail))
|
||||||
|
}
|
||||||
|
crate::AdminWalletMutationOutcome::Unavailable => {
|
||||||
|
Ok(build_admin_payments_backend_unavailable_response(
|
||||||
|
"Redeem code batch backend unavailable",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn build_admin_disable_redeem_code_batch_response(
|
||||||
|
state: &AdminAppState<'_>,
|
||||||
|
request_context: &AdminRequestContext<'_>,
|
||||||
|
) -> Result<Response<Body>, GatewayError> {
|
||||||
|
let Some(batch_id) = parse_batch_id_from_suffix_path(request_context.path(), "/disable") else {
|
||||||
|
return Ok(build_admin_payment_order_not_found_response());
|
||||||
|
};
|
||||||
|
match state
|
||||||
|
.admin_disable_redeem_code_batch(
|
||||||
|
&batch_id,
|
||||||
|
admin_payment_operator_id(request_context).as_deref(),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
crate::AdminWalletMutationOutcome::Applied(batch) => Ok(attach_admin_audit_response(
|
||||||
|
Json(json!({ "batch": build_batch_payload(&batch) })).into_response(),
|
||||||
|
"admin_redeem_code_batch_disabled",
|
||||||
|
"disable_redeem_code_batch",
|
||||||
|
"redeem_code_batch",
|
||||||
|
&batch_id,
|
||||||
|
)),
|
||||||
|
crate::AdminWalletMutationOutcome::NotFound => Ok(build_redeem_code_not_found_response(
|
||||||
|
"Redeem code batch not found",
|
||||||
|
)),
|
||||||
|
crate::AdminWalletMutationOutcome::Invalid(detail) => {
|
||||||
|
Ok(build_admin_payments_bad_request_response(detail))
|
||||||
|
}
|
||||||
|
crate::AdminWalletMutationOutcome::Unavailable => {
|
||||||
|
Ok(build_admin_payments_backend_unavailable_response(
|
||||||
|
"Redeem code batch backend unavailable",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn build_admin_delete_redeem_code_batch_response(
|
||||||
|
state: &AdminAppState<'_>,
|
||||||
|
request_context: &AdminRequestContext<'_>,
|
||||||
|
) -> Result<Response<Body>, GatewayError> {
|
||||||
|
let Some(batch_id) = parse_batch_id_from_suffix_path(request_context.path(), "/delete") else {
|
||||||
|
return Ok(build_admin_payment_order_not_found_response());
|
||||||
|
};
|
||||||
|
match state
|
||||||
|
.admin_delete_redeem_code_batch(
|
||||||
|
&batch_id,
|
||||||
|
admin_payment_operator_id(request_context).as_deref(),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
crate::AdminWalletMutationOutcome::Applied(batch) => Ok(attach_admin_audit_response(
|
||||||
|
Json(json!({ "batch": build_batch_payload(&batch) })).into_response(),
|
||||||
|
"admin_redeem_code_batch_deleted",
|
||||||
|
"delete_redeem_code_batch",
|
||||||
|
"redeem_code_batch",
|
||||||
|
&batch_id,
|
||||||
|
)),
|
||||||
|
crate::AdminWalletMutationOutcome::NotFound => Ok(build_redeem_code_not_found_response(
|
||||||
|
"Redeem code batch not found",
|
||||||
|
)),
|
||||||
|
crate::AdminWalletMutationOutcome::Invalid(detail) => {
|
||||||
|
Ok(build_admin_payments_bad_request_response(detail))
|
||||||
|
}
|
||||||
|
crate::AdminWalletMutationOutcome::Unavailable => {
|
||||||
|
Ok(build_admin_payments_backend_unavailable_response(
|
||||||
|
"Redeem code batch backend unavailable",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn build_admin_disable_redeem_code_response(
|
||||||
|
state: &AdminAppState<'_>,
|
||||||
|
request_context: &AdminRequestContext<'_>,
|
||||||
|
) -> Result<Response<Body>, GatewayError> {
|
||||||
|
let Some(code_id) = parse_code_id_from_suffix_path(request_context.path(), "/disable") else {
|
||||||
|
return Ok(build_admin_payment_order_not_found_response());
|
||||||
|
};
|
||||||
|
match state
|
||||||
|
.admin_disable_redeem_code(
|
||||||
|
&code_id,
|
||||||
|
admin_payment_operator_id(request_context).as_deref(),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
crate::AdminWalletMutationOutcome::Applied(code) => Ok(attach_admin_audit_response(
|
||||||
|
Json(json!({ "code": build_code_payload(&code) })).into_response(),
|
||||||
|
"admin_redeem_code_disabled",
|
||||||
|
"disable_redeem_code",
|
||||||
|
"redeem_code",
|
||||||
|
&code_id,
|
||||||
|
)),
|
||||||
|
crate::AdminWalletMutationOutcome::NotFound => Ok(build_redeem_code_not_found_response(
|
||||||
|
"Redeem code not found",
|
||||||
|
)),
|
||||||
|
crate::AdminWalletMutationOutcome::Invalid(detail) => {
|
||||||
|
Ok(build_admin_payments_bad_request_response(detail))
|
||||||
|
}
|
||||||
|
crate::AdminWalletMutationOutcome::Unavailable => Ok(
|
||||||
|
build_admin_payments_backend_unavailable_response("Redeem code backend unavailable"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ use super::{
|
|||||||
build_admin_payments_data_unavailable_response,
|
build_admin_payments_data_unavailable_response,
|
||||||
callbacks::maybe_build_local_admin_payment_callbacks_response,
|
callbacks::maybe_build_local_admin_payment_callbacks_response,
|
||||||
orders::maybe_build_local_admin_payment_orders_response,
|
orders::maybe_build_local_admin_payment_orders_response,
|
||||||
|
redeem_codes::maybe_build_local_admin_redeem_codes_response,
|
||||||
};
|
};
|
||||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||||
use crate::GatewayError;
|
use crate::GatewayError;
|
||||||
@@ -44,7 +45,30 @@ pub(super) async fn maybe_build_local_admin_payments_response(
|
|||||||
&& path.ends_with("/fail")
|
&& path.ends_with("/fail")
|
||||||
&& path.matches('/').count() == 6)
|
&& path.matches('/').count() == 6)
|
||||||
|| (request_context.method() == http::Method::GET
|
|| (request_context.method() == http::Method::GET
|
||||||
&& path == "/api/admin/payments/callbacks");
|
&& path == "/api/admin/payments/callbacks")
|
||||||
|
|| (request_context.method() == http::Method::GET
|
||||||
|
&& path == "/api/admin/payments/redeem-codes/batches")
|
||||||
|
|| (request_context.method() == http::Method::POST
|
||||||
|
&& path == "/api/admin/payments/redeem-codes/batches")
|
||||||
|
|| (request_context.method() == http::Method::GET
|
||||||
|
&& path.starts_with("/api/admin/payments/redeem-codes/batches/")
|
||||||
|
&& path.matches('/').count() == 6)
|
||||||
|
|| (request_context.method() == http::Method::GET
|
||||||
|
&& path.starts_with("/api/admin/payments/redeem-codes/batches/")
|
||||||
|
&& path.ends_with("/codes")
|
||||||
|
&& path.matches('/').count() == 7)
|
||||||
|
|| (request_context.method() == http::Method::POST
|
||||||
|
&& path.starts_with("/api/admin/payments/redeem-codes/batches/")
|
||||||
|
&& path.ends_with("/disable")
|
||||||
|
&& path.matches('/').count() == 7)
|
||||||
|
|| (request_context.method() == http::Method::POST
|
||||||
|
&& path.starts_with("/api/admin/payments/redeem-codes/batches/")
|
||||||
|
&& path.ends_with("/delete")
|
||||||
|
&& path.matches('/').count() == 7)
|
||||||
|
|| (request_context.method() == http::Method::POST
|
||||||
|
&& path.starts_with("/api/admin/payments/redeem-codes/codes/")
|
||||||
|
&& path.ends_with("/disable")
|
||||||
|
&& path.matches('/').count() == 7);
|
||||||
|
|
||||||
if !is_payments_route {
|
if !is_payments_route {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -67,6 +91,16 @@ pub(super) async fn maybe_build_local_admin_payments_response(
|
|||||||
{
|
{
|
||||||
return Ok(Some(response));
|
return Ok(Some(response));
|
||||||
}
|
}
|
||||||
|
if let Some(response) = maybe_build_local_admin_redeem_codes_response(
|
||||||
|
state,
|
||||||
|
request_context,
|
||||||
|
request_body,
|
||||||
|
route_kind,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
return Ok(Some(response));
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Some(build_admin_payments_data_unavailable_response()))
|
Ok(Some(build_admin_payments_data_unavailable_response()))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use super::super::usage_helpers::admin_monitoring_usage_is_error;
|
||||||
use crate::handlers::admin::request::AdminAppState;
|
use crate::handlers::admin::request::AdminAppState;
|
||||||
use crate::handlers::admin::shared::{provider_key_health_summary, unix_secs_to_rfc3339};
|
use crate::handlers::admin::shared::{provider_key_health_summary, unix_secs_to_rfc3339};
|
||||||
use crate::GatewayError;
|
use crate::GatewayError;
|
||||||
@@ -156,7 +157,10 @@ pub(super) async fn build_admin_monitoring_resilience_snapshot(
|
|||||||
created_until_unix_secs: (now.timestamp().max(0) as u64).saturating_add(1),
|
created_until_unix_secs: (now.timestamp().max(0) as u64).saturating_add(1),
|
||||||
limit: None,
|
limit: None,
|
||||||
})
|
})
|
||||||
.await?;
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.filter(admin_monitoring_usage_is_error)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
recent_usage_errors.sort_by_key(|item| std::cmp::Reverse(item.created_at_unix_ms));
|
recent_usage_errors.sort_by_key(|item| std::cmp::Reverse(item.created_at_unix_ms));
|
||||||
|
|
||||||
let total_errors = recent_usage_errors.len();
|
let total_errors = recent_usage_errors.len();
|
||||||
|
|||||||
@@ -277,6 +277,100 @@ impl<'a> AdminAppState<'a> {
|
|||||||
self.app.admin_fail_payment_order(order_id).await
|
self.app.admin_fail_payment_order(order_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn list_admin_redeem_code_batches(
|
||||||
|
&self,
|
||||||
|
status: Option<&str>,
|
||||||
|
limit: usize,
|
||||||
|
offset: usize,
|
||||||
|
) -> Result<
|
||||||
|
(
|
||||||
|
Vec<aether_data::repository::wallet::StoredAdminRedeemCodeBatch>,
|
||||||
|
u64,
|
||||||
|
),
|
||||||
|
GatewayError,
|
||||||
|
> {
|
||||||
|
self.app
|
||||||
|
.list_admin_redeem_code_batches(status, limit, offset)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn read_admin_redeem_code_batch(
|
||||||
|
&self,
|
||||||
|
batch_id: &str,
|
||||||
|
) -> Result<
|
||||||
|
crate::AdminWalletMutationOutcome<
|
||||||
|
aether_data::repository::wallet::StoredAdminRedeemCodeBatch,
|
||||||
|
>,
|
||||||
|
GatewayError,
|
||||||
|
> {
|
||||||
|
self.app.read_admin_redeem_code_batch(batch_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn list_admin_redeem_codes(
|
||||||
|
&self,
|
||||||
|
batch_id: &str,
|
||||||
|
status: Option<&str>,
|
||||||
|
limit: usize,
|
||||||
|
offset: usize,
|
||||||
|
) -> Result<aether_data::repository::wallet::StoredAdminRedeemCodePage, GatewayError> {
|
||||||
|
self.app
|
||||||
|
.list_admin_redeem_codes(batch_id, status, limit, offset)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn admin_create_redeem_code_batch(
|
||||||
|
&self,
|
||||||
|
input: aether_data::repository::wallet::CreateAdminRedeemCodeBatchInput,
|
||||||
|
) -> Result<
|
||||||
|
Option<aether_data::repository::wallet::CreateAdminRedeemCodeBatchResult>,
|
||||||
|
GatewayError,
|
||||||
|
> {
|
||||||
|
self.app.admin_create_redeem_code_batch(input).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn admin_disable_redeem_code_batch(
|
||||||
|
&self,
|
||||||
|
batch_id: &str,
|
||||||
|
operator_id: Option<&str>,
|
||||||
|
) -> Result<
|
||||||
|
crate::AdminWalletMutationOutcome<
|
||||||
|
aether_data::repository::wallet::StoredAdminRedeemCodeBatch,
|
||||||
|
>,
|
||||||
|
GatewayError,
|
||||||
|
> {
|
||||||
|
self.app
|
||||||
|
.admin_disable_redeem_code_batch(batch_id, operator_id)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn admin_delete_redeem_code_batch(
|
||||||
|
&self,
|
||||||
|
batch_id: &str,
|
||||||
|
operator_id: Option<&str>,
|
||||||
|
) -> Result<
|
||||||
|
crate::AdminWalletMutationOutcome<
|
||||||
|
aether_data::repository::wallet::StoredAdminRedeemCodeBatch,
|
||||||
|
>,
|
||||||
|
GatewayError,
|
||||||
|
> {
|
||||||
|
self.app
|
||||||
|
.admin_delete_redeem_code_batch(batch_id, operator_id)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn admin_disable_redeem_code(
|
||||||
|
&self,
|
||||||
|
code_id: &str,
|
||||||
|
operator_id: Option<&str>,
|
||||||
|
) -> Result<
|
||||||
|
crate::AdminWalletMutationOutcome<aether_data::repository::wallet::StoredAdminRedeemCode>,
|
||||||
|
GatewayError,
|
||||||
|
> {
|
||||||
|
self.app
|
||||||
|
.admin_disable_redeem_code(code_id, operator_id)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn admin_adjust_wallet_balance(
|
pub(crate) async fn admin_adjust_wallet_balance(
|
||||||
&self,
|
&self,
|
||||||
wallet_id: &str,
|
wallet_id: &str,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ use axum::{body::Body, http, response::Response};
|
|||||||
|
|
||||||
pub(super) use super::{build_auth_error_response, AppState, GatewayPublicRequestContext};
|
pub(super) use super::{build_auth_error_response, AppState, GatewayPublicRequestContext};
|
||||||
|
|
||||||
|
#[path = "payment/gateway.rs"]
|
||||||
|
pub(super) mod payment_gateway;
|
||||||
#[path = "payment/postgres.rs"]
|
#[path = "payment/postgres.rs"]
|
||||||
mod payment_postgres;
|
mod payment_postgres;
|
||||||
#[path = "payment/route.rs"]
|
#[path = "payment/route.rs"]
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use super::payment_shared::{
|
||||||
|
normalize_payment_callback_request, payment_callback_signature_matches,
|
||||||
|
NormalizedPaymentCallbackRequest, PaymentCallbackRequest,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(crate) struct CreateCheckoutSessionInput {
|
||||||
|
pub(crate) order_no: String,
|
||||||
|
pub(crate) amount_usd: f64,
|
||||||
|
pub(crate) expires_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct CreateCheckoutSessionOutput {
|
||||||
|
pub(crate) gateway_order_id: String,
|
||||||
|
pub(crate) gateway_response: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct VerifyCallbackInput<'a> {
|
||||||
|
pub(crate) secret: &'a str,
|
||||||
|
pub(crate) signature: &'a str,
|
||||||
|
pub(crate) payload: PaymentCallbackRequest,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct VerifyCallbackOutcome {
|
||||||
|
pub(crate) normalized_payload: NormalizedPaymentCallbackRequest,
|
||||||
|
pub(crate) signature_valid: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) trait PaymentGatewayAdapter: Sync {
|
||||||
|
fn payment_method(&self) -> &'static str;
|
||||||
|
|
||||||
|
fn create_checkout_session(
|
||||||
|
&self,
|
||||||
|
input: &CreateCheckoutSessionInput,
|
||||||
|
) -> Result<CreateCheckoutSessionOutput, String>;
|
||||||
|
|
||||||
|
fn verify_callback(
|
||||||
|
&self,
|
||||||
|
input: VerifyCallbackInput<'_>,
|
||||||
|
) -> Result<VerifyCallbackOutcome, String> {
|
||||||
|
let normalized_payload = normalize_payment_callback_request(input.payload)
|
||||||
|
.map_err(|detail: &'static str| detail.to_string())?;
|
||||||
|
let signature_valid = payment_callback_signature_matches(
|
||||||
|
&normalized_payload.payload,
|
||||||
|
input.signature,
|
||||||
|
input.secret,
|
||||||
|
)?;
|
||||||
|
Ok(VerifyCallbackOutcome {
|
||||||
|
normalized_payload,
|
||||||
|
signature_valid,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct PaymentGatewayRegistry;
|
||||||
|
|
||||||
|
impl PaymentGatewayRegistry {
|
||||||
|
pub(crate) fn get(payment_method: &str) -> Option<&'static dyn PaymentGatewayAdapter> {
|
||||||
|
match payment_method.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"alipay" => Some(&ALIPAY_ADAPTER),
|
||||||
|
"wechat" => Some(&WECHAT_ADAPTER),
|
||||||
|
"manual" => Some(&MANUAL_ADAPTER),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AlipayAdapter;
|
||||||
|
struct WechatAdapter;
|
||||||
|
struct ManualAdapter;
|
||||||
|
|
||||||
|
static ALIPAY_ADAPTER: AlipayAdapter = AlipayAdapter;
|
||||||
|
static WECHAT_ADAPTER: WechatAdapter = WechatAdapter;
|
||||||
|
static MANUAL_ADAPTER: ManualAdapter = ManualAdapter;
|
||||||
|
|
||||||
|
impl PaymentGatewayAdapter for AlipayAdapter {
|
||||||
|
fn payment_method(&self) -> &'static str {
|
||||||
|
"alipay"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_checkout_session(
|
||||||
|
&self,
|
||||||
|
input: &CreateCheckoutSessionInput,
|
||||||
|
) -> Result<CreateCheckoutSessionOutput, String> {
|
||||||
|
let expires_at = input.expires_at.to_rfc3339();
|
||||||
|
let gateway_order_id = format!("ali_{}", input.order_no);
|
||||||
|
Ok(CreateCheckoutSessionOutput {
|
||||||
|
gateway_order_id: gateway_order_id.clone(),
|
||||||
|
gateway_response: json!({
|
||||||
|
"gateway": self.payment_method(),
|
||||||
|
"display_name": "支付宝",
|
||||||
|
"gateway_order_id": gateway_order_id,
|
||||||
|
"payment_url": format!("/pay/mock/alipay/{}", input.order_no),
|
||||||
|
"qr_code": format!("mock://alipay/{}", input.order_no),
|
||||||
|
"expires_at": expires_at,
|
||||||
|
"amount_usd": input.amount_usd,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PaymentGatewayAdapter for WechatAdapter {
|
||||||
|
fn payment_method(&self) -> &'static str {
|
||||||
|
"wechat"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_checkout_session(
|
||||||
|
&self,
|
||||||
|
input: &CreateCheckoutSessionInput,
|
||||||
|
) -> Result<CreateCheckoutSessionOutput, String> {
|
||||||
|
let expires_at = input.expires_at.to_rfc3339();
|
||||||
|
let gateway_order_id = format!("wx_{}", input.order_no);
|
||||||
|
Ok(CreateCheckoutSessionOutput {
|
||||||
|
gateway_order_id: gateway_order_id.clone(),
|
||||||
|
gateway_response: json!({
|
||||||
|
"gateway": self.payment_method(),
|
||||||
|
"display_name": "微信支付",
|
||||||
|
"gateway_order_id": gateway_order_id,
|
||||||
|
"payment_url": format!("/pay/mock/wechat/{}", input.order_no),
|
||||||
|
"qr_code": format!("mock://wechat/{}", input.order_no),
|
||||||
|
"expires_at": expires_at,
|
||||||
|
"amount_usd": input.amount_usd,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PaymentGatewayAdapter for ManualAdapter {
|
||||||
|
fn payment_method(&self) -> &'static str {
|
||||||
|
"manual"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_checkout_session(
|
||||||
|
&self,
|
||||||
|
input: &CreateCheckoutSessionInput,
|
||||||
|
) -> Result<CreateCheckoutSessionOutput, String> {
|
||||||
|
let expires_at = input.expires_at.to_rfc3339();
|
||||||
|
let gateway_order_id = format!("manual_{}", input.order_no);
|
||||||
|
Ok(CreateCheckoutSessionOutput {
|
||||||
|
gateway_order_id: gateway_order_id.clone(),
|
||||||
|
gateway_response: json!({
|
||||||
|
"gateway": self.payment_method(),
|
||||||
|
"display_name": "人工打款",
|
||||||
|
"gateway_order_id": gateway_order_id,
|
||||||
|
"payment_url": serde_json::Value::Null,
|
||||||
|
"qr_code": serde_json::Value::Null,
|
||||||
|
"instructions": "请线下确认到账后由管理员处理",
|
||||||
|
"expires_at": expires_at,
|
||||||
|
"amount_usd": input.amount_usd,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{CreateCheckoutSessionInput, PaymentGatewayRegistry};
|
||||||
|
use chrono::Utc;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registry_resolves_builtin_mock_adapters() {
|
||||||
|
assert!(PaymentGatewayRegistry::get("alipay").is_some());
|
||||||
|
assert!(PaymentGatewayRegistry::get("wechat").is_some());
|
||||||
|
assert!(PaymentGatewayRegistry::get("manual").is_some());
|
||||||
|
assert!(PaymentGatewayRegistry::get("unknown").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builtin_mock_checkout_payloads_keep_existing_frontend_keys() {
|
||||||
|
let adapter = PaymentGatewayRegistry::get("wechat").expect("adapter should exist");
|
||||||
|
let checkout = adapter
|
||||||
|
.create_checkout_session(&CreateCheckoutSessionInput {
|
||||||
|
order_no: "po_test".to_string(),
|
||||||
|
amount_usd: 12.5,
|
||||||
|
expires_at: Utc::now(),
|
||||||
|
})
|
||||||
|
.expect("checkout should build");
|
||||||
|
let payload = checkout
|
||||||
|
.gateway_response
|
||||||
|
.as_object()
|
||||||
|
.expect("gateway response should be object");
|
||||||
|
|
||||||
|
for key in ["gateway_order_id", "payment_url", "qr_code", "expires_at"] {
|
||||||
|
assert!(
|
||||||
|
payload.contains_key(key),
|
||||||
|
"mock checkout payload should contain {key}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
use axum::{body::Body, http, response::Response};
|
use axum::{body::Body, http, response::Response};
|
||||||
|
|
||||||
|
use super::payment_gateway::{PaymentGatewayRegistry, VerifyCallbackInput};
|
||||||
use super::payment_shared::{
|
use super::payment_shared::{
|
||||||
normalize_payment_callback_request, payment_callback_payment_method_from_path,
|
payment_callback_payment_method_from_path, payment_callback_secret, PaymentCallbackRequest,
|
||||||
payment_callback_secret, payment_callback_signature_matches, PaymentCallbackRequest,
|
|
||||||
PAYMENT_CALLBACK_SIGNATURE_HEADER, PAYMENT_CALLBACK_TOKEN_HEADER,
|
PAYMENT_CALLBACK_SIGNATURE_HEADER, PAYMENT_CALLBACK_TOKEN_HEADER,
|
||||||
};
|
};
|
||||||
use super::{
|
use super::{
|
||||||
@@ -72,16 +72,6 @@ pub(super) async fn maybe_build_local_payment_callback_route_response(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let payload = match normalize_payment_callback_request(raw_payload) {
|
|
||||||
Ok(value) => value,
|
|
||||||
Err(detail) => {
|
|
||||||
return Some(build_auth_error_response(
|
|
||||||
http::StatusCode::BAD_REQUEST,
|
|
||||||
detail,
|
|
||||||
false,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let Some(payment_method) =
|
let Some(payment_method) =
|
||||||
payment_callback_payment_method_from_path(&request_context.request_path)
|
payment_callback_payment_method_from_path(&request_context.request_path)
|
||||||
else {
|
else {
|
||||||
@@ -91,17 +81,28 @@ pub(super) async fn maybe_build_local_payment_callback_route_response(
|
|||||||
false,
|
false,
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
let signature_valid =
|
let Some(adapter) = PaymentGatewayRegistry::get(&payment_method) else {
|
||||||
match payment_callback_signature_matches(&payload.payload, &signature, &secret) {
|
return Some(build_auth_error_response(
|
||||||
Ok(value) => value,
|
http::StatusCode::BAD_REQUEST,
|
||||||
Err(err) => {
|
"unsupported payment_method",
|
||||||
return Some(build_auth_error_response(
|
false,
|
||||||
http::StatusCode::INTERNAL_SERVER_ERROR,
|
));
|
||||||
err,
|
};
|
||||||
false,
|
let verified = match adapter.verify_callback(VerifyCallbackInput {
|
||||||
));
|
secret: &secret,
|
||||||
}
|
signature: &signature,
|
||||||
};
|
payload: raw_payload,
|
||||||
|
}) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(err) => {
|
||||||
|
let status = if err == "输入验证失败" {
|
||||||
|
http::StatusCode::BAD_REQUEST
|
||||||
|
} else {
|
||||||
|
http::StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
};
|
||||||
|
return Some(build_auth_error_response(status, err, false));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if state.postgres_pool().is_some() {
|
if state.postgres_pool().is_some() {
|
||||||
return Some(
|
return Some(
|
||||||
@@ -109,8 +110,8 @@ pub(super) async fn maybe_build_local_payment_callback_route_response(
|
|||||||
state,
|
state,
|
||||||
&payment_method,
|
&payment_method,
|
||||||
request_context,
|
request_context,
|
||||||
&payload,
|
&verified.normalized_payload,
|
||||||
signature_valid,
|
verified.signature_valid,
|
||||||
)
|
)
|
||||||
.await,
|
.await,
|
||||||
);
|
);
|
||||||
@@ -122,8 +123,8 @@ pub(super) async fn maybe_build_local_payment_callback_route_response(
|
|||||||
super::payment_test_support::handle_payment_callback_with_test_store(
|
super::payment_test_support::handle_payment_callback_with_test_store(
|
||||||
&payment_method,
|
&payment_method,
|
||||||
request_context,
|
request_context,
|
||||||
&payload,
|
&verified.normalized_payload,
|
||||||
signature_valid,
|
verified.signature_valid,
|
||||||
)
|
)
|
||||||
.await,
|
.await,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,33 +10,33 @@ pub(super) const PAYMENT_CALLBACK_TOKEN_HEADER: &str = "x-payment-callback-token
|
|||||||
pub(super) const PAYMENT_CALLBACK_SIGNATURE_HEADER: &str = "x-payment-callback-signature";
|
pub(super) const PAYMENT_CALLBACK_SIGNATURE_HEADER: &str = "x-payment-callback-signature";
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(super) struct PaymentCallbackRequest {
|
pub(crate) struct PaymentCallbackRequest {
|
||||||
pub(super) callback_key: String,
|
pub(crate) callback_key: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub(super) order_no: Option<String>,
|
pub(crate) order_no: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub(super) gateway_order_id: Option<String>,
|
pub(crate) gateway_order_id: Option<String>,
|
||||||
pub(super) amount_usd: f64,
|
pub(crate) amount_usd: f64,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub(super) pay_amount: Option<f64>,
|
pub(crate) pay_amount: Option<f64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub(super) pay_currency: Option<String>,
|
pub(crate) pay_currency: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub(super) exchange_rate: Option<f64>,
|
pub(crate) exchange_rate: Option<f64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub(super) payload: Option<serde_json::Map<String, serde_json::Value>>,
|
pub(crate) payload: Option<serde_json::Map<String, serde_json::Value>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(super) struct NormalizedPaymentCallbackRequest {
|
pub(crate) struct NormalizedPaymentCallbackRequest {
|
||||||
pub(super) callback_key: String,
|
pub(crate) callback_key: String,
|
||||||
pub(super) order_no: Option<String>,
|
pub(crate) order_no: Option<String>,
|
||||||
pub(super) gateway_order_id: Option<String>,
|
pub(crate) gateway_order_id: Option<String>,
|
||||||
pub(super) amount_usd: f64,
|
pub(crate) amount_usd: f64,
|
||||||
pub(super) pay_amount: Option<f64>,
|
pub(crate) pay_amount: Option<f64>,
|
||||||
pub(super) pay_currency: Option<String>,
|
pub(crate) pay_currency: Option<String>,
|
||||||
pub(super) exchange_rate: Option<f64>,
|
pub(crate) exchange_rate: Option<f64>,
|
||||||
pub(super) payload: serde_json::Value,
|
pub(crate) payload: serde_json::Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn payment_callback_secret() -> Option<String> {
|
pub(super) fn payment_callback_secret() -> Option<String> {
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ mod flow;
|
|||||||
mod reads;
|
mod reads;
|
||||||
#[path = "wallet/recharge.rs"]
|
#[path = "wallet/recharge.rs"]
|
||||||
mod recharge;
|
mod recharge;
|
||||||
|
#[path = "wallet/redeem.rs"]
|
||||||
|
mod redeem;
|
||||||
#[path = "wallet/refunds.rs"]
|
#[path = "wallet/refunds.rs"]
|
||||||
mod refunds;
|
mod refunds;
|
||||||
use self::flow::handle_wallet_flow;
|
use self::flow::handle_wallet_flow;
|
||||||
@@ -39,6 +41,7 @@ use self::recharge::{
|
|||||||
pub(crate) use self::recharge::{
|
pub(crate) use self::recharge::{
|
||||||
sanitize_wallet_gateway_response, wallet_payment_order_payload_from_row,
|
sanitize_wallet_gateway_response, wallet_payment_order_payload_from_row,
|
||||||
};
|
};
|
||||||
|
use self::redeem::handle_wallet_redeem;
|
||||||
use self::refunds::{
|
use self::refunds::{
|
||||||
handle_wallet_create_refund, handle_wallet_refund_detail, handle_wallet_refunds_list,
|
handle_wallet_create_refund, handle_wallet_refund_detail, handle_wallet_refunds_list,
|
||||||
wallet_refund_detail_path_matches,
|
wallet_refund_detail_path_matches,
|
||||||
@@ -153,6 +156,12 @@ pub(super) async fn maybe_build_local_wallet_response(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if decision.route_kind.as_deref() == Some("redeem")
|
||||||
|
&& request_context.request_path == "/api/wallet/redeem"
|
||||||
|
{
|
||||||
|
return Some(handle_wallet_redeem(state, request_context, headers, request_body).await);
|
||||||
|
}
|
||||||
|
|
||||||
if decision.route_kind.as_deref() == Some("list_recharge_orders")
|
if decision.route_kind.as_deref() == Some("list_recharge_orders")
|
||||||
&& request_context.request_path == "/api/wallet/recharge"
|
&& request_context.request_path == "/api/wallet/recharge"
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
use super::super::support_payment::payment_gateway::{
|
||||||
|
CreateCheckoutSessionInput, PaymentGatewayRegistry,
|
||||||
|
};
|
||||||
use super::{
|
use super::{
|
||||||
build_auth_error_response, build_auth_json_response, build_wallet_payload,
|
build_auth_error_response, build_auth_json_response, build_wallet_payload,
|
||||||
build_wallet_recharge_storage_unavailable_response, http, parse_wallet_limit,
|
build_wallet_recharge_storage_unavailable_response, http, parse_wallet_limit,
|
||||||
@@ -75,60 +78,6 @@ fn wallet_build_order_no(now: chrono::DateTime<chrono::Utc>) -> String {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn wallet_checkout_payload(
|
|
||||||
payment_method: &str,
|
|
||||||
order_no: &str,
|
|
||||||
expires_at: chrono::DateTime<chrono::Utc>,
|
|
||||||
) -> Result<(String, serde_json::Value), String> {
|
|
||||||
let expires_at = expires_at.to_rfc3339();
|
|
||||||
match payment_method {
|
|
||||||
"alipay" => {
|
|
||||||
let gateway_order_id = format!("ali_{order_no}");
|
|
||||||
Ok((
|
|
||||||
gateway_order_id.clone(),
|
|
||||||
json!({
|
|
||||||
"gateway": "alipay",
|
|
||||||
"display_name": "支付宝",
|
|
||||||
"gateway_order_id": gateway_order_id,
|
|
||||||
"payment_url": format!("/pay/mock/alipay/{order_no}"),
|
|
||||||
"qr_code": format!("mock://alipay/{order_no}"),
|
|
||||||
"expires_at": expires_at,
|
|
||||||
}),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
"wechat" => {
|
|
||||||
let gateway_order_id = format!("wx_{order_no}");
|
|
||||||
Ok((
|
|
||||||
gateway_order_id.clone(),
|
|
||||||
json!({
|
|
||||||
"gateway": "wechat",
|
|
||||||
"display_name": "微信支付",
|
|
||||||
"gateway_order_id": gateway_order_id,
|
|
||||||
"payment_url": format!("/pay/mock/wechat/{order_no}"),
|
|
||||||
"qr_code": format!("mock://wechat/{order_no}"),
|
|
||||||
"expires_at": expires_at,
|
|
||||||
}),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
"manual" => {
|
|
||||||
let gateway_order_id = format!("manual_{order_no}");
|
|
||||||
Ok((
|
|
||||||
gateway_order_id.clone(),
|
|
||||||
json!({
|
|
||||||
"gateway": "manual",
|
|
||||||
"display_name": "人工打款",
|
|
||||||
"gateway_order_id": gateway_order_id,
|
|
||||||
"payment_url": serde_json::Value::Null,
|
|
||||||
"qr_code": serde_json::Value::Null,
|
|
||||||
"instructions": "请线下确认到账后由管理员处理",
|
|
||||||
"expires_at": expires_at,
|
|
||||||
}),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
_ => Err(format!("unsupported payment_method: {payment_method}")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn wallet_order_id_from_path(request_path: &str) -> Option<String> {
|
fn wallet_order_id_from_path(request_path: &str) -> Option<String> {
|
||||||
let trimmed = request_path.trim_end_matches('/');
|
let trimmed = request_path.trim_end_matches('/');
|
||||||
let order_id = trimmed.strip_prefix("/api/wallet/recharge/")?.trim();
|
let order_id = trimmed.strip_prefix("/api/wallet/recharge/")?.trim();
|
||||||
@@ -357,17 +306,23 @@ pub(super) async fn handle_wallet_create_recharge(
|
|||||||
let order_id = Uuid::new_v4().to_string();
|
let order_id = Uuid::new_v4().to_string();
|
||||||
let order_no = wallet_build_order_no(now);
|
let order_no = wallet_build_order_no(now);
|
||||||
let expires_at = now + chrono::Duration::minutes(30);
|
let expires_at = now + chrono::Duration::minutes(30);
|
||||||
let (gateway_order_id, gateway_response) =
|
let Some(adapter) = PaymentGatewayRegistry::get(&payload.payment_method) else {
|
||||||
match wallet_checkout_payload(&payload.payment_method, &order_no, expires_at) {
|
return build_auth_error_response(
|
||||||
Ok(value) => value,
|
http::StatusCode::BAD_REQUEST,
|
||||||
Err(detail) => {
|
format!("unsupported payment_method: {}", payload.payment_method),
|
||||||
return build_auth_error_response(
|
false,
|
||||||
http::StatusCode::BAD_REQUEST,
|
);
|
||||||
detail,
|
};
|
||||||
false,
|
let checkout = match adapter.create_checkout_session(&CreateCheckoutSessionInput {
|
||||||
);
|
order_no: order_no.clone(),
|
||||||
}
|
amount_usd: payload.amount_usd,
|
||||||
};
|
expires_at,
|
||||||
|
}) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(detail) => {
|
||||||
|
return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false);
|
||||||
|
}
|
||||||
|
};
|
||||||
let order_payload = build_wallet_payment_order_payload(
|
let order_payload = build_wallet_payment_order_payload(
|
||||||
order_id,
|
order_id,
|
||||||
order_no,
|
order_no,
|
||||||
@@ -380,8 +335,8 @@ pub(super) async fn handle_wallet_create_recharge(
|
|||||||
0.0,
|
0.0,
|
||||||
0.0,
|
0.0,
|
||||||
payload.payment_method,
|
payload.payment_method,
|
||||||
Some(gateway_order_id),
|
Some(checkout.gateway_order_id.clone()),
|
||||||
Some(gateway_response.clone()),
|
Some(checkout.gateway_response.clone()),
|
||||||
"pending".to_string(),
|
"pending".to_string(),
|
||||||
Some(now.to_rfc3339()),
|
Some(now.to_rfc3339()),
|
||||||
None,
|
None,
|
||||||
@@ -393,7 +348,7 @@ pub(super) async fn handle_wallet_create_recharge(
|
|||||||
http::StatusCode::OK,
|
http::StatusCode::OK,
|
||||||
json!({
|
json!({
|
||||||
"order": order_payload,
|
"order": order_payload,
|
||||||
"payment_instructions": sanitize_wallet_gateway_response(Some(gateway_response)),
|
"payment_instructions": sanitize_wallet_gateway_response(Some(checkout.gateway_response)),
|
||||||
}),
|
}),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
@@ -405,13 +360,23 @@ pub(super) async fn handle_wallet_create_recharge(
|
|||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
let order_no = wallet_build_order_no(now);
|
let order_no = wallet_build_order_no(now);
|
||||||
let expires_at = now + chrono::Duration::minutes(30);
|
let expires_at = now + chrono::Duration::minutes(30);
|
||||||
let (gateway_order_id, gateway_response) =
|
let Some(adapter) = PaymentGatewayRegistry::get(&payload.payment_method) else {
|
||||||
match wallet_checkout_payload(&payload.payment_method, &order_no, expires_at) {
|
return build_auth_error_response(
|
||||||
Ok(value) => value,
|
http::StatusCode::BAD_REQUEST,
|
||||||
Err(detail) => {
|
format!("unsupported payment_method: {}", payload.payment_method),
|
||||||
return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false);
|
false,
|
||||||
}
|
);
|
||||||
};
|
};
|
||||||
|
let checkout = match adapter.create_checkout_session(&CreateCheckoutSessionInput {
|
||||||
|
order_no: order_no.clone(),
|
||||||
|
amount_usd: payload.amount_usd,
|
||||||
|
expires_at,
|
||||||
|
}) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(detail) => {
|
||||||
|
return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false);
|
||||||
|
}
|
||||||
|
};
|
||||||
let outcome = match state
|
let outcome = match state
|
||||||
.create_wallet_recharge_order(
|
.create_wallet_recharge_order(
|
||||||
aether_data::repository::wallet::CreateWalletRechargeOrderInput {
|
aether_data::repository::wallet::CreateWalletRechargeOrderInput {
|
||||||
@@ -422,8 +387,8 @@ pub(super) async fn handle_wallet_create_recharge(
|
|||||||
pay_currency: payload.pay_currency.clone(),
|
pay_currency: payload.pay_currency.clone(),
|
||||||
exchange_rate: payload.exchange_rate,
|
exchange_rate: payload.exchange_rate,
|
||||||
payment_method: payload.payment_method.clone(),
|
payment_method: payload.payment_method.clone(),
|
||||||
gateway_order_id,
|
gateway_order_id: checkout.gateway_order_id,
|
||||||
gateway_response: gateway_response.clone(),
|
gateway_response: checkout.gateway_response.clone(),
|
||||||
order_no,
|
order_no,
|
||||||
expires_at_unix_secs: expires_at.timestamp().max(0) as u64,
|
expires_at_unix_secs: expires_at.timestamp().max(0) as u64,
|
||||||
},
|
},
|
||||||
@@ -456,7 +421,7 @@ pub(super) async fn handle_wallet_create_recharge(
|
|||||||
http::StatusCode::OK,
|
http::StatusCode::OK,
|
||||||
json!({
|
json!({
|
||||||
"order": order_payload,
|
"order": order_payload,
|
||||||
"payment_instructions": sanitize_wallet_gateway_response(Some(gateway_response)),
|
"payment_instructions": sanitize_wallet_gateway_response(Some(checkout.gateway_response)),
|
||||||
}),
|
}),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
|||||||
136
apps/aether-gateway/src/handlers/public/support/wallet/redeem.rs
Normal file
136
apps/aether-gateway/src/handlers/public/support/wallet/redeem.rs
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
use super::{
|
||||||
|
build_auth_error_response, build_auth_json_response, build_auth_wallet_summary_payload, http,
|
||||||
|
resolve_authenticated_local_user, sanitize_wallet_gateway_response, unix_secs_to_rfc3339,
|
||||||
|
wallet_normalize_optional_string_field, AppState, Body, GatewayPublicRequestContext, Response,
|
||||||
|
};
|
||||||
|
use chrono::Utc;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::json;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct WalletRedeemRequest {
|
||||||
|
code: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wallet_build_redeem_order_no(now: chrono::DateTime<chrono::Utc>) -> String {
|
||||||
|
format!(
|
||||||
|
"po_{}_{}",
|
||||||
|
now.format("%Y%m%d%H%M%S%6f"),
|
||||||
|
&Uuid::new_v4().simple().to_string()[..12]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_wallet_payment_order_payload(
|
||||||
|
record: &aether_data::repository::wallet::StoredAdminPaymentOrder,
|
||||||
|
) -> serde_json::Value {
|
||||||
|
json!({
|
||||||
|
"id": record.id,
|
||||||
|
"order_no": record.order_no,
|
||||||
|
"wallet_id": record.wallet_id,
|
||||||
|
"user_id": record.user_id,
|
||||||
|
"amount_usd": record.amount_usd,
|
||||||
|
"pay_amount": record.pay_amount,
|
||||||
|
"pay_currency": record.pay_currency,
|
||||||
|
"exchange_rate": record.exchange_rate,
|
||||||
|
"refunded_amount_usd": record.refunded_amount_usd,
|
||||||
|
"refundable_amount_usd": record.refundable_amount_usd,
|
||||||
|
"payment_method": record.payment_method,
|
||||||
|
"gateway_order_id": record.gateway_order_id,
|
||||||
|
"gateway_response": sanitize_wallet_gateway_response(record.gateway_response.clone()),
|
||||||
|
"status": record.status,
|
||||||
|
"created_at": unix_secs_to_rfc3339(record.created_at_unix_ms),
|
||||||
|
"paid_at": record.paid_at_unix_secs.and_then(unix_secs_to_rfc3339),
|
||||||
|
"credited_at": record.credited_at_unix_secs.and_then(unix_secs_to_rfc3339),
|
||||||
|
"expires_at": record.expires_at_unix_secs.and_then(unix_secs_to_rfc3339),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn handle_wallet_redeem(
|
||||||
|
state: &AppState,
|
||||||
|
request_context: &GatewayPublicRequestContext,
|
||||||
|
headers: &http::HeaderMap,
|
||||||
|
request_body: Option<&axum::body::Bytes>,
|
||||||
|
) -> Response<Body> {
|
||||||
|
let auth = match resolve_authenticated_local_user(state, request_context, headers).await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(response) => return response,
|
||||||
|
};
|
||||||
|
let Some(request_body) = request_body else {
|
||||||
|
return build_auth_error_response(http::StatusCode::BAD_REQUEST, "缺少请求体", false);
|
||||||
|
};
|
||||||
|
let payload = match serde_json::from_slice::<WalletRedeemRequest>(request_body) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_) => {
|
||||||
|
return build_auth_error_response(http::StatusCode::BAD_REQUEST, "输入验证失败", false)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let code = match wallet_normalize_optional_string_field(Some(payload.code), 128) {
|
||||||
|
Ok(Some(value)) => value,
|
||||||
|
_ => {
|
||||||
|
return build_auth_error_response(http::StatusCode::BAD_REQUEST, "输入验证失败", false)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let outcome = match state
|
||||||
|
.redeem_wallet_code(aether_data::repository::wallet::RedeemWalletCodeInput {
|
||||||
|
code,
|
||||||
|
user_id: auth.user.id.clone(),
|
||||||
|
order_no: wallet_build_redeem_order_no(Utc::now()),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some(value)) => value,
|
||||||
|
Ok(None) => {
|
||||||
|
return build_auth_error_response(
|
||||||
|
http::StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"钱包兑换后端暂不可用",
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
return build_auth_error_response(
|
||||||
|
http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("wallet redeem failed: {err:?}"),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match outcome {
|
||||||
|
aether_data::repository::wallet::RedeemWalletCodeOutcome::Redeemed {
|
||||||
|
wallet,
|
||||||
|
order,
|
||||||
|
amount_usd,
|
||||||
|
batch_name,
|
||||||
|
} => build_auth_json_response(
|
||||||
|
http::StatusCode::OK,
|
||||||
|
json!({
|
||||||
|
"order": build_wallet_payment_order_payload(&order),
|
||||||
|
"wallet": build_auth_wallet_summary_payload(Some(&wallet)),
|
||||||
|
"amount_usd": amount_usd,
|
||||||
|
"batch_name": batch_name,
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
aether_data::repository::wallet::RedeemWalletCodeOutcome::InvalidCode => {
|
||||||
|
build_auth_error_response(http::StatusCode::BAD_REQUEST, "兑换码格式无效", false)
|
||||||
|
}
|
||||||
|
aether_data::repository::wallet::RedeemWalletCodeOutcome::CodeNotFound => {
|
||||||
|
build_auth_error_response(http::StatusCode::NOT_FOUND, "兑换码不存在", false)
|
||||||
|
}
|
||||||
|
aether_data::repository::wallet::RedeemWalletCodeOutcome::CodeDisabled
|
||||||
|
| aether_data::repository::wallet::RedeemWalletCodeOutcome::BatchDisabled => {
|
||||||
|
build_auth_error_response(http::StatusCode::BAD_REQUEST, "兑换码已停用", false)
|
||||||
|
}
|
||||||
|
aether_data::repository::wallet::RedeemWalletCodeOutcome::CodeExpired => {
|
||||||
|
build_auth_error_response(http::StatusCode::BAD_REQUEST, "兑换码已过期", false)
|
||||||
|
}
|
||||||
|
aether_data::repository::wallet::RedeemWalletCodeOutcome::CodeRedeemed => {
|
||||||
|
build_auth_error_response(http::StatusCode::BAD_REQUEST, "兑换码已被使用", false)
|
||||||
|
}
|
||||||
|
aether_data::repository::wallet::RedeemWalletCodeOutcome::WalletInactive => {
|
||||||
|
build_auth_error_response(http::StatusCode::BAD_REQUEST, "wallet is not active", false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -302,6 +302,8 @@ pub(crate) fn admin_proxy_local_requires_buffered_body(
|
|||||||
| (Some("billing_manage"), http::Method::POST, Some("create_collector"))
|
| (Some("billing_manage"), http::Method::POST, Some("create_collector"))
|
||||||
| (Some("billing_manage"), http::Method::PUT, Some("update_collector"))
|
| (Some("billing_manage"), http::Method::PUT, Some("update_collector"))
|
||||||
| (Some("payments_manage"), http::Method::POST, Some("credit_order"))
|
| (Some("payments_manage"), http::Method::POST, Some("credit_order"))
|
||||||
|
| (Some("payments_manage"), http::Method::POST, Some("create_redeem_code_batch"))
|
||||||
|
| (Some("payments_manage"), http::Method::POST, Some("delete_redeem_code_batch"))
|
||||||
| (Some("api_keys_manage"), http::Method::POST, Some("create_api_key"))
|
| (Some("api_keys_manage"), http::Method::POST, Some("create_api_key"))
|
||||||
| (Some("api_keys_manage"), http::Method::PUT, Some("update_api_key"))
|
| (Some("api_keys_manage"), http::Method::PUT, Some("update_api_key"))
|
||||||
| (Some("api_keys_manage"), http::Method::PATCH, Some("toggle_api_key"))
|
| (Some("api_keys_manage"), http::Method::PATCH, Some("toggle_api_key"))
|
||||||
@@ -440,7 +442,7 @@ pub(crate) fn public_support_local_requires_buffered_body(
|
|||||||
) | (
|
) | (
|
||||||
Some("wallet"),
|
Some("wallet"),
|
||||||
http::Method::POST,
|
http::Method::POST,
|
||||||
Some("create_refund" | "create_recharge_order"),
|
Some("create_refund" | "create_recharge_order" | "redeem"),
|
||||||
) | (
|
) | (
|
||||||
Some("payment_callback"),
|
Some("payment_callback"),
|
||||||
http::Method::POST,
|
http::Method::POST,
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
use aether_data::repository::wallet::{
|
use aether_data::repository::wallet::{
|
||||||
AdminPaymentOrderListQuery, AdminWalletLedgerQuery, AdminWalletListQuery,
|
AdminPaymentOrderListQuery, AdminRedeemCodeBatchListQuery, AdminRedeemCodeListQuery,
|
||||||
AdminWalletRefundRequestListQuery, StoredAdminPaymentCallback, StoredAdminPaymentOrder,
|
AdminWalletLedgerQuery, AdminWalletListQuery, AdminWalletRefundRequestListQuery,
|
||||||
StoredAdminWalletLedgerItem, StoredAdminWalletListItem, StoredAdminWalletRefund,
|
StoredAdminPaymentCallback, StoredAdminPaymentOrder, StoredAdminRedeemCodeBatch,
|
||||||
StoredAdminWalletRefundRequestItem, StoredAdminWalletTransaction,
|
StoredAdminRedeemCodePage, StoredAdminWalletLedgerItem, StoredAdminWalletListItem,
|
||||||
|
StoredAdminWalletRefund, StoredAdminWalletRefundRequestItem, StoredAdminWalletTransaction,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -386,6 +387,61 @@ impl AppState {
|
|||||||
None => Ok(AdminWalletMutationOutcome::NotFound),
|
None => Ok(AdminWalletMutationOutcome::NotFound),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn list_admin_redeem_code_batches(
|
||||||
|
&self,
|
||||||
|
status: Option<&str>,
|
||||||
|
limit: usize,
|
||||||
|
offset: usize,
|
||||||
|
) -> Result<(Vec<StoredAdminRedeemCodeBatch>, u64), GatewayError> {
|
||||||
|
let page = self
|
||||||
|
.data
|
||||||
|
.list_admin_redeem_code_batches(&AdminRedeemCodeBatchListQuery {
|
||||||
|
status: status.map(ToOwned::to_owned),
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||||
|
Ok((page.items, page.total))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn read_admin_redeem_code_batch(
|
||||||
|
&self,
|
||||||
|
batch_id: &str,
|
||||||
|
) -> Result<AdminWalletMutationOutcome<StoredAdminRedeemCodeBatch>, GatewayError> {
|
||||||
|
if !self.has_wallet_data_reader() {
|
||||||
|
return Ok(AdminWalletMutationOutcome::Unavailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
match self
|
||||||
|
.data
|
||||||
|
.find_admin_redeem_code_batch(batch_id)
|
||||||
|
.await
|
||||||
|
.map_err(|err| GatewayError::Internal(err.to_string()))?
|
||||||
|
{
|
||||||
|
Some(batch) => Ok(AdminWalletMutationOutcome::Applied(batch)),
|
||||||
|
None => Ok(AdminWalletMutationOutcome::NotFound),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn list_admin_redeem_codes(
|
||||||
|
&self,
|
||||||
|
batch_id: &str,
|
||||||
|
status: Option<&str>,
|
||||||
|
limit: usize,
|
||||||
|
offset: usize,
|
||||||
|
) -> Result<StoredAdminRedeemCodePage, GatewayError> {
|
||||||
|
self.data
|
||||||
|
.list_admin_redeem_codes(&AdminRedeemCodeListQuery {
|
||||||
|
batch_id: batch_id.to_string(),
|
||||||
|
status: status.map(ToOwned::to_owned),
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stored_admin_payment_order_to_gateway(
|
fn stored_admin_payment_order_to_gateway(
|
||||||
|
|||||||
@@ -229,6 +229,126 @@ impl AppState {
|
|||||||
None => Ok(AdminWalletMutationOutcome::Unavailable),
|
None => Ok(AdminWalletMutationOutcome::Unavailable),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn admin_create_redeem_code_batch(
|
||||||
|
&self,
|
||||||
|
input: aether_data::repository::wallet::CreateAdminRedeemCodeBatchInput,
|
||||||
|
) -> Result<
|
||||||
|
Option<aether_data::repository::wallet::CreateAdminRedeemCodeBatchResult>,
|
||||||
|
GatewayError,
|
||||||
|
> {
|
||||||
|
self.data
|
||||||
|
.create_admin_redeem_code_batch(input)
|
||||||
|
.await
|
||||||
|
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn admin_disable_redeem_code_batch(
|
||||||
|
&self,
|
||||||
|
batch_id: &str,
|
||||||
|
operator_id: Option<&str>,
|
||||||
|
) -> Result<
|
||||||
|
AdminWalletMutationOutcome<aether_data::repository::wallet::StoredAdminRedeemCodeBatch>,
|
||||||
|
GatewayError,
|
||||||
|
> {
|
||||||
|
match self
|
||||||
|
.data
|
||||||
|
.disable_admin_redeem_code_batch(
|
||||||
|
aether_data::repository::wallet::DisableAdminRedeemCodeBatchInput {
|
||||||
|
batch_id: batch_id.to_string(),
|
||||||
|
operator_id: operator_id.map(ToOwned::to_owned),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|err| GatewayError::Internal(err.to_string()))?
|
||||||
|
{
|
||||||
|
Some(aether_data::repository::wallet::WalletMutationOutcome::Applied(batch)) => {
|
||||||
|
Ok(AdminWalletMutationOutcome::Applied(batch))
|
||||||
|
}
|
||||||
|
Some(aether_data::repository::wallet::WalletMutationOutcome::NotFound) => {
|
||||||
|
Ok(AdminWalletMutationOutcome::NotFound)
|
||||||
|
}
|
||||||
|
Some(aether_data::repository::wallet::WalletMutationOutcome::Invalid(detail)) => {
|
||||||
|
Ok(AdminWalletMutationOutcome::Invalid(detail))
|
||||||
|
}
|
||||||
|
None => Ok(AdminWalletMutationOutcome::Unavailable),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn admin_delete_redeem_code_batch(
|
||||||
|
&self,
|
||||||
|
batch_id: &str,
|
||||||
|
operator_id: Option<&str>,
|
||||||
|
) -> Result<
|
||||||
|
AdminWalletMutationOutcome<aether_data::repository::wallet::StoredAdminRedeemCodeBatch>,
|
||||||
|
GatewayError,
|
||||||
|
> {
|
||||||
|
match self
|
||||||
|
.data
|
||||||
|
.delete_admin_redeem_code_batch(
|
||||||
|
aether_data::repository::wallet::DeleteAdminRedeemCodeBatchInput {
|
||||||
|
batch_id: batch_id.to_string(),
|
||||||
|
operator_id: operator_id.map(ToOwned::to_owned),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|err| GatewayError::Internal(err.to_string()))?
|
||||||
|
{
|
||||||
|
Some(aether_data::repository::wallet::WalletMutationOutcome::Applied(batch)) => {
|
||||||
|
Ok(AdminWalletMutationOutcome::Applied(batch))
|
||||||
|
}
|
||||||
|
Some(aether_data::repository::wallet::WalletMutationOutcome::NotFound) => {
|
||||||
|
Ok(AdminWalletMutationOutcome::NotFound)
|
||||||
|
}
|
||||||
|
Some(aether_data::repository::wallet::WalletMutationOutcome::Invalid(detail)) => {
|
||||||
|
Ok(AdminWalletMutationOutcome::Invalid(detail))
|
||||||
|
}
|
||||||
|
None => Ok(AdminWalletMutationOutcome::Unavailable),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn admin_disable_redeem_code(
|
||||||
|
&self,
|
||||||
|
code_id: &str,
|
||||||
|
operator_id: Option<&str>,
|
||||||
|
) -> Result<
|
||||||
|
AdminWalletMutationOutcome<aether_data::repository::wallet::StoredAdminRedeemCode>,
|
||||||
|
GatewayError,
|
||||||
|
> {
|
||||||
|
match self
|
||||||
|
.data
|
||||||
|
.disable_admin_redeem_code(
|
||||||
|
aether_data::repository::wallet::DisableAdminRedeemCodeInput {
|
||||||
|
code_id: code_id.to_string(),
|
||||||
|
operator_id: operator_id.map(ToOwned::to_owned),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|err| GatewayError::Internal(err.to_string()))?
|
||||||
|
{
|
||||||
|
Some(aether_data::repository::wallet::WalletMutationOutcome::Applied(code)) => {
|
||||||
|
Ok(AdminWalletMutationOutcome::Applied(code))
|
||||||
|
}
|
||||||
|
Some(aether_data::repository::wallet::WalletMutationOutcome::NotFound) => {
|
||||||
|
Ok(AdminWalletMutationOutcome::NotFound)
|
||||||
|
}
|
||||||
|
Some(aether_data::repository::wallet::WalletMutationOutcome::Invalid(detail)) => {
|
||||||
|
Ok(AdminWalletMutationOutcome::Invalid(detail))
|
||||||
|
}
|
||||||
|
None => Ok(AdminWalletMutationOutcome::Unavailable),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn redeem_wallet_code(
|
||||||
|
&self,
|
||||||
|
input: aether_data::repository::wallet::RedeemWalletCodeInput,
|
||||||
|
) -> Result<Option<aether_data::repository::wallet::RedeemWalletCodeOutcome>, GatewayError>
|
||||||
|
{
|
||||||
|
self.data
|
||||||
|
.redeem_wallet_code(input)
|
||||||
|
.await
|
||||||
|
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stored_admin_payment_order_to_gateway(
|
fn stored_admin_payment_order_to_gateway(
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use aether_data::repository::users::InMemoryUserReadRepository;
|
||||||
use aether_data::repository::wallet::StoredWalletSnapshot;
|
use aether_data::repository::wallet::StoredWalletSnapshot;
|
||||||
|
use aether_data::repository::wallet::{InMemoryWalletRepository, WalletWriteRepository};
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::routing::any;
|
use axum::routing::any;
|
||||||
use axum::{extract::Request, Router};
|
use axum::{extract::Request, Router};
|
||||||
@@ -750,3 +752,103 @@ async fn gateway_rejects_admin_payments_empty_order_identifier_locally_with_trus
|
|||||||
gateway_handle.abort();
|
gateway_handle.abort();
|
||||||
upstream_handle.abort();
|
upstream_handle.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gateway_handles_admin_redeem_code_batch_lifecycle_locally() {
|
||||||
|
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![]));
|
||||||
|
let wallet_repository = Arc::new(InMemoryWalletRepository::seed(
|
||||||
|
Vec::<StoredWalletSnapshot>::new(),
|
||||||
|
));
|
||||||
|
let gateway = build_router_with_state(
|
||||||
|
AppState::new()
|
||||||
|
.expect("gateway should build")
|
||||||
|
.with_data_state_for_tests(
|
||||||
|
crate::data::GatewayDataState::with_user_and_wallet_for_tests(
|
||||||
|
user_repository,
|
||||||
|
wallet_repository.clone(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let create_response = admin_request(client.post(format!(
|
||||||
|
"{gateway_url}/api/admin/payments/redeem-codes/batches"
|
||||||
|
)))
|
||||||
|
.json(&json!({
|
||||||
|
"name": "五月渠道卡",
|
||||||
|
"amount_usd": 9.9,
|
||||||
|
"total_count": 2,
|
||||||
|
"description": "offline promo",
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("request should succeed");
|
||||||
|
assert_eq!(create_response.status(), StatusCode::OK);
|
||||||
|
let create_payload: serde_json::Value = create_response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("json body should parse");
|
||||||
|
let batch_id = create_payload["batch"]["id"]
|
||||||
|
.as_str()
|
||||||
|
.expect("batch id should exist")
|
||||||
|
.to_string();
|
||||||
|
assert_eq!(create_payload["batch"]["name"], "五月渠道卡");
|
||||||
|
assert_eq!(create_payload["batch"]["balance_bucket"], "gift");
|
||||||
|
assert_eq!(create_payload["codes"].as_array().map(Vec::len), Some(2));
|
||||||
|
|
||||||
|
let list_response = admin_request(client.get(format!(
|
||||||
|
"{gateway_url}/api/admin/payments/redeem-codes/batches?limit=20&offset=0"
|
||||||
|
)))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("request should succeed");
|
||||||
|
assert_eq!(list_response.status(), StatusCode::OK);
|
||||||
|
let list_payload: serde_json::Value =
|
||||||
|
list_response.json().await.expect("json body should parse");
|
||||||
|
assert_eq!(list_payload["items"][0]["id"], batch_id);
|
||||||
|
assert_eq!(list_payload["items"][0]["balance_bucket"], "gift");
|
||||||
|
|
||||||
|
let codes_response = admin_request(client.get(format!(
|
||||||
|
"{gateway_url}/api/admin/payments/redeem-codes/batches/{batch_id}/codes?limit=20&offset=0"
|
||||||
|
)))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("request should succeed");
|
||||||
|
assert_eq!(codes_response.status(), StatusCode::OK);
|
||||||
|
let codes_payload: serde_json::Value =
|
||||||
|
codes_response.json().await.expect("json body should parse");
|
||||||
|
assert_eq!(codes_payload["batch"]["id"], batch_id);
|
||||||
|
assert_eq!(codes_payload["batch"]["balance_bucket"], "gift");
|
||||||
|
assert_eq!(codes_payload["items"].as_array().map(Vec::len), Some(2));
|
||||||
|
|
||||||
|
let disable_response = admin_request(client.post(format!(
|
||||||
|
"{gateway_url}/api/admin/payments/redeem-codes/batches/{batch_id}/disable"
|
||||||
|
)))
|
||||||
|
.json(&json!({}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("request should succeed");
|
||||||
|
assert_eq!(disable_response.status(), StatusCode::OK);
|
||||||
|
let disable_payload: serde_json::Value = disable_response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("json body should parse");
|
||||||
|
assert_eq!(disable_payload["batch"]["status"], "disabled");
|
||||||
|
|
||||||
|
let delete_response = admin_request(client.post(format!(
|
||||||
|
"{gateway_url}/api/admin/payments/redeem-codes/batches/{batch_id}/delete"
|
||||||
|
)))
|
||||||
|
.json(&json!({}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("request should succeed");
|
||||||
|
assert_eq!(delete_response.status(), StatusCode::OK);
|
||||||
|
let delete_payload: serde_json::Value = delete_response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("json body should parse");
|
||||||
|
assert_eq!(delete_payload["batch"]["id"], batch_id);
|
||||||
|
|
||||||
|
gateway_handle.abort();
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ use aether_data::repository::usage::InMemoryUsageReadRepository;
|
|||||||
use aether_data::repository::users::{
|
use aether_data::repository::users::{
|
||||||
InMemoryUserReadRepository, StoredUserAuthRecord, StoredUserExportRow,
|
InMemoryUserReadRepository, StoredUserAuthRecord, StoredUserExportRow,
|
||||||
};
|
};
|
||||||
use aether_data::repository::wallet::{InMemoryWalletRepository, StoredWalletSnapshot};
|
use aether_data::repository::wallet::{
|
||||||
|
InMemoryWalletRepository, StoredWalletSnapshot, WalletWriteRepository,
|
||||||
|
};
|
||||||
use aether_data_contracts::repository::global_models::StoredProviderActiveGlobalModel;
|
use aether_data_contracts::repository::global_models::StoredProviderActiveGlobalModel;
|
||||||
use aether_data_contracts::repository::provider_catalog::ProviderCatalogReadRepository;
|
use aether_data_contracts::repository::provider_catalog::ProviderCatalogReadRepository;
|
||||||
use aether_data_contracts::repository::usage::{StoredRequestUsageAudit, UsageRepository};
|
use aether_data_contracts::repository::usage::{StoredRequestUsageAudit, UsageRepository};
|
||||||
@@ -3234,6 +3236,88 @@ async fn gateway_rejects_invalid_nested_announcement_paths_as_local_not_found_wi
|
|||||||
upstream_handle.abort();
|
upstream_handle.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gateway_redeems_wallet_code_locally() {
|
||||||
|
let now = Utc::now();
|
||||||
|
let user = sample_auth_user(now);
|
||||||
|
let access_token = build_test_auth_token(
|
||||||
|
"access",
|
||||||
|
serde_json::Map::from_iter([
|
||||||
|
("user_id".to_string(), json!(user.id.clone())),
|
||||||
|
("role".to_string(), json!(user.role.clone())),
|
||||||
|
(
|
||||||
|
"created_at".to_string(),
|
||||||
|
json!(user.created_at.map(|value| value.to_rfc3339())),
|
||||||
|
),
|
||||||
|
("session_id".to_string(), json!("session-wallet-redeem-1")),
|
||||||
|
]),
|
||||||
|
now + chrono::Duration::hours(1),
|
||||||
|
);
|
||||||
|
|
||||||
|
let wallet_repository = Arc::new(InMemoryWalletRepository::seed(vec![sample_auth_wallet(
|
||||||
|
"user-auth-1",
|
||||||
|
now,
|
||||||
|
)]));
|
||||||
|
let batch = wallet_repository
|
||||||
|
.create_admin_redeem_code_batch(
|
||||||
|
aether_data::repository::wallet::CreateAdminRedeemCodeBatchInput {
|
||||||
|
name: "测试兑换".to_string(),
|
||||||
|
amount_usd: 6.5,
|
||||||
|
currency: "USD".to_string(),
|
||||||
|
balance_bucket: "gift".to_string(),
|
||||||
|
total_count: 1,
|
||||||
|
expires_at_unix_secs: None,
|
||||||
|
description: Some("public support".to_string()),
|
||||||
|
created_by: Some("admin-user-1".to_string()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("batch should create");
|
||||||
|
let redeem_code = batch.codes[0].code.clone();
|
||||||
|
|
||||||
|
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
|
||||||
|
let state = AppState::new()
|
||||||
|
.expect("gateway should build")
|
||||||
|
.with_data_state_for_tests(
|
||||||
|
crate::data::GatewayDataState::with_user_and_wallet_for_tests(
|
||||||
|
user_repository,
|
||||||
|
wallet_repository,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.with_auth_sessions_for_tests([sample_auth_session(
|
||||||
|
"user-auth-1",
|
||||||
|
"session-wallet-redeem-1",
|
||||||
|
"device-wallet-redeem-1",
|
||||||
|
"refresh-token-wallet-redeem-1",
|
||||||
|
now,
|
||||||
|
)]);
|
||||||
|
let gateway = build_router_with_state(state);
|
||||||
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||||
|
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.post(format!("{gateway_url}/api/wallet/redeem"))
|
||||||
|
.header("authorization", format!("Bearer {access_token}"))
|
||||||
|
.header("x-client-device-id", "device-wallet-redeem-1")
|
||||||
|
.header("user-agent", "AetherTest/1.0")
|
||||||
|
.json(&json!({ "code": redeem_code }))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("request should succeed");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||||
|
assert_eq!(payload["batch_name"], "测试兑换");
|
||||||
|
assert_eq!(payload["amount_usd"], 6.5);
|
||||||
|
assert_eq!(payload["order"]["payment_method"], "gift_code");
|
||||||
|
assert_eq!(payload["order"]["refundable_amount_usd"], 0.0);
|
||||||
|
assert_eq!(payload["wallet"]["total_recharged"], 26.5);
|
||||||
|
assert_eq!(payload["wallet"]["recharge_balance"], 12.5);
|
||||||
|
assert_eq!(payload["wallet"]["gift_balance"], 9.5);
|
||||||
|
assert_eq!(payload["wallet"]["refundable_balance"], 12.5);
|
||||||
|
|
||||||
|
gateway_handle.abort();
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn gateway_creates_wallet_recharge_orders_locally_without_proxying_upstream() {
|
async fn gateway_creates_wallet_recharge_orders_locally_without_proxying_upstream() {
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
|
|||||||
@@ -4766,3 +4766,120 @@ COMMENT ON COLUMN public.usage.client_response_body IS
|
|||||||
'DEPRECATED: HTTP body owner moved to public.usage_body_blobs plus public.usage_http_audits.client_response_body_ref. Legacy compatibility only; do not write new values.';
|
'DEPRECATED: HTTP body owner moved to public.usage_body_blobs plus public.usage_http_audits.client_response_body_ref. Legacy compatibility only; do not write new values.';
|
||||||
COMMENT ON COLUMN public.usage.client_response_body_compressed IS
|
COMMENT ON COLUMN public.usage.client_response_body_compressed IS
|
||||||
'DEPRECATED: HTTP body owner moved to public.usage_body_blobs plus public.usage_http_audits.client_response_body_ref. Legacy compatibility only; do not write new values.';
|
'DEPRECATED: HTTP body owner moved to public.usage_body_blobs plus public.usage_http_audits.client_response_body_ref. Legacy compatibility only; do not write new values.';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.redeem_code_batches (
|
||||||
|
id character varying(36) NOT NULL,
|
||||||
|
name character varying(120) NOT NULL,
|
||||||
|
amount_usd numeric(20,8) NOT NULL,
|
||||||
|
currency character varying(3) DEFAULT 'USD'::character varying NOT NULL,
|
||||||
|
balance_bucket character varying(20) DEFAULT 'gift'::character varying NOT NULL,
|
||||||
|
total_count integer NOT NULL,
|
||||||
|
status character varying(20) DEFAULT 'active'::character varying NOT NULL,
|
||||||
|
description text,
|
||||||
|
created_by character varying(36),
|
||||||
|
expires_at timestamp with time zone,
|
||||||
|
created_at timestamp with time zone NOT NULL,
|
||||||
|
updated_at timestamp with time zone NOT NULL,
|
||||||
|
CONSTRAINT ck_redeem_code_batches_amount_positive CHECK ((amount_usd > (0)::numeric)),
|
||||||
|
CONSTRAINT ck_redeem_code_batches_total_count_positive CHECK ((total_count > 0))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.redeem_codes (
|
||||||
|
id character varying(36) NOT NULL,
|
||||||
|
batch_id character varying(36) NOT NULL,
|
||||||
|
code_hash character varying(64) NOT NULL,
|
||||||
|
code_prefix character varying(8) NOT NULL,
|
||||||
|
code_suffix character varying(8) NOT NULL,
|
||||||
|
status character varying(20) DEFAULT 'active'::character varying NOT NULL,
|
||||||
|
redeemed_by_user_id character varying(36),
|
||||||
|
redeemed_wallet_id character varying(36),
|
||||||
|
redeemed_payment_order_id character varying(36),
|
||||||
|
redeemed_at timestamp with time zone,
|
||||||
|
disabled_by character varying(36),
|
||||||
|
created_at timestamp with time zone NOT NULL,
|
||||||
|
updated_at timestamp with time zone NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
DO $mig$ BEGIN
|
||||||
|
ALTER TABLE ONLY public.redeem_code_batches
|
||||||
|
ADD CONSTRAINT redeem_code_batches_pkey PRIMARY KEY (id);
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
WHEN duplicate_table THEN NULL;
|
||||||
|
WHEN invalid_table_definition THEN NULL;
|
||||||
|
END $mig$;
|
||||||
|
|
||||||
|
DO $mig$ BEGIN
|
||||||
|
ALTER TABLE ONLY public.redeem_codes
|
||||||
|
ADD CONSTRAINT redeem_codes_pkey PRIMARY KEY (id);
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
WHEN duplicate_table THEN NULL;
|
||||||
|
WHEN invalid_table_definition THEN NULL;
|
||||||
|
END $mig$;
|
||||||
|
|
||||||
|
DO $mig$ BEGIN
|
||||||
|
ALTER TABLE ONLY public.redeem_codes
|
||||||
|
ADD CONSTRAINT uq_redeem_codes_code_hash UNIQUE (code_hash);
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
WHEN duplicate_table THEN NULL;
|
||||||
|
WHEN invalid_table_definition THEN NULL;
|
||||||
|
END $mig$;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_redeem_code_batches_status
|
||||||
|
ON public.redeem_code_batches USING btree (status, created_at);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_redeem_codes_batch_created
|
||||||
|
ON public.redeem_codes USING btree (batch_id, created_at);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_redeem_codes_status
|
||||||
|
ON public.redeem_codes USING btree (status, updated_at);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_redeem_codes_redeemed_user
|
||||||
|
ON public.redeem_codes USING btree (redeemed_by_user_id, redeemed_at);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_redeem_codes_redeemed_order
|
||||||
|
ON public.redeem_codes USING btree (redeemed_payment_order_id);
|
||||||
|
|
||||||
|
DO $mig$ BEGIN
|
||||||
|
ALTER TABLE ONLY public.redeem_code_batches
|
||||||
|
ADD CONSTRAINT redeem_code_batches_created_by_fkey FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE SET NULL;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
END $mig$;
|
||||||
|
|
||||||
|
DO $mig$ BEGIN
|
||||||
|
ALTER TABLE ONLY public.redeem_codes
|
||||||
|
ADD CONSTRAINT redeem_codes_batch_id_fkey FOREIGN KEY (batch_id) REFERENCES public.redeem_code_batches(id) ON DELETE CASCADE;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
END $mig$;
|
||||||
|
|
||||||
|
DO $mig$ BEGIN
|
||||||
|
ALTER TABLE ONLY public.redeem_codes
|
||||||
|
ADD CONSTRAINT redeem_codes_redeemed_by_user_id_fkey FOREIGN KEY (redeemed_by_user_id) REFERENCES public.users(id) ON DELETE SET NULL;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
END $mig$;
|
||||||
|
|
||||||
|
DO $mig$ BEGIN
|
||||||
|
ALTER TABLE ONLY public.redeem_codes
|
||||||
|
ADD CONSTRAINT redeem_codes_redeemed_wallet_id_fkey FOREIGN KEY (redeemed_wallet_id) REFERENCES public.wallets(id) ON DELETE SET NULL;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
END $mig$;
|
||||||
|
|
||||||
|
DO $mig$ BEGIN
|
||||||
|
ALTER TABLE ONLY public.redeem_codes
|
||||||
|
ADD CONSTRAINT redeem_codes_redeemed_payment_order_id_fkey FOREIGN KEY (redeemed_payment_order_id) REFERENCES public.payment_orders(id) ON DELETE SET NULL;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
END $mig$;
|
||||||
|
|
||||||
|
DO $mig$ BEGIN
|
||||||
|
ALTER TABLE ONLY public.redeem_codes
|
||||||
|
ADD CONSTRAINT redeem_codes_disabled_by_fkey FOREIGN KEY (disabled_by) REFERENCES public.users(id) ON DELETE SET NULL;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
END $mig$;
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS public.redeem_code_batches (
|
||||||
|
id character varying(36) NOT NULL,
|
||||||
|
name character varying(120) NOT NULL,
|
||||||
|
amount_usd numeric(20,8) NOT NULL,
|
||||||
|
currency character varying(3) DEFAULT 'USD'::character varying NOT NULL,
|
||||||
|
balance_bucket character varying(20) DEFAULT 'gift'::character varying NOT NULL,
|
||||||
|
total_count integer NOT NULL,
|
||||||
|
status character varying(20) DEFAULT 'active'::character varying NOT NULL,
|
||||||
|
description text,
|
||||||
|
created_by character varying(36),
|
||||||
|
expires_at timestamp with time zone,
|
||||||
|
created_at timestamp with time zone NOT NULL,
|
||||||
|
updated_at timestamp with time zone NOT NULL,
|
||||||
|
CONSTRAINT ck_redeem_code_batches_amount_positive CHECK ((amount_usd > (0)::numeric)),
|
||||||
|
CONSTRAINT ck_redeem_code_batches_total_count_positive CHECK ((total_count > 0))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.redeem_codes (
|
||||||
|
id character varying(36) NOT NULL,
|
||||||
|
batch_id character varying(36) NOT NULL,
|
||||||
|
code_hash character varying(64) NOT NULL,
|
||||||
|
code_prefix character varying(8) NOT NULL,
|
||||||
|
code_suffix character varying(8) NOT NULL,
|
||||||
|
status character varying(20) DEFAULT 'active'::character varying NOT NULL,
|
||||||
|
redeemed_by_user_id character varying(36),
|
||||||
|
redeemed_wallet_id character varying(36),
|
||||||
|
redeemed_payment_order_id character varying(36),
|
||||||
|
redeemed_at timestamp with time zone,
|
||||||
|
disabled_by character varying(36),
|
||||||
|
created_at timestamp with time zone NOT NULL,
|
||||||
|
updated_at timestamp with time zone NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
DO $mig$ BEGIN
|
||||||
|
ALTER TABLE ONLY public.redeem_code_batches
|
||||||
|
ADD CONSTRAINT redeem_code_batches_pkey PRIMARY KEY (id);
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
WHEN duplicate_table THEN NULL;
|
||||||
|
WHEN invalid_table_definition THEN NULL;
|
||||||
|
END $mig$;
|
||||||
|
|
||||||
|
DO $mig$ BEGIN
|
||||||
|
ALTER TABLE ONLY public.redeem_codes
|
||||||
|
ADD CONSTRAINT redeem_codes_pkey PRIMARY KEY (id);
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
WHEN duplicate_table THEN NULL;
|
||||||
|
WHEN invalid_table_definition THEN NULL;
|
||||||
|
END $mig$;
|
||||||
|
|
||||||
|
DO $mig$ BEGIN
|
||||||
|
ALTER TABLE ONLY public.redeem_codes
|
||||||
|
ADD CONSTRAINT uq_redeem_codes_code_hash UNIQUE (code_hash);
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
WHEN duplicate_table THEN NULL;
|
||||||
|
WHEN invalid_table_definition THEN NULL;
|
||||||
|
END $mig$;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_redeem_code_batches_status
|
||||||
|
ON public.redeem_code_batches USING btree (status, created_at);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_redeem_codes_batch_created
|
||||||
|
ON public.redeem_codes USING btree (batch_id, created_at);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_redeem_codes_status
|
||||||
|
ON public.redeem_codes USING btree (status, updated_at);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_redeem_codes_redeemed_user
|
||||||
|
ON public.redeem_codes USING btree (redeemed_by_user_id, redeemed_at);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_redeem_codes_redeemed_order
|
||||||
|
ON public.redeem_codes USING btree (redeemed_payment_order_id);
|
||||||
|
|
||||||
|
DO $mig$ BEGIN
|
||||||
|
ALTER TABLE ONLY public.redeem_code_batches
|
||||||
|
ADD CONSTRAINT redeem_code_batches_created_by_fkey FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE SET NULL;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
END $mig$;
|
||||||
|
|
||||||
|
DO $mig$ BEGIN
|
||||||
|
ALTER TABLE ONLY public.redeem_codes
|
||||||
|
ADD CONSTRAINT redeem_codes_batch_id_fkey FOREIGN KEY (batch_id) REFERENCES public.redeem_code_batches(id) ON DELETE CASCADE;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
END $mig$;
|
||||||
|
|
||||||
|
DO $mig$ BEGIN
|
||||||
|
ALTER TABLE ONLY public.redeem_codes
|
||||||
|
ADD CONSTRAINT redeem_codes_redeemed_by_user_id_fkey FOREIGN KEY (redeemed_by_user_id) REFERENCES public.users(id) ON DELETE SET NULL;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
END $mig$;
|
||||||
|
|
||||||
|
DO $mig$ BEGIN
|
||||||
|
ALTER TABLE ONLY public.redeem_codes
|
||||||
|
ADD CONSTRAINT redeem_codes_redeemed_wallet_id_fkey FOREIGN KEY (redeemed_wallet_id) REFERENCES public.wallets(id) ON DELETE SET NULL;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
END $mig$;
|
||||||
|
|
||||||
|
DO $mig$ BEGIN
|
||||||
|
ALTER TABLE ONLY public.redeem_codes
|
||||||
|
ADD CONSTRAINT redeem_codes_redeemed_payment_order_id_fkey FOREIGN KEY (redeemed_payment_order_id) REFERENCES public.payment_orders(id) ON DELETE SET NULL;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
END $mig$;
|
||||||
|
|
||||||
|
DO $mig$ BEGIN
|
||||||
|
ALTER TABLE ONLY public.redeem_codes
|
||||||
|
ADD CONSTRAINT redeem_codes_disabled_by_fkey FOREIGN KEY (disabled_by) REFERENCES public.users(id) ON DELETE SET NULL;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
END $mig$;
|
||||||
@@ -8,7 +8,7 @@ use tracing::{error, info, warn};
|
|||||||
|
|
||||||
static MIGRATOR: Migrator = sqlx::migrate!("./migrations");
|
static MIGRATOR: Migrator = sqlx::migrate!("./migrations");
|
||||||
static BASELINE_V2_SQL: &str = include_str!("../bootstrap/20260413020000_baseline_v2.sql");
|
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 =
|
const MIGRATIONS_TABLE_EXISTS_SQL: &str =
|
||||||
"SELECT to_regclass('public._sqlx_migrations') IS NOT NULL";
|
"SELECT to_regclass('public._sqlx_migrations') IS NOT NULL";
|
||||||
const PUBLIC_BASE_TABLE_COUNT_SQL: &str = r#"
|
const PUBLIC_BASE_TABLE_COUNT_SQL: &str = r#"
|
||||||
@@ -597,6 +597,7 @@ mod tests {
|
|||||||
20260410000000,
|
20260410000000,
|
||||||
20260413020000,
|
20260413020000,
|
||||||
20260413030000,
|
20260413030000,
|
||||||
|
20260415000000,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -687,7 +688,12 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
pending_versions,
|
pending_versions,
|
||||||
vec![20260410000000, 20260413020000, 20260413030000]
|
vec![
|
||||||
|
20260410000000,
|
||||||
|
20260413020000,
|
||||||
|
20260413030000,
|
||||||
|
20260415000000
|
||||||
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ impl RequestCandidateReadRepository for InMemoryRequestCandidateRepository {
|
|||||||
.values()
|
.values()
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect::<Vec<_>>();
|
.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);
|
rows.truncate(limit);
|
||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
@@ -90,7 +90,7 @@ impl RequestCandidateReadRepository for InMemoryRequestCandidateRepository {
|
|||||||
.filter(|row| row.provider_id.as_deref() == Some(provider_id))
|
.filter(|row| row.provider_id.as_deref() == Some(provider_id))
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect::<Vec<_>>();
|
.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);
|
rows.truncate(limit);
|
||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
@@ -125,7 +125,7 @@ impl RequestCandidateReadRepository for InMemoryRequestCandidateRepository {
|
|||||||
})
|
})
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect::<Vec<_>>();
|
.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);
|
rows.truncate(limit);
|
||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ impl VideoTaskReadRepository for InMemoryVideoTaskRepository {
|
|||||||
.filter(|task| task.status.is_active())
|
.filter(|task| task.status.is_active())
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect::<Vec<_>>();
|
.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);
|
tasks.truncate(limit);
|
||||||
Ok(tasks)
|
Ok(tasks)
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,16 +6,22 @@ pub use memory::InMemoryWalletRepository;
|
|||||||
pub use sql::SqlxWalletRepository;
|
pub use sql::SqlxWalletRepository;
|
||||||
pub use types::{
|
pub use types::{
|
||||||
AdjustWalletBalanceInput, AdminPaymentCallbackRecord, AdminPaymentOrderListQuery,
|
AdjustWalletBalanceInput, AdminPaymentCallbackRecord, AdminPaymentOrderListQuery,
|
||||||
AdminWalletLedgerQuery, AdminWalletListQuery, AdminWalletPaymentOrderRecord,
|
AdminRedeemCodeBatchListQuery, AdminRedeemCodeListQuery, AdminWalletLedgerQuery,
|
||||||
AdminWalletRefundRecord, AdminWalletRefundRequestListQuery, AdminWalletTransactionRecord,
|
AdminWalletListQuery, AdminWalletPaymentOrderRecord, AdminWalletRefundRecord,
|
||||||
CompleteAdminWalletRefundInput, CreateManualWalletRechargeInput,
|
AdminWalletRefundRequestListQuery, AdminWalletTransactionRecord,
|
||||||
|
CompleteAdminWalletRefundInput, CreateAdminRedeemCodeBatchInput,
|
||||||
|
CreateAdminRedeemCodeBatchResult, CreateManualWalletRechargeInput,
|
||||||
CreateWalletRechargeOrderInput, CreateWalletRechargeOrderOutcome,
|
CreateWalletRechargeOrderInput, CreateWalletRechargeOrderOutcome,
|
||||||
CreateWalletRefundRequestInput, CreateWalletRefundRequestOutcome, CreditAdminPaymentOrderInput,
|
CreateWalletRefundRequestInput, CreateWalletRefundRequestOutcome,
|
||||||
FailAdminWalletRefundInput, ProcessAdminWalletRefundInput, ProcessPaymentCallbackInput,
|
CreatedAdminRedeemCodePlaintext, CreditAdminPaymentOrderInput, DeleteAdminRedeemCodeBatchInput,
|
||||||
ProcessPaymentCallbackOutcome, StoredAdminPaymentCallback, StoredAdminPaymentCallbackPage,
|
DisableAdminRedeemCodeBatchInput, DisableAdminRedeemCodeInput, FailAdminWalletRefundInput,
|
||||||
StoredAdminPaymentOrder, StoredAdminPaymentOrderPage, StoredAdminWalletLedgerItem,
|
ProcessAdminWalletRefundInput, ProcessPaymentCallbackInput, ProcessPaymentCallbackOutcome,
|
||||||
StoredAdminWalletLedgerPage, StoredAdminWalletListItem, StoredAdminWalletListPage,
|
RedeemWalletCodeInput, RedeemWalletCodeOutcome, StoredAdminPaymentCallback,
|
||||||
StoredAdminWalletRefund, StoredAdminWalletRefundPage, StoredAdminWalletRefundRequestItem,
|
StoredAdminPaymentCallbackPage, StoredAdminPaymentOrder, StoredAdminPaymentOrderPage,
|
||||||
|
StoredAdminRedeemCode, StoredAdminRedeemCodeBatch, StoredAdminRedeemCodeBatchPage,
|
||||||
|
StoredAdminRedeemCodePage, StoredAdminWalletLedgerItem, StoredAdminWalletLedgerPage,
|
||||||
|
StoredAdminWalletListItem, StoredAdminWalletListPage, StoredAdminWalletRefund,
|
||||||
|
StoredAdminWalletRefundPage, StoredAdminWalletRefundRequestItem,
|
||||||
StoredAdminWalletRefundRequestPage, StoredAdminWalletTransaction,
|
StoredAdminWalletRefundRequestPage, StoredAdminWalletTransaction,
|
||||||
StoredAdminWalletTransactionPage, StoredWalletDailyUsageLedger,
|
StoredAdminWalletTransactionPage, StoredWalletDailyUsageLedger,
|
||||||
StoredWalletDailyUsageLedgerPage, StoredWalletSnapshot, WalletLookupKey, WalletMutationOutcome,
|
StoredWalletDailyUsageLedgerPage, StoredWalletSnapshot, WalletLookupKey, WalletMutationOutcome,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -443,6 +443,160 @@ pub struct StoredAdminPaymentCallbackPage {
|
|||||||
pub total: u64,
|
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)]
|
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct CreateWalletRechargeOrderInput {
|
pub struct CreateWalletRechargeOrderInput {
|
||||||
pub preferred_wallet_id: Option<String>,
|
pub preferred_wallet_id: Option<String>,
|
||||||
@@ -686,6 +840,21 @@ pub trait WalletReadRepository: Send + Sync {
|
|||||||
limit: usize,
|
limit: usize,
|
||||||
offset: usize,
|
offset: usize,
|
||||||
) -> Result<StoredAdminPaymentCallbackPage, crate::DataLayerError>;
|
) -> 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]
|
#[async_trait]
|
||||||
@@ -758,6 +927,31 @@ pub trait WalletWriteRepository: Send + Sync {
|
|||||||
&self,
|
&self,
|
||||||
input: CreditAdminPaymentOrderInput,
|
input: CreditAdminPaymentOrderInput,
|
||||||
) -> Result<WalletMutationOutcome<(StoredAdminPaymentOrder, bool)>, crate::DataLayerError>;
|
) -> 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 {}
|
pub trait WalletRepository: WalletReadRepository + WalletWriteRepository + Send + Sync {}
|
||||||
@@ -766,7 +960,10 @@ impl<T> WalletRepository for T where T: WalletReadRepository + WalletWriteReposi
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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;
|
use crate::repository::settlement::UsageSettlementInput;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -804,4 +1001,20 @@ mod tests {
|
|||||||
};
|
};
|
||||||
assert!(input.validate().is_err());
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,75 @@ export interface AdminPaymentCreditRequest {
|
|||||||
gateway_response?: Record<string, unknown>
|
gateway_response?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RedeemCodeBatch {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
amount_usd: number
|
||||||
|
currency: string
|
||||||
|
balance_bucket: string
|
||||||
|
total_count: number
|
||||||
|
redeemed_count: number
|
||||||
|
active_count: number
|
||||||
|
status: string
|
||||||
|
description?: string | null
|
||||||
|
created_by?: string | null
|
||||||
|
expires_at?: string | null
|
||||||
|
created_at: string | null
|
||||||
|
updated_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RedeemCodeRecord {
|
||||||
|
id: string
|
||||||
|
batch_id: string
|
||||||
|
batch_name?: string | null
|
||||||
|
code_prefix: string
|
||||||
|
code_suffix: string
|
||||||
|
masked_code: string
|
||||||
|
status: string
|
||||||
|
redeemed_by_user_id?: string | null
|
||||||
|
redeemed_by_user_name?: string | null
|
||||||
|
redeemed_wallet_id?: string | null
|
||||||
|
redeemed_payment_order_id?: string | null
|
||||||
|
redeemed_order_no?: string | null
|
||||||
|
redeemed_at?: string | null
|
||||||
|
disabled_by?: string | null
|
||||||
|
expires_at?: string | null
|
||||||
|
created_at: string | null
|
||||||
|
updated_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateRedeemCodeBatchRequest {
|
||||||
|
name: string
|
||||||
|
amount_usd: number
|
||||||
|
total_count: number
|
||||||
|
expires_at?: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateRedeemCodeBatchResponse {
|
||||||
|
batch: RedeemCodeBatch
|
||||||
|
codes: Array<{
|
||||||
|
id: string
|
||||||
|
code: string
|
||||||
|
masked_code: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RedeemCodeBatchListResponse {
|
||||||
|
items: RedeemCodeBatch[]
|
||||||
|
total: number
|
||||||
|
limit: number
|
||||||
|
offset: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RedeemCodeListResponse {
|
||||||
|
batch: RedeemCodeBatch
|
||||||
|
items: RedeemCodeRecord[]
|
||||||
|
total: number
|
||||||
|
limit: number
|
||||||
|
offset: number
|
||||||
|
}
|
||||||
|
|
||||||
export const adminPaymentsApi = {
|
export const adminPaymentsApi = {
|
||||||
async listOrders(params?: {
|
async listOrders(params?: {
|
||||||
status?: string
|
status?: string
|
||||||
@@ -90,4 +159,72 @@ export const adminPaymentsApi = {
|
|||||||
const response = await apiClient.get<AdminPaymentCallbacksResponse>('/api/admin/payments/callbacks', { params })
|
const response = await apiClient.get<AdminPaymentCallbacksResponse>('/api/admin/payments/callbacks', { params })
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async listRedeemCodeBatches(params?: {
|
||||||
|
status?: string
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
}): Promise<RedeemCodeBatchListResponse> {
|
||||||
|
const response = await apiClient.get<RedeemCodeBatchListResponse>(
|
||||||
|
'/api/admin/payments/redeem-codes/batches',
|
||||||
|
{ params }
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async createRedeemCodeBatch(
|
||||||
|
payload: CreateRedeemCodeBatchRequest
|
||||||
|
): Promise<CreateRedeemCodeBatchResponse> {
|
||||||
|
const response = await apiClient.post<CreateRedeemCodeBatchResponse>(
|
||||||
|
'/api/admin/payments/redeem-codes/batches',
|
||||||
|
payload
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async getRedeemCodeBatch(batchId: string): Promise<{ batch: RedeemCodeBatch }> {
|
||||||
|
const response = await apiClient.get<{ batch: RedeemCodeBatch }>(
|
||||||
|
`/api/admin/payments/redeem-codes/batches/${batchId}`
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async listRedeemCodes(
|
||||||
|
batchId: string,
|
||||||
|
params?: {
|
||||||
|
status?: string
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
}
|
||||||
|
): Promise<RedeemCodeListResponse> {
|
||||||
|
const response = await apiClient.get<RedeemCodeListResponse>(
|
||||||
|
`/api/admin/payments/redeem-codes/batches/${batchId}/codes`,
|
||||||
|
{ params }
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async disableRedeemCodeBatch(batchId: string): Promise<{ batch: RedeemCodeBatch }> {
|
||||||
|
const response = await apiClient.post<{ batch: RedeemCodeBatch }>(
|
||||||
|
`/api/admin/payments/redeem-codes/batches/${batchId}/disable`,
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteRedeemCodeBatch(batchId: string): Promise<{ batch: RedeemCodeBatch }> {
|
||||||
|
const response = await apiClient.post<{ batch: RedeemCodeBatch }>(
|
||||||
|
`/api/admin/payments/redeem-codes/batches/${batchId}/delete`,
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async disableRedeemCode(codeId: string): Promise<{ code: RedeemCodeRecord }> {
|
||||||
|
const response = await apiClient.post<{ code: RedeemCodeRecord }>(
|
||||||
|
`/api/admin/payments/redeem-codes/codes/${codeId}/disable`,
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,6 +150,17 @@ export interface WalletRefundCreateRequest {
|
|||||||
idempotency_key?: string
|
idempotency_key?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WalletRedeemRequest {
|
||||||
|
code: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WalletRedeemResponse {
|
||||||
|
order: PaymentOrder
|
||||||
|
wallet: WalletSummary
|
||||||
|
amount_usd: number
|
||||||
|
batch_name: string
|
||||||
|
}
|
||||||
|
|
||||||
export const walletApi = {
|
export const walletApi = {
|
||||||
async getBalance(): Promise<WalletBalanceResponse> {
|
async getBalance(): Promise<WalletBalanceResponse> {
|
||||||
const response = await apiClient.get<WalletBalanceResponse>('/api/wallet/balance')
|
const response = await apiClient.get<WalletBalanceResponse>('/api/wallet/balance')
|
||||||
@@ -213,4 +224,9 @@ export const walletApi = {
|
|||||||
const response = await apiClient.post<RefundRequest>('/api/wallet/refunds', payload)
|
const response = await apiClient.post<RefundRequest>('/api/wallet/refunds', payload)
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async redeemCode(payload: WalletRedeemRequest): Promise<WalletRedeemResponse> {
|
||||||
|
const response = await apiClient.post<WalletRedeemResponse>('/api/wallet/redeem', payload)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<div class="px-5 py-5">
|
<div class="px-5 py-5">
|
||||||
<Tabs v-model="activeTab">
|
<Tabs v-model="activeTab">
|
||||||
<TabsList class="tabs-button-list grid w-full max-w-[760px] grid-cols-4">
|
<TabsList class="tabs-button-list grid w-full max-w-[960px] grid-cols-5">
|
||||||
<TabsTrigger value="ledger">
|
<TabsTrigger value="ledger">
|
||||||
资金流水
|
资金流水
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@@ -27,6 +27,9 @@
|
|||||||
<TabsTrigger value="callbacks">
|
<TabsTrigger value="callbacks">
|
||||||
回调日志
|
回调日志
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="redeem_codes">
|
||||||
|
兑换码
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent
|
<TabsContent
|
||||||
@@ -617,6 +620,334 @@
|
|||||||
@update:page-size="handleCallbackPageSizeChange"
|
@update:page-size="handleCallbackPageSizeChange"
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent
|
||||||
|
value="redeem_codes"
|
||||||
|
class="mt-5 space-y-5"
|
||||||
|
>
|
||||||
|
<div class="rounded-2xl border border-border/60 bg-background p-4 space-y-4">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-semibold">
|
||||||
|
批量生成兑换码
|
||||||
|
</h4>
|
||||||
|
<p class="text-xs text-muted-foreground mt-1">
|
||||||
|
生成后本会话可切换显示明文;页面刷新后仅保留脱敏码。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<RefreshButton
|
||||||
|
:loading="loadingRedeemBatches || loadingRedeemCodes"
|
||||||
|
@click="loadRedeemCodeBatches"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3 lg:grid-cols-4">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label>批次名称</Label>
|
||||||
|
<Input v-model="redeemBatchForm.name" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label>面额 (USD)</Label>
|
||||||
|
<Input
|
||||||
|
v-model.number="redeemBatchForm.amount_usd"
|
||||||
|
type="number"
|
||||||
|
min="0.01"
|
||||||
|
step="0.01"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label>生成数量</Label>
|
||||||
|
<Input
|
||||||
|
v-model.number="redeemBatchForm.total_count"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label>过期时间(可选)</Label>
|
||||||
|
<Input
|
||||||
|
v-model="redeemBatchForm.expires_at"
|
||||||
|
type="datetime-local"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label>备注(可选)</Label>
|
||||||
|
<Textarea
|
||||||
|
v-model="redeemBatchForm.description"
|
||||||
|
rows="3"
|
||||||
|
placeholder="例如:五一活动 / 线下渠道 / KOC 发放"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
:disabled="!canExportLatestGeneratedRedeemCodes"
|
||||||
|
@click="exportLatestGeneratedRedeemCodes"
|
||||||
|
>
|
||||||
|
导出最近生成
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
:disabled="submittingRedeemBatch"
|
||||||
|
@click="submitRedeemCodeBatch"
|
||||||
|
>
|
||||||
|
{{ submittingRedeemBatch ? '生成中...' : '生成兑换码' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="latestGeneratedRedeemBatch"
|
||||||
|
class="rounded-xl border border-border/60 bg-muted/20 p-3 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
最近生成批次:
|
||||||
|
<span class="font-medium text-foreground">{{ latestGeneratedRedeemBatch.name }}</span>
|
||||||
|
· {{ latestGeneratedRedeemCodes.length }} 个兑换码
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-5 xl:grid-cols-[1.1fr_1fr]">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<Select v-model="redeemBatchStatusFilter">
|
||||||
|
<SelectTrigger class="w-[180px]">
|
||||||
|
<SelectValue placeholder="批次状态" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">
|
||||||
|
全部状态
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="active">
|
||||||
|
可用
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="disabled">
|
||||||
|
已停用
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<div class="text-sm text-muted-foreground">
|
||||||
|
共 {{ redeemBatchTotal }} 个批次
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-border/60 overflow-hidden bg-background">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>批次</TableHead>
|
||||||
|
<TableHead>面额</TableHead>
|
||||||
|
<TableHead>数量</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead class="text-right">
|
||||||
|
操作
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow
|
||||||
|
v-for="batch in redeemBatches"
|
||||||
|
:key="batch.id"
|
||||||
|
class="hover:bg-muted/20"
|
||||||
|
:class="batch.id === selectedRedeemBatchId ? 'bg-muted/30 ring-1 ring-border/60' : ''"
|
||||||
|
>
|
||||||
|
<TableCell class="min-w-[220px]">
|
||||||
|
<div class="text-sm font-medium">
|
||||||
|
{{ batch.name }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-muted-foreground mt-1">
|
||||||
|
过期: {{ formatDateTime(batch.expires_at) }}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="tabular-nums">
|
||||||
|
{{ formatCurrency(batch.amount_usd) }}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="text-xs text-muted-foreground">
|
||||||
|
{{ batch.redeemed_count }} / {{ batch.total_count }} 已使用
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge :variant="batch.status === 'active' ? 'success' : 'secondary'">
|
||||||
|
{{ batch.status === 'active' ? '可用' : '已停用' }}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="text-right">
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
:variant="batch.id === selectedRedeemBatchId ? 'default' : 'outline'"
|
||||||
|
@click="selectRedeemBatch(batch)"
|
||||||
|
>
|
||||||
|
{{ batch.id === selectedRedeemBatchId ? '当前查看' : '查看码' }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="batch.status === 'active'"
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
@click="disableRedeemBatch(batch.id)"
|
||||||
|
>
|
||||||
|
停用批次
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="batch.status === 'disabled'"
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
:disabled="batch.redeemed_count > 0"
|
||||||
|
@click="deleteRedeemBatch(batch)"
|
||||||
|
>
|
||||||
|
删除批次
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow v-if="!loadingRedeemBatches && redeemBatches.length === 0">
|
||||||
|
<TableCell
|
||||||
|
colspan="5"
|
||||||
|
class="py-10"
|
||||||
|
>
|
||||||
|
<EmptyState
|
||||||
|
title="暂无兑换码批次"
|
||||||
|
description="创建批次后会在这里显示"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
:current="redeemBatchPage"
|
||||||
|
:total="redeemBatchTotal"
|
||||||
|
:page-size="redeemBatchPageSize"
|
||||||
|
@update:current="handleRedeemBatchPageChange"
|
||||||
|
@update:page-size="handleRedeemBatchPageSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref="redeemCodesPanelRef"
|
||||||
|
class="space-y-4"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-semibold">
|
||||||
|
{{ currentRedeemBatch?.name || '兑换码列表' }}
|
||||||
|
</h4>
|
||||||
|
<p class="text-xs text-muted-foreground mt-1">
|
||||||
|
{{ currentRedeemBatch ? `面额 ${formatCurrency(currentRedeemBatch.amount_usd)} · 剩余 ${currentRedeemBatch.active_count}` : '先从左侧选择一个批次' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs text-muted-foreground">显示明文</span>
|
||||||
|
<Switch
|
||||||
|
:model-value="showPlainRedeemCodes"
|
||||||
|
:disabled="!canRevealPlainRedeemCodes"
|
||||||
|
@update:model-value="showPlainRedeemCodes = Boolean($event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select v-model="redeemCodeStatusFilter">
|
||||||
|
<SelectTrigger class="w-[180px]">
|
||||||
|
<SelectValue placeholder="码状态" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">
|
||||||
|
全部状态
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="active">
|
||||||
|
可用
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="disabled">
|
||||||
|
已停用
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="redeemed">
|
||||||
|
已兑换
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
{{
|
||||||
|
canRevealPlainRedeemCodes
|
||||||
|
? '当前批次属于本次生成,已支持明文显示开关。'
|
||||||
|
: '仅当前会话内最近生成的一批兑换码支持明文显示;其余批次仅显示脱敏码。'
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-border/60 overflow-hidden bg-background">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>兑换码</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead>兑换用户</TableHead>
|
||||||
|
<TableHead>关联订单</TableHead>
|
||||||
|
<TableHead class="text-right">
|
||||||
|
操作
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow
|
||||||
|
v-for="code in redeemCodes"
|
||||||
|
:key="code.id"
|
||||||
|
>
|
||||||
|
<TableCell class="font-mono text-xs">
|
||||||
|
{{ displayRedeemCode(code) }}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge :variant="redeemCodeStatusBadge(code.status)">
|
||||||
|
{{ redeemCodeStatusLabel(code.status) }}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="text-xs text-muted-foreground">
|
||||||
|
{{ code.redeemed_by_user_name || code.redeemed_by_user_id || '-' }}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="font-mono text-xs">
|
||||||
|
{{ code.redeemed_order_no || code.redeemed_payment_order_id || '-' }}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="text-right">
|
||||||
|
<Button
|
||||||
|
v-if="code.status === 'active'"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
@click="disableRedeemCode(code.id)"
|
||||||
|
>
|
||||||
|
停用
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow v-if="!loadingRedeemCodes && redeemCodes.length === 0">
|
||||||
|
<TableCell
|
||||||
|
colspan="5"
|
||||||
|
class="py-10"
|
||||||
|
>
|
||||||
|
<EmptyState
|
||||||
|
title="暂无兑换码"
|
||||||
|
description="选择左侧批次后会显示兑换码明细"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
:current="redeemCodePage"
|
||||||
|
:total="redeemCodeTotal"
|
||||||
|
:page-size="redeemCodePageSize"
|
||||||
|
@update:current="handleRedeemCodePageChange"
|
||||||
|
@update:page-size="handleRedeemCodePageSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -1007,7 +1338,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
@@ -1023,6 +1354,7 @@ import {
|
|||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
|
Switch,
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCell,
|
TableCell,
|
||||||
@@ -1033,6 +1365,7 @@ import {
|
|||||||
TabsContent,
|
TabsContent,
|
||||||
TabsList,
|
TabsList,
|
||||||
TabsTrigger,
|
TabsTrigger,
|
||||||
|
Textarea,
|
||||||
} from '@/components/ui'
|
} from '@/components/ui'
|
||||||
import { EmptyState } from '@/components/common'
|
import { EmptyState } from '@/components/common'
|
||||||
import { X } from 'lucide-vue-next'
|
import { X } from 'lucide-vue-next'
|
||||||
@@ -1041,7 +1374,12 @@ import {
|
|||||||
type AdminGlobalRefund,
|
type AdminGlobalRefund,
|
||||||
type AdminLedgerTransaction,
|
type AdminLedgerTransaction,
|
||||||
} from '@/api/admin-wallets'
|
} from '@/api/admin-wallets'
|
||||||
import { adminPaymentsApi, type PaymentCallbackRecord } from '@/api/admin-payments'
|
import {
|
||||||
|
adminPaymentsApi,
|
||||||
|
type PaymentCallbackRecord,
|
||||||
|
type RedeemCodeBatch,
|
||||||
|
type RedeemCodeRecord,
|
||||||
|
} from '@/api/admin-payments'
|
||||||
import type { PaymentOrder } from '@/api/wallet'
|
import type { PaymentOrder } from '@/api/wallet'
|
||||||
import { parseApiError } from '@/utils/errorParser'
|
import { parseApiError } from '@/utils/errorParser'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
@@ -1062,7 +1400,7 @@ import {
|
|||||||
walletTransactionReasonLabel,
|
walletTransactionReasonLabel,
|
||||||
} from '@/utils/walletDisplay'
|
} from '@/utils/walletDisplay'
|
||||||
|
|
||||||
type WalletManagementTab = 'ledger' | 'refunds' | 'orders' | 'callbacks'
|
type WalletManagementTab = 'ledger' | 'refunds' | 'orders' | 'callbacks' | 'redeem_codes'
|
||||||
type LedgerCategory = 'recharge' | 'gift' | 'adjust' | 'refund'
|
type LedgerCategory = 'recharge' | 'gift' | 'adjust' | 'refund'
|
||||||
type LedgerReasonOption = {
|
type LedgerReasonOption = {
|
||||||
value: string
|
value: string
|
||||||
@@ -1092,8 +1430,11 @@ const loadingLedger = ref(false)
|
|||||||
const loadingRefunds = ref(false)
|
const loadingRefunds = ref(false)
|
||||||
const loadingOrders = ref(false)
|
const loadingOrders = ref(false)
|
||||||
const loadingCallbacks = ref(false)
|
const loadingCallbacks = ref(false)
|
||||||
|
const loadingRedeemBatches = ref(false)
|
||||||
|
const loadingRedeemCodes = ref(false)
|
||||||
const submittingRefundAction = ref(false)
|
const submittingRefundAction = ref(false)
|
||||||
const submittingOrderAction = ref(false)
|
const submittingOrderAction = ref(false)
|
||||||
|
const submittingRedeemBatch = ref(false)
|
||||||
|
|
||||||
const ledgerItems = ref<AdminLedgerTransaction[]>([])
|
const ledgerItems = ref<AdminLedgerTransaction[]>([])
|
||||||
const ledgerTotal = ref(0)
|
const ledgerTotal = ref(0)
|
||||||
@@ -1129,6 +1470,43 @@ const callbackPage = ref(1)
|
|||||||
const callbackPageSize = ref(20)
|
const callbackPageSize = ref(20)
|
||||||
const callbackMethodFilter = ref('all')
|
const callbackMethodFilter = ref('all')
|
||||||
|
|
||||||
|
const redeemBatches = ref<RedeemCodeBatch[]>([])
|
||||||
|
const redeemBatchTotal = ref(0)
|
||||||
|
const redeemBatchPage = ref(1)
|
||||||
|
const redeemBatchPageSize = ref(20)
|
||||||
|
const redeemBatchStatusFilter = ref('all')
|
||||||
|
|
||||||
|
const redeemCodes = ref<RedeemCodeRecord[]>([])
|
||||||
|
const redeemCodeTotal = ref(0)
|
||||||
|
const redeemCodePage = ref(1)
|
||||||
|
const redeemCodePageSize = ref(20)
|
||||||
|
const redeemCodeStatusFilter = ref('all')
|
||||||
|
const selectedRedeemBatchId = ref<string | null>(null)
|
||||||
|
const currentRedeemBatch = ref<RedeemCodeBatch | null>(null)
|
||||||
|
const latestGeneratedRedeemBatch = ref<RedeemCodeBatch | null>(null)
|
||||||
|
const latestGeneratedRedeemCodes = ref<Array<{ id: string; code: string; masked_code: string }>>([])
|
||||||
|
const showPlainRedeemCodes = ref(false)
|
||||||
|
const redeemCodesPanelRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
const redeemBatchForm = reactive({
|
||||||
|
name: '',
|
||||||
|
amount_usd: 10,
|
||||||
|
total_count: 20,
|
||||||
|
expires_at: '',
|
||||||
|
description: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const canRevealPlainRedeemCodes = computed(
|
||||||
|
() =>
|
||||||
|
!!currentRedeemBatch.value &&
|
||||||
|
currentRedeemBatch.value.id === latestGeneratedRedeemBatch.value?.id &&
|
||||||
|
latestGeneratedRedeemCodes.value.length > 0
|
||||||
|
)
|
||||||
|
|
||||||
|
const canExportLatestGeneratedRedeemCodes = computed(
|
||||||
|
() => !!latestGeneratedRedeemBatch.value && latestGeneratedRedeemCodes.value.length > 0
|
||||||
|
)
|
||||||
|
|
||||||
const walletMetaMap = ref<Record<string, { ownerName: string; ownerType: 'user' | 'api_key' }>>({})
|
const walletMetaMap = ref<Record<string, { ownerName: string; ownerType: 'user' | 'api_key' }>>({})
|
||||||
|
|
||||||
const showLedgerDrawer = ref(false)
|
const showLedgerDrawer = ref(false)
|
||||||
@@ -1188,6 +1566,22 @@ watch(callbackMethodFilter, () => {
|
|||||||
void loadCallbacks()
|
void loadCallbacks()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(redeemBatchStatusFilter, () => {
|
||||||
|
redeemBatchPage.value = 1
|
||||||
|
void loadRedeemCodeBatches()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(redeemCodeStatusFilter, () => {
|
||||||
|
redeemCodePage.value = 1
|
||||||
|
void loadRedeemCodes()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(canRevealPlainRedeemCodes, (enabled) => {
|
||||||
|
if (!enabled) {
|
||||||
|
showPlainRedeemCodes.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => route.query.tab,
|
() => route.query.tab,
|
||||||
(tab) => {
|
(tab) => {
|
||||||
@@ -1206,11 +1600,12 @@ onMounted(async () => {
|
|||||||
loadRefunds(),
|
loadRefunds(),
|
||||||
loadOrders(),
|
loadOrders(),
|
||||||
loadCallbacks(),
|
loadCallbacks(),
|
||||||
|
loadRedeemCodeBatches(),
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
function isValidTab(tab: unknown): tab is WalletManagementTab {
|
function isValidTab(tab: unknown): tab is WalletManagementTab {
|
||||||
return tab === 'ledger' || tab === 'refunds' || tab === 'orders' || tab === 'callbacks'
|
return tab === 'ledger' || tab === 'refunds' || tab === 'orders' || tab === 'callbacks' || tab === 'redeem_codes'
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadWalletMetaMap() {
|
async function loadWalletMetaMap() {
|
||||||
@@ -1316,6 +1711,202 @@ async function loadCallbacks() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadRedeemCodeBatches() {
|
||||||
|
loadingRedeemBatches.value = true
|
||||||
|
try {
|
||||||
|
const offset = (redeemBatchPage.value - 1) * redeemBatchPageSize.value
|
||||||
|
const resp = await adminPaymentsApi.listRedeemCodeBatches({
|
||||||
|
status: redeemBatchStatusFilter.value !== 'all' ? redeemBatchStatusFilter.value : undefined,
|
||||||
|
limit: redeemBatchPageSize.value,
|
||||||
|
offset,
|
||||||
|
})
|
||||||
|
redeemBatches.value = resp.items
|
||||||
|
redeemBatchTotal.value = resp.total
|
||||||
|
|
||||||
|
if (selectedRedeemBatchId.value) {
|
||||||
|
const latest = resp.items.find(item => item.id === selectedRedeemBatchId.value)
|
||||||
|
if (latest) {
|
||||||
|
currentRedeemBatch.value = latest
|
||||||
|
await loadRedeemCodes(latest.id)
|
||||||
|
} else {
|
||||||
|
selectedRedeemBatchId.value = null
|
||||||
|
currentRedeemBatch.value = null
|
||||||
|
redeemCodes.value = []
|
||||||
|
redeemCodeTotal.value = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error('加载兑换码批次失败:', error)
|
||||||
|
showError(parseApiError(error, '加载兑换码批次失败'))
|
||||||
|
} finally {
|
||||||
|
loadingRedeemBatches.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRedeemCodes(batchId = selectedRedeemBatchId.value || undefined) {
|
||||||
|
if (!batchId) {
|
||||||
|
redeemCodes.value = []
|
||||||
|
redeemCodeTotal.value = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loadingRedeemCodes.value = true
|
||||||
|
try {
|
||||||
|
const offset = (redeemCodePage.value - 1) * redeemCodePageSize.value
|
||||||
|
const resp = await adminPaymentsApi.listRedeemCodes(batchId, {
|
||||||
|
status: redeemCodeStatusFilter.value !== 'all' ? redeemCodeStatusFilter.value : undefined,
|
||||||
|
limit: redeemCodePageSize.value,
|
||||||
|
offset,
|
||||||
|
})
|
||||||
|
currentRedeemBatch.value = resp.batch
|
||||||
|
selectedRedeemBatchId.value = resp.batch.id
|
||||||
|
redeemCodes.value = resp.items
|
||||||
|
redeemCodeTotal.value = resp.total
|
||||||
|
} catch (error) {
|
||||||
|
log.error('加载兑换码列表失败:', error)
|
||||||
|
showError(parseApiError(error, '加载兑换码列表失败'))
|
||||||
|
} finally {
|
||||||
|
loadingRedeemCodes.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectRedeemBatch(batch: RedeemCodeBatch) {
|
||||||
|
currentRedeemBatch.value = batch
|
||||||
|
selectedRedeemBatchId.value = batch.id
|
||||||
|
redeemCodePage.value = 1
|
||||||
|
await loadRedeemCodes(batch.id)
|
||||||
|
await nextTick()
|
||||||
|
redeemCodesPanelRef.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportRedeemCodesCsv(batch: RedeemCodeBatch, codes: Array<{ id: string; code: string; masked_code: string }>) {
|
||||||
|
const header = ['id', 'batch_name', 'code', 'masked_code']
|
||||||
|
const rows = codes.map(code => [code.id, batch.name, code.code, code.masked_code])
|
||||||
|
const csv = [header, ...rows]
|
||||||
|
.map(row => row.map(cell => `"${String(cell).replaceAll('"', '""')}"`).join(','))
|
||||||
|
.join('\n')
|
||||||
|
const blob = new Blob([`\uFEFF${csv}`], { type: 'text/csv;charset=utf-8;' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = `redeem-codes-${batch.name}-${batch.id}.csv`
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitRedeemCodeBatch() {
|
||||||
|
if (!redeemBatchForm.name.trim()) {
|
||||||
|
showError('请填写批次名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!redeemBatchForm.amount_usd || redeemBatchForm.amount_usd <= 0) {
|
||||||
|
showError('请填写有效面额')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!redeemBatchForm.total_count || redeemBatchForm.total_count <= 0) {
|
||||||
|
showError('请填写有效数量')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
submittingRedeemBatch.value = true
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: redeemBatchForm.name.trim(),
|
||||||
|
amount_usd: redeemBatchForm.amount_usd,
|
||||||
|
total_count: redeemBatchForm.total_count,
|
||||||
|
expires_at: redeemBatchForm.expires_at ? new Date(redeemBatchForm.expires_at).toISOString() : undefined,
|
||||||
|
description: redeemBatchForm.description.trim() || undefined,
|
||||||
|
}
|
||||||
|
const resp = await adminPaymentsApi.createRedeemCodeBatch(payload)
|
||||||
|
latestGeneratedRedeemBatch.value = resp.batch
|
||||||
|
latestGeneratedRedeemCodes.value = resp.codes
|
||||||
|
showPlainRedeemCodes.value = true
|
||||||
|
success('兑换码批次已创建')
|
||||||
|
redeemBatchForm.name = ''
|
||||||
|
redeemBatchForm.description = ''
|
||||||
|
redeemBatchForm.expires_at = ''
|
||||||
|
currentRedeemBatch.value = resp.batch
|
||||||
|
selectedRedeemBatchId.value = resp.batch.id
|
||||||
|
await loadRedeemCodeBatches()
|
||||||
|
await loadRedeemCodes(resp.batch.id)
|
||||||
|
} catch (error) {
|
||||||
|
log.error('创建兑换码批次失败:', error)
|
||||||
|
showError(parseApiError(error, '创建兑换码批次失败'))
|
||||||
|
} finally {
|
||||||
|
submittingRedeemBatch.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportLatestGeneratedRedeemCodes() {
|
||||||
|
if (!latestGeneratedRedeemBatch.value || latestGeneratedRedeemCodes.value.length === 0) {
|
||||||
|
showError('当前没有可导出的新生成兑换码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
exportRedeemCodesCsv(latestGeneratedRedeemBatch.value, latestGeneratedRedeemCodes.value)
|
||||||
|
success('CSV 已导出')
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayRedeemCode(code: RedeemCodeRecord) {
|
||||||
|
if (!showPlainRedeemCodes.value || !canRevealPlainRedeemCodes.value) {
|
||||||
|
return code.masked_code
|
||||||
|
}
|
||||||
|
return latestGeneratedRedeemCodes.value.find(item => item.id === code.id)?.code || code.masked_code
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disableRedeemBatch(batchId: string) {
|
||||||
|
try {
|
||||||
|
await adminPaymentsApi.disableRedeemCodeBatch(batchId)
|
||||||
|
success('批次已停用')
|
||||||
|
await loadRedeemCodeBatches()
|
||||||
|
} catch (error) {
|
||||||
|
log.error('停用兑换码批次失败:', error)
|
||||||
|
showError(parseApiError(error, '停用兑换码批次失败'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteRedeemBatch(batch: RedeemCodeBatch) {
|
||||||
|
if (batch.redeemed_count > 0) {
|
||||||
|
showError('已有兑换记录的批次不能删除')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!window.confirm(`确认删除批次「${batch.name}」吗?删除后无法恢复。`)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await adminPaymentsApi.deleteRedeemCodeBatch(batch.id)
|
||||||
|
success('批次已删除')
|
||||||
|
if (selectedRedeemBatchId.value === batch.id) {
|
||||||
|
selectedRedeemBatchId.value = null
|
||||||
|
currentRedeemBatch.value = null
|
||||||
|
redeemCodes.value = []
|
||||||
|
redeemCodeTotal.value = 0
|
||||||
|
showPlainRedeemCodes.value = false
|
||||||
|
}
|
||||||
|
if (latestGeneratedRedeemBatch.value?.id === batch.id) {
|
||||||
|
latestGeneratedRedeemBatch.value = null
|
||||||
|
latestGeneratedRedeemCodes.value = []
|
||||||
|
showPlainRedeemCodes.value = false
|
||||||
|
}
|
||||||
|
await loadRedeemCodeBatches()
|
||||||
|
} catch (error) {
|
||||||
|
log.error('删除兑换码批次失败:', error)
|
||||||
|
showError(parseApiError(error, '删除兑换码批次失败'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disableRedeemCode(codeId: string) {
|
||||||
|
try {
|
||||||
|
await adminPaymentsApi.disableRedeemCode(codeId)
|
||||||
|
success('兑换码已停用')
|
||||||
|
await Promise.all([loadRedeemCodes(), loadRedeemCodeBatches()])
|
||||||
|
} catch (error) {
|
||||||
|
log.error('停用兑换码失败:', error)
|
||||||
|
showError(parseApiError(error, '停用兑换码失败'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function orderWalletName(walletId: string) {
|
function orderWalletName(walletId: string) {
|
||||||
return walletMetaMap.value[walletId]?.ownerName || '未知钱包'
|
return walletMetaMap.value[walletId]?.ownerName || '未知钱包'
|
||||||
}
|
}
|
||||||
@@ -1568,6 +2159,28 @@ function handleCallbackPageSizeChange(size: number) {
|
|||||||
void loadCallbacks()
|
void loadCallbacks()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleRedeemBatchPageChange(page: number) {
|
||||||
|
redeemBatchPage.value = page
|
||||||
|
void loadRedeemCodeBatches()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRedeemBatchPageSizeChange(size: number) {
|
||||||
|
redeemBatchPageSize.value = size
|
||||||
|
redeemBatchPage.value = 1
|
||||||
|
void loadRedeemCodeBatches()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRedeemCodePageChange(page: number) {
|
||||||
|
redeemCodePage.value = page
|
||||||
|
void loadRedeemCodes()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRedeemCodePageSizeChange(size: number) {
|
||||||
|
redeemCodePageSize.value = size
|
||||||
|
redeemCodePage.value = 1
|
||||||
|
void loadRedeemCodes()
|
||||||
|
}
|
||||||
|
|
||||||
function ownerTypeLabel(ownerType: 'user' | 'api_key') {
|
function ownerTypeLabel(ownerType: 'user' | 'api_key') {
|
||||||
return ownerType === 'user' ? '用户钱包' : '独立密钥'
|
return ownerType === 'user' ? '用户钱包' : '独立密钥'
|
||||||
}
|
}
|
||||||
@@ -1587,6 +2200,20 @@ function formatDateTime(value: string | null | undefined) {
|
|||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function redeemCodeStatusLabel(status: string) {
|
||||||
|
if (status === 'active') return '可用'
|
||||||
|
if (status === 'disabled') return '已停用'
|
||||||
|
if (status === 'redeemed') return '已兑换'
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
function redeemCodeStatusBadge(status: string) {
|
||||||
|
if (status === 'active') return 'success'
|
||||||
|
if (status === 'disabled') return 'secondary'
|
||||||
|
if (status === 'redeemed') return 'outline'
|
||||||
|
return 'secondary'
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -56,6 +56,52 @@
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Card class="p-5 space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-base font-semibold">
|
||||||
|
兑换码充值
|
||||||
|
</h3>
|
||||||
|
<p class="text-xs text-muted-foreground mt-1">
|
||||||
|
输入卡密后会直接充值到钱包的充值余额
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<RefreshButton
|
||||||
|
:loading="loadingOrders || loadingTransactions"
|
||||||
|
@click="() => Promise.all([loadBalance(), loadOrders(), loadTransactions()])"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-[1fr_auto] gap-3">
|
||||||
|
<Input
|
||||||
|
v-model="redeemForm.code"
|
||||||
|
placeholder="输入兑换码,例如 ABCD-EFGH-IJKL-MNOP"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
:disabled="submittingRedeem"
|
||||||
|
@click="submitRedeem"
|
||||||
|
>
|
||||||
|
{{ submittingRedeem ? '兑换中...' : '立即兑换' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="latestRedeem"
|
||||||
|
class="rounded-xl border border-border/60 bg-muted/20 p-3 text-xs text-muted-foreground space-y-1.5"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
已兑换批次: <span class="font-medium text-foreground">{{ latestRedeem.batch_name }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
充值金额: <span class="font-medium text-foreground">{{ formatCurrency(latestRedeem.amount_usd) }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
关联订单: <span class="font-mono text-foreground">{{ latestRedeem.order.order_no }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<!-- TODO(wallet): 充值/退款用户主动操作入口暂未启用,待支付链路联调完成后再开放 -->
|
<!-- TODO(wallet): 充值/退款用户主动操作入口暂未启用,待支付链路联调完成后再开放 -->
|
||||||
<div
|
<div
|
||||||
v-if="ENABLE_WALLET_ACTION_FORMS"
|
v-if="ENABLE_WALLET_ACTION_FORMS"
|
||||||
@@ -572,6 +618,7 @@ import {
|
|||||||
type PaymentOrder,
|
type PaymentOrder,
|
||||||
type RefundRequest,
|
type RefundRequest,
|
||||||
type WalletBalanceResponse,
|
type WalletBalanceResponse,
|
||||||
|
type WalletRedeemResponse,
|
||||||
} from '@/api/wallet'
|
} from '@/api/wallet'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { parseApiError } from '@/utils/errorParser'
|
import { parseApiError } from '@/utils/errorParser'
|
||||||
@@ -601,11 +648,13 @@ const loadingInitial = ref(true)
|
|||||||
const loadingTransactions = ref(false)
|
const loadingTransactions = ref(false)
|
||||||
const loadingOrders = ref(false)
|
const loadingOrders = ref(false)
|
||||||
const loadingRefunds = ref(false)
|
const loadingRefunds = ref(false)
|
||||||
|
const submittingRedeem = ref(false)
|
||||||
const submittingRecharge = ref(false)
|
const submittingRecharge = ref(false)
|
||||||
const submittingRefund = ref(false)
|
const submittingRefund = ref(false)
|
||||||
|
|
||||||
const walletBalance = ref<WalletBalanceResponse | null>(null)
|
const walletBalance = ref<WalletBalanceResponse | null>(null)
|
||||||
const latestRecharge = ref<{ order: PaymentOrder; payment_instructions: Record<string, unknown> } | null>(null)
|
const latestRecharge = ref<{ order: PaymentOrder; payment_instructions: Record<string, unknown> } | null>(null)
|
||||||
|
const latestRedeem = ref<WalletRedeemResponse | null>(null)
|
||||||
|
|
||||||
const flowItems = ref<FlowItem[]>([])
|
const flowItems = ref<FlowItem[]>([])
|
||||||
const todayUsage = ref<DailyUsageRecord | null>(null)
|
const todayUsage = ref<DailyUsageRecord | null>(null)
|
||||||
@@ -638,6 +687,10 @@ const refundForm = reactive({
|
|||||||
reason: '',
|
reason: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const redeemForm = reactive({
|
||||||
|
code: '',
|
||||||
|
})
|
||||||
|
|
||||||
const refundableOrders = computed(() =>
|
const refundableOrders = computed(() =>
|
||||||
rechargeOrders.value.filter(o => (o.refundable_amount_usd || 0) > 0)
|
rechargeOrders.value.filter(o => (o.refundable_amount_usd || 0) > 0)
|
||||||
)
|
)
|
||||||
@@ -750,6 +803,29 @@ async function loadRefunds() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function submitRedeem() {
|
||||||
|
if (!redeemForm.code.trim()) {
|
||||||
|
showError('请输入兑换码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
submittingRedeem.value = true
|
||||||
|
try {
|
||||||
|
latestRedeem.value = await walletApi.redeemCode({
|
||||||
|
code: redeemForm.code.trim(),
|
||||||
|
})
|
||||||
|
redeemForm.code = ''
|
||||||
|
success('兑换成功')
|
||||||
|
await Promise.all([loadBalance(), loadOrders(), loadTransactions(), loadTodayCost()])
|
||||||
|
activeTab.value = 'orders'
|
||||||
|
} catch (error) {
|
||||||
|
log.error('兑换码充值失败:', error)
|
||||||
|
showError(parseApiError(error, '兑换码充值失败'))
|
||||||
|
} finally {
|
||||||
|
submittingRedeem.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function submitRecharge() {
|
async function submitRecharge() {
|
||||||
if (!rechargeForm.amount_usd || rechargeForm.amount_usd <= 0) {
|
if (!rechargeForm.amount_usd || rechargeForm.amount_usd <= 0) {
|
||||||
showError('请输入有效的充值金额')
|
showError('请输入有效的充值金额')
|
||||||
|
|||||||
Reference in New Issue
Block a user