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",
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 {
None
}

View File

@@ -358,12 +358,13 @@ pub(super) fn classify_public_support_route(
} else if method == http::Method::POST
&& matches!(
normalized_path,
"/api/wallet/recharge" | "/api/wallet/refunds"
"/api/wallet/recharge" | "/api/wallet/refunds" | "/api/wallet/redeem"
)
{
let route_kind = match normalized_path {
"/api/wallet/recharge" => "create_recharge_order",
"/api/wallet/refunds" => "create_refund",
"/api/wallet/redeem" => "redeem",
_ => "create_recharge_order",
};
Some(classified(

View File

@@ -136,3 +136,72 @@ fn classifies_admin_payments_callbacks_as_admin_proxy_route() {
);
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());
}
#[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]
fn classifies_announcement_unread_count_as_public_support_route() {
let headers = headers(&[]);

View File

@@ -52,16 +52,22 @@ pub(crate) use aether_data::repository::users::{
StoredUserPreferenceRecord, StoredUserSessionRecord,
};
use aether_data::repository::wallet::{
AdjustWalletBalanceInput, AdminPaymentOrderListQuery, AdminWalletLedgerQuery,
AdminWalletListQuery, AdminWalletRefundRequestListQuery, CompleteAdminWalletRefundInput,
AdjustWalletBalanceInput, AdminPaymentOrderListQuery, AdminRedeemCodeBatchListQuery,
AdminRedeemCodeListQuery, AdminWalletLedgerQuery, AdminWalletListQuery,
AdminWalletRefundRequestListQuery, CompleteAdminWalletRefundInput,
CreateAdminRedeemCodeBatchInput, CreateAdminRedeemCodeBatchResult,
CreateManualWalletRechargeInput, CreateWalletRechargeOrderInput,
CreateWalletRechargeOrderOutcome, CreateWalletRefundRequestInput,
CreateWalletRefundRequestOutcome, CreditAdminPaymentOrderInput, FailAdminWalletRefundInput,
ProcessAdminWalletRefundInput, ProcessPaymentCallbackInput, ProcessPaymentCallbackOutcome,
CreateWalletRefundRequestOutcome, CreditAdminPaymentOrderInput,
DeleteAdminRedeemCodeBatchInput, DisableAdminRedeemCodeBatchInput, DisableAdminRedeemCodeInput,
FailAdminWalletRefundInput, ProcessAdminWalletRefundInput, ProcessPaymentCallbackInput,
ProcessPaymentCallbackOutcome, RedeemWalletCodeInput, RedeemWalletCodeOutcome,
StoredAdminPaymentCallback, StoredAdminPaymentCallbackPage, StoredAdminPaymentOrder,
StoredAdminPaymentOrderPage, StoredAdminWalletLedgerPage, StoredAdminWalletListPage,
StoredAdminWalletRefund, StoredAdminWalletRefundPage, StoredAdminWalletRefundRequestPage,
StoredAdminWalletTransaction, StoredAdminWalletTransactionPage, StoredWalletDailyUsageLedger,
StoredAdminPaymentOrderPage, StoredAdminRedeemCode, StoredAdminRedeemCodeBatch,
StoredAdminRedeemCodeBatchPage, StoredAdminRedeemCodePage, StoredAdminWalletLedgerPage,
StoredAdminWalletListPage, StoredAdminWalletRefund, StoredAdminWalletRefundPage,
StoredAdminWalletRefundRequestPage, StoredAdminWalletTransaction,
StoredAdminWalletTransactionPage, StoredWalletDailyUsageLedger,
StoredWalletDailyUsageLedgerPage, StoredWalletSnapshot, WalletLookupKey, WalletMutationOutcome,
WalletReadRepository, WalletWriteRepository,
};

View File

@@ -1,15 +1,19 @@
use super::{
read_decision_trace, read_provider_transport_snapshot, read_request_candidate_trace,
AdjustWalletBalanceInput, AdminPaymentOrderListQuery, AdminWalletLedgerQuery,
AdminWalletListQuery, AdminWalletRefundRequestListQuery, AnnouncementListQuery,
CompleteAdminWalletRefundInput, CreateAnnouncementRecord, CreateManualWalletRechargeInput,
CreateWalletRechargeOrderInput, CreateWalletRechargeOrderOutcome,
CreateWalletRefundRequestInput, CreateWalletRefundRequestOutcome, CreditAdminPaymentOrderInput,
DataLayerError, DecisionTrace, FailAdminWalletRefundInput, GatewayDataState,
GatewayProviderTransportSnapshot, LocalVideoTaskReadResponse, ProcessAdminWalletRefundInput,
ProcessPaymentCallbackInput, ProcessPaymentCallbackOutcome, RedisStreamRunner,
RequestAuditBundle, RequestCandidateTrace, StoredAdminPaymentCallbackPage,
StoredAdminPaymentOrder, StoredAdminPaymentOrderPage, StoredAdminWalletLedgerPage,
AdjustWalletBalanceInput, AdminPaymentOrderListQuery, AdminRedeemCodeBatchListQuery,
AdminRedeemCodeListQuery, AdminWalletLedgerQuery, AdminWalletListQuery,
AdminWalletRefundRequestListQuery, AnnouncementListQuery, CompleteAdminWalletRefundInput,
CreateAdminRedeemCodeBatchInput, CreateAdminRedeemCodeBatchResult, CreateAnnouncementRecord,
CreateManualWalletRechargeInput, CreateWalletRechargeOrderInput,
CreateWalletRechargeOrderOutcome, CreateWalletRefundRequestInput,
CreateWalletRefundRequestOutcome, CreditAdminPaymentOrderInput, DataLayerError, DecisionTrace,
DeleteAdminRedeemCodeBatchInput, DisableAdminRedeemCodeBatchInput, DisableAdminRedeemCodeInput,
FailAdminWalletRefundInput, GatewayDataState, GatewayProviderTransportSnapshot,
LocalVideoTaskReadResponse, ProcessAdminWalletRefundInput, ProcessPaymentCallbackInput,
ProcessPaymentCallbackOutcome, RedeemWalletCodeInput, RedeemWalletCodeOutcome,
RedisStreamRunner, RequestAuditBundle, RequestCandidateTrace, StoredAdminPaymentCallbackPage,
StoredAdminPaymentOrder, StoredAdminPaymentOrderPage, StoredAdminRedeemCodeBatch,
StoredAdminRedeemCodeBatchPage, StoredAdminRedeemCodePage, StoredAdminWalletLedgerPage,
StoredAdminWalletListPage, StoredAdminWalletRefund, StoredAdminWalletRefundPage,
StoredAdminWalletRefundRequestPage, StoredAdminWalletTransaction,
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(
&self,
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(
&self,
input: UsageSettlementInput,

View File

@@ -6,6 +6,7 @@ mod callbacks;
mod orders;
#[path = "../../payment/postgres.rs"]
mod payment_postgres;
mod redeem_codes;
mod routes;
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,
callbacks::maybe_build_local_admin_payment_callbacks_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::GatewayError;
@@ -44,7 +45,30 @@ pub(super) async fn maybe_build_local_admin_payments_response(
&& path.ends_with("/fail")
&& path.matches('/').count() == 6)
|| (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 {
return Ok(None);
@@ -67,6 +91,16 @@ pub(super) async fn maybe_build_local_admin_payments_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()))
}

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::shared::{provider_key_health_summary, unix_secs_to_rfc3339};
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),
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));
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
}
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(
&self,
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};
#[path = "payment/gateway.rs"]
pub(super) mod payment_gateway;
#[path = "payment/postgres.rs"]
mod payment_postgres;
#[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 super::payment_gateway::{PaymentGatewayRegistry, VerifyCallbackInput};
use super::payment_shared::{
normalize_payment_callback_request, payment_callback_payment_method_from_path,
payment_callback_secret, payment_callback_signature_matches, PaymentCallbackRequest,
payment_callback_payment_method_from_path, payment_callback_secret, PaymentCallbackRequest,
PAYMENT_CALLBACK_SIGNATURE_HEADER, PAYMENT_CALLBACK_TOKEN_HEADER,
};
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) =
payment_callback_payment_method_from_path(&request_context.request_path)
else {
@@ -91,17 +81,28 @@ pub(super) async fn maybe_build_local_payment_callback_route_response(
false,
));
};
let signature_valid =
match payment_callback_signature_matches(&payload.payload, &signature, &secret) {
Ok(value) => value,
Err(err) => {
return Some(build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
err,
false,
));
}
};
let Some(adapter) = PaymentGatewayRegistry::get(&payment_method) else {
return Some(build_auth_error_response(
http::StatusCode::BAD_REQUEST,
"unsupported payment_method",
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() {
return Some(
@@ -109,8 +110,8 @@ pub(super) async fn maybe_build_local_payment_callback_route_response(
state,
&payment_method,
request_context,
&payload,
signature_valid,
&verified.normalized_payload,
verified.signature_valid,
)
.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(
&payment_method,
request_context,
&payload,
signature_valid,
&verified.normalized_payload,
verified.signature_valid,
)
.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";
#[derive(Debug, Deserialize)]
pub(super) struct PaymentCallbackRequest {
pub(super) callback_key: String,
pub(crate) struct PaymentCallbackRequest {
pub(crate) callback_key: String,
#[serde(default)]
pub(super) order_no: Option<String>,
pub(crate) order_no: Option<String>,
#[serde(default)]
pub(super) gateway_order_id: Option<String>,
pub(super) amount_usd: f64,
pub(crate) gateway_order_id: Option<String>,
pub(crate) amount_usd: f64,
#[serde(default)]
pub(super) pay_amount: Option<f64>,
pub(crate) pay_amount: Option<f64>,
#[serde(default)]
pub(super) pay_currency: Option<String>,
pub(crate) pay_currency: Option<String>,
#[serde(default)]
pub(super) exchange_rate: Option<f64>,
pub(crate) exchange_rate: Option<f64>,
#[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)]
pub(super) struct NormalizedPaymentCallbackRequest {
pub(super) callback_key: String,
pub(super) order_no: Option<String>,
pub(super) gateway_order_id: Option<String>,
pub(super) amount_usd: f64,
pub(super) pay_amount: Option<f64>,
pub(super) pay_currency: Option<String>,
pub(super) exchange_rate: Option<f64>,
pub(super) payload: serde_json::Value,
pub(crate) struct NormalizedPaymentCallbackRequest {
pub(crate) callback_key: String,
pub(crate) order_no: Option<String>,
pub(crate) gateway_order_id: Option<String>,
pub(crate) amount_usd: f64,
pub(crate) pay_amount: Option<f64>,
pub(crate) pay_currency: Option<String>,
pub(crate) exchange_rate: Option<f64>,
pub(crate) payload: serde_json::Value,
}
pub(super) fn payment_callback_secret() -> Option<String> {

View File

@@ -23,6 +23,8 @@ mod flow;
mod reads;
#[path = "wallet/recharge.rs"]
mod recharge;
#[path = "wallet/redeem.rs"]
mod redeem;
#[path = "wallet/refunds.rs"]
mod refunds;
use self::flow::handle_wallet_flow;
@@ -39,6 +41,7 @@ use self::recharge::{
pub(crate) use self::recharge::{
sanitize_wallet_gateway_response, wallet_payment_order_payload_from_row,
};
use self::redeem::handle_wallet_redeem;
use self::refunds::{
handle_wallet_create_refund, handle_wallet_refund_detail, handle_wallet_refunds_list,
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")
&& request_context.request_path == "/api/wallet/recharge"
{

View File

@@ -1,3 +1,6 @@
use super::super::support_payment::payment_gateway::{
CreateCheckoutSessionInput, PaymentGatewayRegistry,
};
use super::{
build_auth_error_response, build_auth_json_response, build_wallet_payload,
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> {
let trimmed = request_path.trim_end_matches('/');
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_no = wallet_build_order_no(now);
let expires_at = now + chrono::Duration::minutes(30);
let (gateway_order_id, gateway_response) =
match wallet_checkout_payload(&payload.payment_method, &order_no, expires_at) {
Ok(value) => value,
Err(detail) => {
return build_auth_error_response(
http::StatusCode::BAD_REQUEST,
detail,
false,
);
}
};
let Some(adapter) = PaymentGatewayRegistry::get(&payload.payment_method) else {
return build_auth_error_response(
http::StatusCode::BAD_REQUEST,
format!("unsupported payment_method: {}", payload.payment_method),
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(
order_id,
order_no,
@@ -380,8 +335,8 @@ pub(super) async fn handle_wallet_create_recharge(
0.0,
0.0,
payload.payment_method,
Some(gateway_order_id),
Some(gateway_response.clone()),
Some(checkout.gateway_order_id.clone()),
Some(checkout.gateway_response.clone()),
"pending".to_string(),
Some(now.to_rfc3339()),
None,
@@ -393,7 +348,7 @@ pub(super) async fn handle_wallet_create_recharge(
http::StatusCode::OK,
json!({
"order": order_payload,
"payment_instructions": sanitize_wallet_gateway_response(Some(gateway_response)),
"payment_instructions": sanitize_wallet_gateway_response(Some(checkout.gateway_response)),
}),
None,
);
@@ -405,13 +360,23 @@ pub(super) async fn handle_wallet_create_recharge(
let now = Utc::now();
let order_no = wallet_build_order_no(now);
let expires_at = now + chrono::Duration::minutes(30);
let (gateway_order_id, gateway_response) =
match wallet_checkout_payload(&payload.payment_method, &order_no, expires_at) {
Ok(value) => value,
Err(detail) => {
return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false);
}
};
let Some(adapter) = PaymentGatewayRegistry::get(&payload.payment_method) else {
return build_auth_error_response(
http::StatusCode::BAD_REQUEST,
format!("unsupported payment_method: {}", payload.payment_method),
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
.create_wallet_recharge_order(
aether_data::repository::wallet::CreateWalletRechargeOrderInput {
@@ -422,8 +387,8 @@ pub(super) async fn handle_wallet_create_recharge(
pay_currency: payload.pay_currency.clone(),
exchange_rate: payload.exchange_rate,
payment_method: payload.payment_method.clone(),
gateway_order_id,
gateway_response: gateway_response.clone(),
gateway_order_id: checkout.gateway_order_id,
gateway_response: checkout.gateway_response.clone(),
order_no,
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,
json!({
"order": order_payload,
"payment_instructions": sanitize_wallet_gateway_response(Some(gateway_response)),
"payment_instructions": sanitize_wallet_gateway_response(Some(checkout.gateway_response)),
}),
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::PUT, Some("update_collector"))
| (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::PUT, Some("update_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"),
http::Method::POST,
Some("create_refund" | "create_recharge_order"),
Some("create_refund" | "create_recharge_order" | "redeem"),
) | (
Some("payment_callback"),
http::Method::POST,

View File

@@ -1,8 +1,9 @@
use aether_data::repository::wallet::{
AdminPaymentOrderListQuery, AdminWalletLedgerQuery, AdminWalletListQuery,
AdminWalletRefundRequestListQuery, StoredAdminPaymentCallback, StoredAdminPaymentOrder,
StoredAdminWalletLedgerItem, StoredAdminWalletListItem, StoredAdminWalletRefund,
StoredAdminWalletRefundRequestItem, StoredAdminWalletTransaction,
AdminPaymentOrderListQuery, AdminRedeemCodeBatchListQuery, AdminRedeemCodeListQuery,
AdminWalletLedgerQuery, AdminWalletListQuery, AdminWalletRefundRequestListQuery,
StoredAdminPaymentCallback, StoredAdminPaymentOrder, StoredAdminRedeemCodeBatch,
StoredAdminRedeemCodePage, StoredAdminWalletLedgerItem, StoredAdminWalletListItem,
StoredAdminWalletRefund, StoredAdminWalletRefundRequestItem, StoredAdminWalletTransaction,
};
use crate::{
@@ -386,6 +387,61 @@ impl AppState {
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(

View File

@@ -229,6 +229,126 @@ impl AppState {
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(

View File

@@ -1,6 +1,8 @@
use std::sync::{Arc, Mutex};
use aether_data::repository::users::InMemoryUserReadRepository;
use aether_data::repository::wallet::StoredWalletSnapshot;
use aether_data::repository::wallet::{InMemoryWalletRepository, WalletWriteRepository};
use axum::body::Body;
use axum::routing::any;
use axum::{extract::Request, Router};
@@ -750,3 +752,103 @@ async fn gateway_rejects_admin_payments_empty_order_identifier_locally_with_trus
gateway_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::{
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::provider_catalog::ProviderCatalogReadRepository;
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();
}
#[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]
async fn gateway_creates_wallet_recharge_orders_locally_without_proxying_upstream() {
let now = Utc::now();