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

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

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

* fix(payments): harden redeem code wallet credits

---------

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

View File

@@ -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
} }

View File

@@ -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(

View File

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

View File

@@ -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(&[]);

View File

@@ -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,
}; };

View File

@@ -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,

View File

@@ -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;

View File

@@ -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"),
),
}
}

View File

@@ -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()))
} }

View File

@@ -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();

View File

@@ -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,

View File

@@ -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"]

View File

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

View File

@@ -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,
); );

View File

@@ -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> {

View File

@@ -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"
{ {

View File

@@ -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,
) )

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

View File

@@ -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,

View File

@@ -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(

View File

@@ -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(

View File

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

View File

@@ -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();

View File

@@ -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$;

View File

@@ -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$;

View File

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

View File

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

View File

@@ -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

View File

@@ -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

View File

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

View File

@@ -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
},
} }

View File

@@ -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
},
} }

View File

@@ -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>

View File

@@ -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('请输入有效的充值金额')