Files
Genarrative/server-rs/crates/api-server/src/payment_admin.rs
T
kdletters 4b529a8952
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m59s
Project CI / Backend tests (pull_request) Failing after 4m8s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 5m9s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m20s
Project CI / Frontend tests (pull_request) Successful in 3m17s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m48s
Project CI / Repository checks (pull_request) Failing after 4m15s
Project CI / Native shell tests (pull_request) Successful in 6m52s
新增支付服务接入与订单收银台
新增支付应用、API Key 与外部产品订单接口

接入微信 Native 下单、二维码收银台与支付结果回调

新增后台支付订单与回调投递页面

补齐 SpacetimeDB 支付表、生成绑定、OpenAPI 与使用说明
2026-10-03 17:18:13 +08:00

145 lines
4.9 KiB
Rust

use crate::{
admin::{AuthenticatedAdmin, fetch_admin_payment_rows},
http_error::AppError,
request_context::RequestContext,
state::AppState,
};
use axum::{Extension, Json, extract::State};
use serde::Serialize;
use serde_json::Value;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminPaymentOrderEntry {
pub order_id: String,
pub app_id: String,
pub owner_user_id: String,
pub merchant_order_id: String,
pub title: String,
pub amount_cents: u64,
pub currency: String,
pub provider: String,
pub provider_trade_no: Option<String>,
pub status: String,
pub created_at: String,
pub expires_at: String,
pub paid_at: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminPaymentOrderListResponse {
pub entries: Vec<AdminPaymentOrderEntry>,
pub webhooks: Vec<AdminPaymentWebhookEntry>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminPaymentWebhookEntry {
pub delivery_id: String,
pub order_id: String,
pub app_id: String,
pub callback_url: String,
pub status: String,
pub attempt_count: u32,
pub available_at: String,
pub last_error_message: Option<String>,
pub updated_at: String,
}
pub async fn admin_list_payment_orders(
State(state): State<AppState>,
Extension(_admin): Extension<AuthenticatedAdmin>,
Extension(_request_context): Extension<RequestContext>,
) -> Result<Json<AdminPaymentOrderListResponse>, AppError> {
let rows = fetch_admin_payment_rows(
&state,
"SELECT order_id, app_id, owner_user_id, merchant_order_id, title, amount_cents, currency, provider, provider_trade_no, status, created_at, expires_at, paid_at FROM payment_order ORDER BY created_at DESC LIMIT 200",
)
.await?;
let entries = rows
.into_iter()
.filter_map(parse_payment_order_row)
.collect();
let webhook_rows = fetch_admin_payment_rows(
&state,
"SELECT delivery_id, order_id, app_id, callback_url, status, attempt_count, available_at, last_error_message, updated_at FROM payment_webhook_delivery ORDER BY updated_at DESC LIMIT 200",
)
.await?;
let webhooks = webhook_rows
.into_iter()
.filter_map(parse_payment_webhook_row)
.collect();
Ok(Json(AdminPaymentOrderListResponse { entries, webhooks }))
}
fn parse_payment_webhook_row(row: Value) -> Option<AdminPaymentWebhookEntry> {
let cells = row.as_array()?;
Some(AdminPaymentWebhookEntry {
delivery_id: cell_text(cells.get(0)?)?,
order_id: cell_text(cells.get(1)?)?,
app_id: cell_text(cells.get(2)?)?,
callback_url: cell_text(cells.get(3)?)?,
status: cell_text(cells.get(4)?)?,
attempt_count: cell_u64(cells.get(5)?)? as u32,
available_at: cell_timestamp(cells.get(6)?),
last_error_message: cell_option_text(cells.get(7)?),
updated_at: cell_timestamp(cells.get(8)?),
})
}
fn parse_payment_order_row(row: Value) -> Option<AdminPaymentOrderEntry> {
let cells = row.as_array()?;
Some(AdminPaymentOrderEntry {
order_id: cell_text(cells.get(0)?)?,
app_id: cell_text(cells.get(1)?)?,
owner_user_id: cell_text(cells.get(2)?)?,
merchant_order_id: cell_text(cells.get(3)?)?,
title: cell_text(cells.get(4)?)?,
amount_cents: cell_u64(cells.get(5)?)?,
currency: cell_text(cells.get(6)?)?,
provider: cell_text(cells.get(7)?)?,
provider_trade_no: cell_option_text(cells.get(8)?),
status: cell_text(cells.get(9)?)?,
created_at: cell_timestamp(cells.get(10)?),
expires_at: cell_timestamp(cells.get(11)?),
paid_at: cell_option_timestamp(cells.get(12)?),
})
}
fn cell_text(value: &Value) -> Option<String> {
match value {
Value::String(text) => Some(text.clone()),
Value::Number(number) => Some(number.to_string()),
Value::Array(values) if values.len() == 2 => cell_text(values.get(1)?),
Value::Array(values) if values.len() == 1 => cell_text(values.first()?),
Value::Object(object) if object.len() == 1 => cell_text(object.values().next()?),
_ => Some(value.to_string()),
}
}
fn cell_option_text(value: &Value) -> Option<String> {
if matches!(value, Value::Array(values) if values.first().and_then(Value::as_u64) == Some(1)) {
return None;
}
cell_text(value).filter(|text| !text.is_empty() && text != "null")
}
fn cell_u64(value: &Value) -> Option<u64> {
cell_text(value)?.parse().ok()
}
fn cell_timestamp(value: &Value) -> String {
cell_text(value)
.and_then(|text| text.parse::<i64>().ok())
.map(module_runtime::format_utc_micros)
.unwrap_or_else(|| "-".to_string())
}
fn cell_option_timestamp(value: &Value) -> Option<String> {
if matches!(value, Value::Array(values) if values.first().and_then(Value::as_u64) == Some(1)) {
return None;
}
Some(cell_timestamp(value))
}