mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
feat(payments): 增加兑换码与支付适配框架 (#299)
* feat(payments): 增加兑换码与支付适配框架 * fix(ci): 对齐 Rust 1.95 lint 与格式要求 * fix(payments): harden redeem code wallet credits --------- Co-authored-by: fawney19 <elky0401@gmail.com>
This commit is contained in:
@@ -6,6 +6,7 @@ mod callbacks;
|
||||
mod orders;
|
||||
#[path = "../../payment/postgres.rs"]
|
||||
mod payment_postgres;
|
||||
mod redeem_codes;
|
||||
mod routes;
|
||||
mod shared;
|
||||
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
use super::{
|
||||
admin_payment_operator_id, build_admin_payment_order_not_found_response,
|
||||
build_admin_payments_backend_unavailable_response, build_admin_payments_bad_request_response,
|
||||
parse_admin_payments_limit, parse_admin_payments_offset,
|
||||
};
|
||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||
use crate::handlers::admin::shared::{
|
||||
attach_admin_audit_response, query_param_value, unix_secs_to_rfc3339,
|
||||
};
|
||||
use crate::GatewayError;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct AdminRedeemCodeBatchCreateRequest {
|
||||
pub(super) name: String,
|
||||
pub(super) amount_usd: f64,
|
||||
pub(super) total_count: usize,
|
||||
#[serde(default)]
|
||||
pub(super) expires_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(super) description: Option<String>,
|
||||
}
|
||||
|
||||
fn normalize_required_text(
|
||||
value: &str,
|
||||
field_name: &str,
|
||||
max_len: usize,
|
||||
) -> Result<String, String> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(format!("{field_name} 不能为空"));
|
||||
}
|
||||
if trimmed.chars().count() > max_len {
|
||||
return Err(format!("{field_name} 长度不能超过 {max_len}"));
|
||||
}
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
fn normalize_optional_text(
|
||||
value: Option<String>,
|
||||
field_name: &str,
|
||||
max_len: usize,
|
||||
) -> Result<Option<String>, String> {
|
||||
match value {
|
||||
Some(value) => {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
if trimmed.chars().count() > max_len {
|
||||
return Err(format!("{field_name} 长度不能超过 {max_len}"));
|
||||
}
|
||||
Ok(Some(trimmed.to_string()))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_batch_id_from_detail_path(path: &str) -> Option<String> {
|
||||
path.trim_end_matches('/')
|
||||
.strip_prefix("/api/admin/payments/redeem-codes/batches/")?
|
||||
.split('/')
|
||||
.next()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty() && !value.contains('/'))
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn parse_batch_id_from_suffix_path(path: &str, suffix: &str) -> Option<String> {
|
||||
path.trim_end_matches('/')
|
||||
.strip_prefix("/api/admin/payments/redeem-codes/batches/")?
|
||||
.strip_suffix(suffix)
|
||||
.map(|value| value.trim().trim_matches('/').to_string())
|
||||
.filter(|value| !value.is_empty() && !value.contains('/'))
|
||||
}
|
||||
|
||||
fn parse_code_id_from_suffix_path(path: &str, suffix: &str) -> Option<String> {
|
||||
path.trim_end_matches('/')
|
||||
.strip_prefix("/api/admin/payments/redeem-codes/codes/")?
|
||||
.strip_suffix(suffix)
|
||||
.map(|value| value.trim().trim_matches('/').to_string())
|
||||
.filter(|value| !value.is_empty() && !value.contains('/'))
|
||||
}
|
||||
|
||||
fn parse_batch_expires_at(value: Option<String>) -> Result<Option<u64>, String> {
|
||||
let Some(value) = normalize_optional_text(value, "expires_at", 64)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let parsed = chrono::DateTime::parse_from_rfc3339(&value)
|
||||
.map_err(|_| "expires_at 必须为 ISO8601 时间".to_string())?;
|
||||
Ok(Some(parsed.timestamp().max(0) as u64))
|
||||
}
|
||||
|
||||
fn build_redeem_code_not_found_response(detail: &str) -> Response<Body> {
|
||||
(
|
||||
http::StatusCode::NOT_FOUND,
|
||||
Json(json!({ "detail": detail })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn build_batch_payload(
|
||||
batch: &aether_data::repository::wallet::StoredAdminRedeemCodeBatch,
|
||||
) -> serde_json::Value {
|
||||
json!({
|
||||
"id": batch.id,
|
||||
"name": batch.name,
|
||||
"amount_usd": batch.amount_usd,
|
||||
"currency": batch.currency,
|
||||
"balance_bucket": batch.balance_bucket,
|
||||
"total_count": batch.total_count,
|
||||
"redeemed_count": batch.redeemed_count,
|
||||
"active_count": batch.active_count,
|
||||
"status": batch.status,
|
||||
"description": batch.description,
|
||||
"created_by": batch.created_by,
|
||||
"expires_at": batch.expires_at_unix_secs.and_then(unix_secs_to_rfc3339),
|
||||
"created_at": unix_secs_to_rfc3339(batch.created_at_unix_ms),
|
||||
"updated_at": unix_secs_to_rfc3339(batch.updated_at_unix_secs),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_code_payload(
|
||||
code: &aether_data::repository::wallet::StoredAdminRedeemCode,
|
||||
) -> serde_json::Value {
|
||||
json!({
|
||||
"id": code.id,
|
||||
"batch_id": code.batch_id,
|
||||
"batch_name": code.batch_name,
|
||||
"code_prefix": code.code_prefix,
|
||||
"code_suffix": code.code_suffix,
|
||||
"masked_code": code.masked_code,
|
||||
"status": code.status,
|
||||
"redeemed_by_user_id": code.redeemed_by_user_id,
|
||||
"redeemed_by_user_name": code.redeemed_by_user_name,
|
||||
"redeemed_wallet_id": code.redeemed_wallet_id,
|
||||
"redeemed_payment_order_id": code.redeemed_payment_order_id,
|
||||
"redeemed_order_no": code.redeemed_order_no,
|
||||
"redeemed_at": code.redeemed_at_unix_secs.and_then(unix_secs_to_rfc3339),
|
||||
"disabled_by": code.disabled_by,
|
||||
"expires_at": code.expires_at_unix_secs.and_then(unix_secs_to_rfc3339),
|
||||
"created_at": unix_secs_to_rfc3339(code.created_at_unix_ms),
|
||||
"updated_at": unix_secs_to_rfc3339(code.updated_at_unix_secs),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn maybe_build_local_admin_redeem_codes_response(
|
||||
state: &AdminAppState<'_>,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
request_body: Option<&axum::body::Bytes>,
|
||||
route_kind: Option<&str>,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
match route_kind {
|
||||
Some("list_redeem_code_batches") => Ok(Some(
|
||||
build_admin_redeem_code_batches_response(state, request_context).await?,
|
||||
)),
|
||||
Some("create_redeem_code_batch") => Ok(Some(
|
||||
build_admin_create_redeem_code_batch_response(state, request_context, request_body)
|
||||
.await?,
|
||||
)),
|
||||
Some("get_redeem_code_batch") => Ok(Some(
|
||||
build_admin_redeem_code_batch_detail_response(state, request_context).await?,
|
||||
)),
|
||||
Some("list_redeem_codes") => Ok(Some(
|
||||
build_admin_redeem_codes_response(state, request_context).await?,
|
||||
)),
|
||||
Some("disable_redeem_code_batch") => Ok(Some(
|
||||
build_admin_disable_redeem_code_batch_response(state, request_context).await?,
|
||||
)),
|
||||
Some("delete_redeem_code_batch") => Ok(Some(
|
||||
build_admin_delete_redeem_code_batch_response(state, request_context).await?,
|
||||
)),
|
||||
Some("disable_redeem_code") => Ok(Some(
|
||||
build_admin_disable_redeem_code_response(state, request_context).await?,
|
||||
)),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_admin_redeem_code_batches_response(
|
||||
state: &AdminAppState<'_>,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
) -> Result<Response<Body>, GatewayError> {
|
||||
let query = request_context.query_string();
|
||||
let limit = match parse_admin_payments_limit(query) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(build_admin_payments_bad_request_response(detail)),
|
||||
};
|
||||
let offset = match parse_admin_payments_offset(query) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(build_admin_payments_bad_request_response(detail)),
|
||||
};
|
||||
let status = query_param_value(query, "status");
|
||||
let (items, total) = state
|
||||
.list_admin_redeem_code_batches(status.as_deref(), limit, offset)
|
||||
.await?;
|
||||
Ok(Json(json!({
|
||||
"items": items.iter().map(build_batch_payload).collect::<Vec<_>>(),
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}))
|
||||
.into_response())
|
||||
}
|
||||
|
||||
async fn build_admin_create_redeem_code_batch_response(
|
||||
state: &AdminAppState<'_>,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
request_body: Option<&axum::body::Bytes>,
|
||||
) -> Result<Response<Body>, GatewayError> {
|
||||
let Some(body) = request_body else {
|
||||
return Ok(build_admin_payments_bad_request_response("请求体不能为空"));
|
||||
};
|
||||
let payload = match serde_json::from_slice::<AdminRedeemCodeBatchCreateRequest>(body) {
|
||||
Ok(value) => value,
|
||||
Err(_) => {
|
||||
return Ok(build_admin_payments_bad_request_response(
|
||||
"请求数据验证失败",
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let name = match normalize_required_text(&payload.name, "name", 120) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(build_admin_payments_bad_request_response(detail)),
|
||||
};
|
||||
if !payload.amount_usd.is_finite() || payload.amount_usd <= 0.0 {
|
||||
return Ok(build_admin_payments_bad_request_response(
|
||||
"amount_usd 必须大于 0",
|
||||
));
|
||||
}
|
||||
if payload.total_count == 0 || payload.total_count > 5000 {
|
||||
return Ok(build_admin_payments_bad_request_response(
|
||||
"total_count 必须在 1 到 5000 之间",
|
||||
));
|
||||
}
|
||||
let description = match normalize_optional_text(payload.description, "description", 500) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(build_admin_payments_bad_request_response(detail)),
|
||||
};
|
||||
let expires_at_unix_secs = match parse_batch_expires_at(payload.expires_at) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(build_admin_payments_bad_request_response(detail)),
|
||||
};
|
||||
|
||||
let Some(result) = state
|
||||
.admin_create_redeem_code_batch(
|
||||
aether_data::repository::wallet::CreateAdminRedeemCodeBatchInput {
|
||||
name,
|
||||
amount_usd: payload.amount_usd,
|
||||
currency: "USD".to_string(),
|
||||
balance_bucket: "gift".to_string(),
|
||||
total_count: payload.total_count,
|
||||
expires_at_unix_secs,
|
||||
description,
|
||||
created_by: admin_payment_operator_id(request_context),
|
||||
},
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(build_admin_payments_backend_unavailable_response(
|
||||
"Redeem code batch backend unavailable",
|
||||
));
|
||||
};
|
||||
|
||||
Ok(attach_admin_audit_response(
|
||||
Json(json!({
|
||||
"batch": build_batch_payload(&result.batch),
|
||||
"codes": result
|
||||
.codes
|
||||
.iter()
|
||||
.map(|code| json!({
|
||||
"id": code.code_id,
|
||||
"code": code.code,
|
||||
"masked_code": code.masked_code,
|
||||
}))
|
||||
.collect::<Vec<_>>(),
|
||||
}))
|
||||
.into_response(),
|
||||
"admin_redeem_code_batch_created",
|
||||
"create_redeem_code_batch",
|
||||
"redeem_code_batch",
|
||||
&result.batch.id,
|
||||
))
|
||||
}
|
||||
|
||||
async fn build_admin_redeem_code_batch_detail_response(
|
||||
state: &AdminAppState<'_>,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
) -> Result<Response<Body>, GatewayError> {
|
||||
let Some(batch_id) = parse_batch_id_from_detail_path(request_context.path()) else {
|
||||
return Ok(build_admin_payment_order_not_found_response());
|
||||
};
|
||||
match state.read_admin_redeem_code_batch(&batch_id).await? {
|
||||
crate::AdminWalletMutationOutcome::Applied(batch) => {
|
||||
Ok(Json(json!({ "batch": build_batch_payload(&batch) })).into_response())
|
||||
}
|
||||
crate::AdminWalletMutationOutcome::NotFound => Ok(build_redeem_code_not_found_response(
|
||||
"Redeem code batch not found",
|
||||
)),
|
||||
crate::AdminWalletMutationOutcome::Invalid(detail) => {
|
||||
Ok(build_admin_payments_bad_request_response(detail))
|
||||
}
|
||||
crate::AdminWalletMutationOutcome::Unavailable => {
|
||||
Ok(build_admin_payments_backend_unavailable_response(
|
||||
"Redeem code batch backend unavailable",
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_admin_redeem_codes_response(
|
||||
state: &AdminAppState<'_>,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
) -> Result<Response<Body>, GatewayError> {
|
||||
let Some(batch_id) = parse_batch_id_from_suffix_path(request_context.path(), "/codes") else {
|
||||
return Ok(build_admin_payment_order_not_found_response());
|
||||
};
|
||||
let query = request_context.query_string();
|
||||
let limit = match parse_admin_payments_limit(query) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(build_admin_payments_bad_request_response(detail)),
|
||||
};
|
||||
let offset = match parse_admin_payments_offset(query) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(build_admin_payments_bad_request_response(detail)),
|
||||
};
|
||||
let status = query_param_value(query, "status");
|
||||
match state.read_admin_redeem_code_batch(&batch_id).await? {
|
||||
crate::AdminWalletMutationOutcome::Applied(batch) => {
|
||||
let page = state
|
||||
.list_admin_redeem_codes(&batch_id, status.as_deref(), limit, offset)
|
||||
.await?;
|
||||
Ok(Json(json!({
|
||||
"batch": build_batch_payload(&batch),
|
||||
"items": page.items.iter().map(build_code_payload).collect::<Vec<_>>(),
|
||||
"total": page.total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}))
|
||||
.into_response())
|
||||
}
|
||||
crate::AdminWalletMutationOutcome::NotFound => Ok(build_redeem_code_not_found_response(
|
||||
"Redeem code batch not found",
|
||||
)),
|
||||
crate::AdminWalletMutationOutcome::Invalid(detail) => {
|
||||
Ok(build_admin_payments_bad_request_response(detail))
|
||||
}
|
||||
crate::AdminWalletMutationOutcome::Unavailable => {
|
||||
Ok(build_admin_payments_backend_unavailable_response(
|
||||
"Redeem code batch backend unavailable",
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_admin_disable_redeem_code_batch_response(
|
||||
state: &AdminAppState<'_>,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
) -> Result<Response<Body>, GatewayError> {
|
||||
let Some(batch_id) = parse_batch_id_from_suffix_path(request_context.path(), "/disable") else {
|
||||
return Ok(build_admin_payment_order_not_found_response());
|
||||
};
|
||||
match state
|
||||
.admin_disable_redeem_code_batch(
|
||||
&batch_id,
|
||||
admin_payment_operator_id(request_context).as_deref(),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
crate::AdminWalletMutationOutcome::Applied(batch) => Ok(attach_admin_audit_response(
|
||||
Json(json!({ "batch": build_batch_payload(&batch) })).into_response(),
|
||||
"admin_redeem_code_batch_disabled",
|
||||
"disable_redeem_code_batch",
|
||||
"redeem_code_batch",
|
||||
&batch_id,
|
||||
)),
|
||||
crate::AdminWalletMutationOutcome::NotFound => Ok(build_redeem_code_not_found_response(
|
||||
"Redeem code batch not found",
|
||||
)),
|
||||
crate::AdminWalletMutationOutcome::Invalid(detail) => {
|
||||
Ok(build_admin_payments_bad_request_response(detail))
|
||||
}
|
||||
crate::AdminWalletMutationOutcome::Unavailable => {
|
||||
Ok(build_admin_payments_backend_unavailable_response(
|
||||
"Redeem code batch backend unavailable",
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_admin_delete_redeem_code_batch_response(
|
||||
state: &AdminAppState<'_>,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
) -> Result<Response<Body>, GatewayError> {
|
||||
let Some(batch_id) = parse_batch_id_from_suffix_path(request_context.path(), "/delete") else {
|
||||
return Ok(build_admin_payment_order_not_found_response());
|
||||
};
|
||||
match state
|
||||
.admin_delete_redeem_code_batch(
|
||||
&batch_id,
|
||||
admin_payment_operator_id(request_context).as_deref(),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
crate::AdminWalletMutationOutcome::Applied(batch) => Ok(attach_admin_audit_response(
|
||||
Json(json!({ "batch": build_batch_payload(&batch) })).into_response(),
|
||||
"admin_redeem_code_batch_deleted",
|
||||
"delete_redeem_code_batch",
|
||||
"redeem_code_batch",
|
||||
&batch_id,
|
||||
)),
|
||||
crate::AdminWalletMutationOutcome::NotFound => Ok(build_redeem_code_not_found_response(
|
||||
"Redeem code batch not found",
|
||||
)),
|
||||
crate::AdminWalletMutationOutcome::Invalid(detail) => {
|
||||
Ok(build_admin_payments_bad_request_response(detail))
|
||||
}
|
||||
crate::AdminWalletMutationOutcome::Unavailable => {
|
||||
Ok(build_admin_payments_backend_unavailable_response(
|
||||
"Redeem code batch backend unavailable",
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_admin_disable_redeem_code_response(
|
||||
state: &AdminAppState<'_>,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
) -> Result<Response<Body>, GatewayError> {
|
||||
let Some(code_id) = parse_code_id_from_suffix_path(request_context.path(), "/disable") else {
|
||||
return Ok(build_admin_payment_order_not_found_response());
|
||||
};
|
||||
match state
|
||||
.admin_disable_redeem_code(
|
||||
&code_id,
|
||||
admin_payment_operator_id(request_context).as_deref(),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
crate::AdminWalletMutationOutcome::Applied(code) => Ok(attach_admin_audit_response(
|
||||
Json(json!({ "code": build_code_payload(&code) })).into_response(),
|
||||
"admin_redeem_code_disabled",
|
||||
"disable_redeem_code",
|
||||
"redeem_code",
|
||||
&code_id,
|
||||
)),
|
||||
crate::AdminWalletMutationOutcome::NotFound => Ok(build_redeem_code_not_found_response(
|
||||
"Redeem code not found",
|
||||
)),
|
||||
crate::AdminWalletMutationOutcome::Invalid(detail) => {
|
||||
Ok(build_admin_payments_bad_request_response(detail))
|
||||
}
|
||||
crate::AdminWalletMutationOutcome::Unavailable => Ok(
|
||||
build_admin_payments_backend_unavailable_response("Redeem code backend unavailable"),
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ use super::{
|
||||
build_admin_payments_data_unavailable_response,
|
||||
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()))
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde_json::json;
|
||||
|
||||
use super::payment_shared::{
|
||||
normalize_payment_callback_request, payment_callback_signature_matches,
|
||||
NormalizedPaymentCallbackRequest, PaymentCallbackRequest,
|
||||
};
|
||||
|
||||
pub(crate) struct CreateCheckoutSessionInput {
|
||||
pub(crate) order_no: String,
|
||||
pub(crate) amount_usd: f64,
|
||||
pub(crate) expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub(crate) struct CreateCheckoutSessionOutput {
|
||||
pub(crate) gateway_order_id: String,
|
||||
pub(crate) gateway_response: serde_json::Value,
|
||||
}
|
||||
|
||||
pub(crate) struct VerifyCallbackInput<'a> {
|
||||
pub(crate) secret: &'a str,
|
||||
pub(crate) signature: &'a str,
|
||||
pub(crate) payload: PaymentCallbackRequest,
|
||||
}
|
||||
|
||||
pub(crate) struct VerifyCallbackOutcome {
|
||||
pub(crate) normalized_payload: NormalizedPaymentCallbackRequest,
|
||||
pub(crate) signature_valid: bool,
|
||||
}
|
||||
|
||||
pub(crate) trait PaymentGatewayAdapter: Sync {
|
||||
fn payment_method(&self) -> &'static str;
|
||||
|
||||
fn create_checkout_session(
|
||||
&self,
|
||||
input: &CreateCheckoutSessionInput,
|
||||
) -> Result<CreateCheckoutSessionOutput, String>;
|
||||
|
||||
fn verify_callback(
|
||||
&self,
|
||||
input: VerifyCallbackInput<'_>,
|
||||
) -> Result<VerifyCallbackOutcome, String> {
|
||||
let normalized_payload = normalize_payment_callback_request(input.payload)
|
||||
.map_err(|detail: &'static str| detail.to_string())?;
|
||||
let signature_valid = payment_callback_signature_matches(
|
||||
&normalized_payload.payload,
|
||||
input.signature,
|
||||
input.secret,
|
||||
)?;
|
||||
Ok(VerifyCallbackOutcome {
|
||||
normalized_payload,
|
||||
signature_valid,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct PaymentGatewayRegistry;
|
||||
|
||||
impl PaymentGatewayRegistry {
|
||||
pub(crate) fn get(payment_method: &str) -> Option<&'static dyn PaymentGatewayAdapter> {
|
||||
match payment_method.trim().to_ascii_lowercase().as_str() {
|
||||
"alipay" => Some(&ALIPAY_ADAPTER),
|
||||
"wechat" => Some(&WECHAT_ADAPTER),
|
||||
"manual" => Some(&MANUAL_ADAPTER),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AlipayAdapter;
|
||||
struct WechatAdapter;
|
||||
struct ManualAdapter;
|
||||
|
||||
static ALIPAY_ADAPTER: AlipayAdapter = AlipayAdapter;
|
||||
static WECHAT_ADAPTER: WechatAdapter = WechatAdapter;
|
||||
static MANUAL_ADAPTER: ManualAdapter = ManualAdapter;
|
||||
|
||||
impl PaymentGatewayAdapter for AlipayAdapter {
|
||||
fn payment_method(&self) -> &'static str {
|
||||
"alipay"
|
||||
}
|
||||
|
||||
fn create_checkout_session(
|
||||
&self,
|
||||
input: &CreateCheckoutSessionInput,
|
||||
) -> Result<CreateCheckoutSessionOutput, String> {
|
||||
let expires_at = input.expires_at.to_rfc3339();
|
||||
let gateway_order_id = format!("ali_{}", input.order_no);
|
||||
Ok(CreateCheckoutSessionOutput {
|
||||
gateway_order_id: gateway_order_id.clone(),
|
||||
gateway_response: json!({
|
||||
"gateway": self.payment_method(),
|
||||
"display_name": "支付宝",
|
||||
"gateway_order_id": gateway_order_id,
|
||||
"payment_url": format!("/pay/mock/alipay/{}", input.order_no),
|
||||
"qr_code": format!("mock://alipay/{}", input.order_no),
|
||||
"expires_at": expires_at,
|
||||
"amount_usd": input.amount_usd,
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl PaymentGatewayAdapter for WechatAdapter {
|
||||
fn payment_method(&self) -> &'static str {
|
||||
"wechat"
|
||||
}
|
||||
|
||||
fn create_checkout_session(
|
||||
&self,
|
||||
input: &CreateCheckoutSessionInput,
|
||||
) -> Result<CreateCheckoutSessionOutput, String> {
|
||||
let expires_at = input.expires_at.to_rfc3339();
|
||||
let gateway_order_id = format!("wx_{}", input.order_no);
|
||||
Ok(CreateCheckoutSessionOutput {
|
||||
gateway_order_id: gateway_order_id.clone(),
|
||||
gateway_response: json!({
|
||||
"gateway": self.payment_method(),
|
||||
"display_name": "微信支付",
|
||||
"gateway_order_id": gateway_order_id,
|
||||
"payment_url": format!("/pay/mock/wechat/{}", input.order_no),
|
||||
"qr_code": format!("mock://wechat/{}", input.order_no),
|
||||
"expires_at": expires_at,
|
||||
"amount_usd": input.amount_usd,
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl PaymentGatewayAdapter for ManualAdapter {
|
||||
fn payment_method(&self) -> &'static str {
|
||||
"manual"
|
||||
}
|
||||
|
||||
fn create_checkout_session(
|
||||
&self,
|
||||
input: &CreateCheckoutSessionInput,
|
||||
) -> Result<CreateCheckoutSessionOutput, String> {
|
||||
let expires_at = input.expires_at.to_rfc3339();
|
||||
let gateway_order_id = format!("manual_{}", input.order_no);
|
||||
Ok(CreateCheckoutSessionOutput {
|
||||
gateway_order_id: gateway_order_id.clone(),
|
||||
gateway_response: json!({
|
||||
"gateway": self.payment_method(),
|
||||
"display_name": "人工打款",
|
||||
"gateway_order_id": gateway_order_id,
|
||||
"payment_url": serde_json::Value::Null,
|
||||
"qr_code": serde_json::Value::Null,
|
||||
"instructions": "请线下确认到账后由管理员处理",
|
||||
"expires_at": expires_at,
|
||||
"amount_usd": input.amount_usd,
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{CreateCheckoutSessionInput, PaymentGatewayRegistry};
|
||||
use chrono::Utc;
|
||||
|
||||
#[test]
|
||||
fn registry_resolves_builtin_mock_adapters() {
|
||||
assert!(PaymentGatewayRegistry::get("alipay").is_some());
|
||||
assert!(PaymentGatewayRegistry::get("wechat").is_some());
|
||||
assert!(PaymentGatewayRegistry::get("manual").is_some());
|
||||
assert!(PaymentGatewayRegistry::get("unknown").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_mock_checkout_payloads_keep_existing_frontend_keys() {
|
||||
let adapter = PaymentGatewayRegistry::get("wechat").expect("adapter should exist");
|
||||
let checkout = adapter
|
||||
.create_checkout_session(&CreateCheckoutSessionInput {
|
||||
order_no: "po_test".to_string(),
|
||||
amount_usd: 12.5,
|
||||
expires_at: Utc::now(),
|
||||
})
|
||||
.expect("checkout should build");
|
||||
let payload = checkout
|
||||
.gateway_response
|
||||
.as_object()
|
||||
.expect("gateway response should be object");
|
||||
|
||||
for key in ["gateway_order_id", "payment_url", "qr_code", "expires_at"] {
|
||||
assert!(
|
||||
payload.contains_key(key),
|
||||
"mock checkout payload should contain {key}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
use axum::{body::Body, http, response::Response};
|
||||
|
||||
use 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,
|
||||
);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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"
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
136
apps/aether-gateway/src/handlers/public/support/wallet/redeem.rs
Normal file
136
apps/aether-gateway/src/handlers/public/support/wallet/redeem.rs
Normal file
@@ -0,0 +1,136 @@
|
||||
use super::{
|
||||
build_auth_error_response, build_auth_json_response, build_auth_wallet_summary_payload, http,
|
||||
resolve_authenticated_local_user, sanitize_wallet_gateway_response, unix_secs_to_rfc3339,
|
||||
wallet_normalize_optional_string_field, AppState, Body, GatewayPublicRequestContext, Response,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WalletRedeemRequest {
|
||||
code: String,
|
||||
}
|
||||
|
||||
fn wallet_build_redeem_order_no(now: chrono::DateTime<chrono::Utc>) -> String {
|
||||
format!(
|
||||
"po_{}_{}",
|
||||
now.format("%Y%m%d%H%M%S%6f"),
|
||||
&Uuid::new_v4().simple().to_string()[..12]
|
||||
)
|
||||
}
|
||||
|
||||
fn build_wallet_payment_order_payload(
|
||||
record: &aether_data::repository::wallet::StoredAdminPaymentOrder,
|
||||
) -> serde_json::Value {
|
||||
json!({
|
||||
"id": record.id,
|
||||
"order_no": record.order_no,
|
||||
"wallet_id": record.wallet_id,
|
||||
"user_id": record.user_id,
|
||||
"amount_usd": record.amount_usd,
|
||||
"pay_amount": record.pay_amount,
|
||||
"pay_currency": record.pay_currency,
|
||||
"exchange_rate": record.exchange_rate,
|
||||
"refunded_amount_usd": record.refunded_amount_usd,
|
||||
"refundable_amount_usd": record.refundable_amount_usd,
|
||||
"payment_method": record.payment_method,
|
||||
"gateway_order_id": record.gateway_order_id,
|
||||
"gateway_response": sanitize_wallet_gateway_response(record.gateway_response.clone()),
|
||||
"status": record.status,
|
||||
"created_at": unix_secs_to_rfc3339(record.created_at_unix_ms),
|
||||
"paid_at": record.paid_at_unix_secs.and_then(unix_secs_to_rfc3339),
|
||||
"credited_at": record.credited_at_unix_secs.and_then(unix_secs_to_rfc3339),
|
||||
"expires_at": record.expires_at_unix_secs.and_then(unix_secs_to_rfc3339),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn handle_wallet_redeem(
|
||||
state: &AppState,
|
||||
request_context: &GatewayPublicRequestContext,
|
||||
headers: &http::HeaderMap,
|
||||
request_body: Option<&axum::body::Bytes>,
|
||||
) -> Response<Body> {
|
||||
let auth = match resolve_authenticated_local_user(state, request_context, headers).await {
|
||||
Ok(value) => value,
|
||||
Err(response) => return response,
|
||||
};
|
||||
let Some(request_body) = request_body else {
|
||||
return build_auth_error_response(http::StatusCode::BAD_REQUEST, "缺少请求体", false);
|
||||
};
|
||||
let payload = match serde_json::from_slice::<WalletRedeemRequest>(request_body) {
|
||||
Ok(value) => value,
|
||||
Err(_) => {
|
||||
return build_auth_error_response(http::StatusCode::BAD_REQUEST, "输入验证失败", false)
|
||||
}
|
||||
};
|
||||
let code = match wallet_normalize_optional_string_field(Some(payload.code), 128) {
|
||||
Ok(Some(value)) => value,
|
||||
_ => {
|
||||
return build_auth_error_response(http::StatusCode::BAD_REQUEST, "输入验证失败", false)
|
||||
}
|
||||
};
|
||||
|
||||
let outcome = match state
|
||||
.redeem_wallet_code(aether_data::repository::wallet::RedeemWalletCodeInput {
|
||||
code,
|
||||
user_id: auth.user.id.clone(),
|
||||
order_no: wallet_build_redeem_order_no(Utc::now()),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Some(value)) => value,
|
||||
Ok(None) => {
|
||||
return build_auth_error_response(
|
||||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
"钱包兑换后端暂不可用",
|
||||
false,
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
return build_auth_error_response(
|
||||
http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("wallet redeem failed: {err:?}"),
|
||||
false,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
match outcome {
|
||||
aether_data::repository::wallet::RedeemWalletCodeOutcome::Redeemed {
|
||||
wallet,
|
||||
order,
|
||||
amount_usd,
|
||||
batch_name,
|
||||
} => build_auth_json_response(
|
||||
http::StatusCode::OK,
|
||||
json!({
|
||||
"order": build_wallet_payment_order_payload(&order),
|
||||
"wallet": build_auth_wallet_summary_payload(Some(&wallet)),
|
||||
"amount_usd": amount_usd,
|
||||
"batch_name": batch_name,
|
||||
}),
|
||||
None,
|
||||
),
|
||||
aether_data::repository::wallet::RedeemWalletCodeOutcome::InvalidCode => {
|
||||
build_auth_error_response(http::StatusCode::BAD_REQUEST, "兑换码格式无效", false)
|
||||
}
|
||||
aether_data::repository::wallet::RedeemWalletCodeOutcome::CodeNotFound => {
|
||||
build_auth_error_response(http::StatusCode::NOT_FOUND, "兑换码不存在", false)
|
||||
}
|
||||
aether_data::repository::wallet::RedeemWalletCodeOutcome::CodeDisabled
|
||||
| aether_data::repository::wallet::RedeemWalletCodeOutcome::BatchDisabled => {
|
||||
build_auth_error_response(http::StatusCode::BAD_REQUEST, "兑换码已停用", false)
|
||||
}
|
||||
aether_data::repository::wallet::RedeemWalletCodeOutcome::CodeExpired => {
|
||||
build_auth_error_response(http::StatusCode::BAD_REQUEST, "兑换码已过期", false)
|
||||
}
|
||||
aether_data::repository::wallet::RedeemWalletCodeOutcome::CodeRedeemed => {
|
||||
build_auth_error_response(http::StatusCode::BAD_REQUEST, "兑换码已被使用", false)
|
||||
}
|
||||
aether_data::repository::wallet::RedeemWalletCodeOutcome::WalletInactive => {
|
||||
build_auth_error_response(http::StatusCode::BAD_REQUEST, "wallet is not active", false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -302,6 +302,8 @@ pub(crate) fn admin_proxy_local_requires_buffered_body(
|
||||
| (Some("billing_manage"), http::Method::POST, Some("create_collector"))
|
||||
| (Some("billing_manage"), http::Method::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,
|
||||
|
||||
Reference in New Issue
Block a user