Files
Genarrative/server-rs/crates/api-server/src/payment.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

557 lines
21 KiB
Rust

use axum::{
Extension, Json,
body::Bytes,
extract::{Path, State},
http::{HeaderMap, StatusCode},
};
use hmac::{Hmac, Mac};
use serde::Deserialize;
use serde_json::{Value, json};
use sha2::Sha256;
use shared_contracts::payment::{
PaymentApiKeyResponse, PaymentAppCreateRequest, PaymentAppCreateResponse,
PaymentAppListResponse, PaymentAppResponse, PaymentOrderCreateRequest,
PaymentOrderCreateResponse, PaymentOrderResponse,
};
use shared_kernel::{build_prefixed_uuid_id, offset_datetime_to_unix_micros, parse_rfc3339};
use spacetime_client::{
PaymentApiKeyCreateRecordInput, PaymentAppCreateRecordInput, PaymentOrderCreateRecordInput,
PaymentOrderGetRecordInput, PaymentOrderMarkPaidRecordInput,
PaymentOrderProviderAttemptRecordInput, PaymentOrderProviderCodeRecordInput,
PaymentOrderRecord,
};
use crate::{
api_response::json_success_body,
auth::AuthenticatedAccessToken,
editor_project::current_utc_micros,
external_api_auth::ExternalApiPrincipal,
external_api_keys::hash_external_api_key,
http_error::AppError,
request_context::RequestContext,
state::AppState,
wechat::pay::{build_wechat_payment_service_order_request, map_wechat_pay_error},
};
const PAYMENT_APP_ID_PREFIX: &str = "payment-app-";
const PAYMENT_KEY_ID_PREFIX: &str = "payment-key-";
const PAYMENT_CHECKOUT_TOKEN_PREFIX: &str = "checkout-";
const PAYMENT_RAW_KEY_PREFIX: &str = "tnr_pay_";
const PAYMENT_CHECKOUT_EXPIRATION_SECONDS: i64 = 5 * 60;
const PAYMENT_IDEMPOTENCY_HEADER: &str = "idempotency-key";
pub async fn list_payment_apps(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, AppError> {
let apps = state
.spacetime_client()
.list_payment_apps(authenticated.claims().user_id().to_string())
.await
.map_err(map_payment_client_error)?
.into_iter()
.map(payment_app_response)
.collect();
let keys = state
.spacetime_client()
.list_payment_api_keys(authenticated.claims().user_id().to_string())
.await
.map_err(map_payment_client_error)?
.into_iter()
.map(payment_api_key_response)
.collect();
Ok(json_success_body(
Some(&request_context),
PaymentAppListResponse { apps, keys },
))
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PaymentAppUpdateRequest {
pub enabled: bool,
}
pub async fn update_payment_app(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Path(app_id): Path<String>,
Json(payload): Json<PaymentAppUpdateRequest>,
) -> Result<Json<Value>, AppError> {
let app = state
.spacetime_client()
.update_payment_app(spacetime_client::PaymentAppUpdateRecordInput {
app_id,
owner_user_id: authenticated.claims().user_id().to_string(),
enabled: payload.enabled,
updated_at_micros: current_utc_micros(),
})
.await
.map_err(map_payment_client_error)?;
Ok(json_success_body(
Some(&request_context),
PaymentAppResponse {
app_id: app.app_id,
name: app.name,
callback_url: app.callback_url,
enabled: app.enabled,
created_at: module_runtime::format_utc_micros(app.created_at_micros),
updated_at: module_runtime::format_utc_micros(app.updated_at_micros),
},
))
}
pub async fn revoke_payment_api_key(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Path(key_id): Path<String>,
) -> Result<Json<Value>, AppError> {
let key = state
.spacetime_client()
.revoke_payment_api_key(spacetime_client::PaymentApiKeyRevokeRecordInput {
key_id,
owner_user_id: authenticated.claims().user_id().to_string(),
revoked_at_micros: current_utc_micros(),
})
.await
.map_err(map_payment_client_error)?;
Ok(json_success_body(
Some(&request_context),
payment_api_key_response(key),
))
}
pub async fn create_payment_app(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<PaymentAppCreateRequest>,
) -> Result<Json<Value>, AppError> {
let user_id = authenticated.claims().user_id().to_string();
let now_micros = current_utc_micros();
let app = state
.spacetime_client()
.create_payment_app(PaymentAppCreateRecordInput {
app_id: build_prefixed_uuid_id(PAYMENT_APP_ID_PREFIX),
owner_user_id: user_id.clone(),
name: payload.name,
callback_url: payload.callback_url,
now_micros,
})
.await
.map_err(map_payment_client_error)?;
let raw_key = format!(
"{PAYMENT_RAW_KEY_PREFIX}{}.{}",
shared_kernel::new_uuid_simple_string(),
shared_kernel::new_uuid_simple_string()
);
let key = state
.spacetime_client()
.create_payment_api_key(PaymentApiKeyCreateRecordInput {
key_id: build_prefixed_uuid_id(PAYMENT_KEY_ID_PREFIX),
app_id: app.app_id.clone(),
owner_user_id: user_id,
name: "支付服务默认 Key".to_string(),
key_prefix: raw_key.chars().take(18).collect(),
key_hash: hash_external_api_key(raw_key.as_str()),
scopes: vec![
module_runtime::PAYMENT_SCOPE_ORDERS_CREATE.to_string(),
module_runtime::PAYMENT_SCOPE_ORDERS_READ.to_string(),
module_runtime::PAYMENT_SCOPE_ORDERS_CLOSE.to_string(),
],
now_micros,
})
.await
.map_err(map_payment_client_error)?;
Ok(json_success_body(
Some(&request_context),
PaymentAppCreateResponse {
app: payment_app_response(app),
api_key: raw_key,
key: payment_api_key_response(key),
},
))
}
pub async fn create_external_payment_order(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
headers: HeaderMap,
Json(payload): Json<PaymentOrderCreateRequest>,
) -> Result<Json<Value>, AppError> {
require_payment_scope(&principal, module_runtime::PAYMENT_SCOPE_ORDERS_CREATE)?;
let app_id = principal.payment_app_id().ok_or_else(|| {
AppError::from_status(StatusCode::FORBIDDEN).with_message("该 Key 不是支付应用 Key")
})?;
let idempotency_key = headers
.get(PAYMENT_IDEMPOTENCY_HEADER)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_message("缺少 Idempotency-Key")
})?
.to_string();
if payload.provider != module_runtime::PAYMENT_PROVIDER_WECHAT_NATIVE {
return Err(AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("当前仅支持微信 Native 支付"));
}
let now_micros = current_utc_micros();
let expires_at_micros = now_micros
.checked_add(PAYMENT_CHECKOUT_EXPIRATION_SECONDS * 1_000_000)
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_message("订单有效期无效")
})?;
let items_json = serde_json::to_string(&payload.items).map_err(|_| {
AppError::from_status(StatusCode::BAD_REQUEST).with_message("商品明细格式无效")
})?;
let order = state
.spacetime_client()
.create_payment_order(PaymentOrderCreateRecordInput {
order_id: shared_kernel::new_uuid_simple_string(),
app_id: app_id.to_string(),
owner_user_id: principal.owner_user_id().to_string(),
merchant_order_id: payload.merchant_order_id,
idempotency_lookup: format!("{app_id}:{idempotency_key}"),
title: payload.title,
items_json,
amount_cents: payload.amount_cents,
currency: payload.currency,
provider: module_runtime::PaymentProvider::WechatNative,
checkout_token: format!(
"{PAYMENT_CHECKOUT_TOKEN_PREFIX}{}",
shared_kernel::new_uuid_simple_string()
),
now_micros,
expires_at_micros,
callback_url: principal.payment_callback_url().map(str::to_string),
callback_signing_key_hash: Some(principal.payment_key_hash().to_string()),
})
.await
.map_err(map_payment_client_error)?;
if order.provider_code_url.is_some()
|| matches!(
order.status,
module_runtime::PaymentOrderStatus::Paid
| module_runtime::PaymentOrderStatus::Closed
| module_runtime::PaymentOrderStatus::Expired
)
{
return Ok(json_success_body(
Some(&request_context),
PaymentOrderCreateResponse {
order: payment_order_response(order),
},
));
}
let order = state
.spacetime_client()
.mark_payment_order_provider_attempted(PaymentOrderProviderAttemptRecordInput {
order_id: order.order_id,
attempted_at_micros: current_utc_micros(),
})
.await
.map_err(map_payment_client_error)?;
let provider_payment = state
.wechat_pay_client()
.create_native_order(build_wechat_payment_service_order_request(
order.order_id.clone(),
order.title.clone(),
order.amount_cents,
resolve_payment_client_ip(&headers),
resolve_payment_notify_url()?,
))
.await
.map_err(map_wechat_pay_error)?;
let order = state
.spacetime_client()
.set_payment_order_provider_code(PaymentOrderProviderCodeRecordInput {
order_id: order.order_id,
provider_code_url: provider_payment.code_url,
updated_at_micros: current_utc_micros(),
})
.await
.map_err(map_payment_client_error)?;
Ok(json_success_body(
Some(&request_context),
PaymentOrderCreateResponse {
order: payment_order_response(order),
},
))
}
pub async fn get_external_payment_order(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
Path(order_id): Path<String>,
) -> Result<Json<Value>, AppError> {
require_payment_scope(&principal, module_runtime::PAYMENT_SCOPE_ORDERS_READ)?;
let order = state
.spacetime_client()
.get_payment_order(PaymentOrderGetRecordInput {
order_id,
owner_user_id: Some(principal.owner_user_id().to_string()),
})
.await
.map_err(map_payment_client_error)?;
Ok(json_success_body(
Some(&request_context),
PaymentOrderCreateResponse {
order: payment_order_response(order),
},
))
}
pub async fn get_public_payment_checkout(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Path(checkout_token): Path<String>,
) -> Result<Json<Value>, AppError> {
let order = state
.spacetime_client()
.get_payment_order_by_checkout_token(checkout_token)
.await
.map_err(map_payment_client_error)?;
Ok(json_success_body(
Some(&request_context),
PaymentOrderCreateResponse {
order: payment_order_response(order),
},
))
}
pub async fn handle_payment_wechat_notify(
State(state): State<AppState>,
headers: HeaderMap,
body: Bytes,
) -> Result<StatusCode, AppError> {
let notify = state
.wechat_pay_client()
.parse_notify(&headers, &body)
.map_err(map_wechat_pay_error)?;
if notify.trade_state != "SUCCESS" {
return Ok(StatusCode::NO_CONTENT);
}
let transaction_id = notify.transaction_id.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("微信支付成功通知缺少 transaction_id")
})?;
let amount_cents = notify.amount_total_cents.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信支付成功通知缺少金额")
})?;
let paid_at_micros = notify
.success_time
.as_deref()
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("微信支付成功通知缺少 success_time")
})
.and_then(|value| {
parse_rfc3339(value)
.map(offset_datetime_to_unix_micros)
.map_err(|_| {
AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("微信支付成功通知 success_time 无效")
})
})?;
let order = state
.spacetime_client()
.get_payment_order(PaymentOrderGetRecordInput {
order_id: notify.out_trade_no,
owner_user_id: None,
})
.await
.map_err(map_payment_client_error)?;
if order.provider != module_runtime::PaymentProvider::WechatNative
|| order.amount_cents != amount_cents
{
return Err(AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("微信支付通知与本地支付订单不一致"));
}
let paid_order = state
.spacetime_client()
.mark_payment_order_paid(PaymentOrderMarkPaidRecordInput {
order_id: order.order_id,
provider_trade_no: transaction_id,
amount_cents,
paid_at_micros,
})
.await
.map_err(map_payment_client_error)?;
if paid_order.callback_url.is_some() {
let callback_key = paid_order
.callback_signing_key_hash
.as_deref()
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message("支付订单缺少外部回调签名配置")
})?;
let payload_json = serde_json::to_string(&json!({
"event": "payment.paid",
"orderId": paid_order.order_id,
"appId": paid_order.app_id,
"merchantOrderId": paid_order.merchant_order_id,
"amountCents": paid_order.amount_cents,
"currency": paid_order.currency,
"provider": paid_order.provider.as_str(),
"providerTradeNo": paid_order.provider_trade_no,
"status": "paid",
"paidAt": paid_order.paid_at_micros.map(module_runtime::format_utc_micros),
}))
.map_err(|_| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_message("支付回调内容序列化失败")
})?;
let signature = payment_webhook_signature(callback_key, payload_json.as_bytes());
state
.spacetime_client()
.enqueue_payment_webhook(spacetime_client::PaymentWebhookEnqueueRecordInput {
order_id: paid_order.order_id,
payload_json,
signature,
now_micros: current_utc_micros(),
})
.await
.map_err(map_payment_client_error)?;
}
Ok(StatusCode::NO_CONTENT)
}
fn payment_webhook_signature(key_hash: &str, payload: &[u8]) -> String {
let mut mac = Hmac::<Sha256>::new_from_slice(key_hash.as_bytes())
.expect("HMAC accepts a non-empty SHA-256 key hash");
mac.update(payload);
format!("sha256={}", hex::encode(mac.finalize().into_bytes()))
}
fn payment_app_response(record: spacetime_client::PaymentAppRecord) -> PaymentAppResponse {
PaymentAppResponse {
app_id: record.app_id,
name: record.name,
callback_url: record.callback_url,
enabled: record.enabled,
created_at: module_runtime::format_utc_micros(record.created_at_micros),
updated_at: module_runtime::format_utc_micros(record.updated_at_micros),
}
}
fn payment_api_key_response(
record: spacetime_client::PaymentApiKeyRecord,
) -> PaymentApiKeyResponse {
PaymentApiKeyResponse {
key_id: record.key_id,
app_id: record.app_id,
name: record.name,
key_prefix: record.key_prefix,
scopes: serde_json::from_str(&record.scopes_json).unwrap_or_default(),
created_at: module_runtime::format_utc_micros(record.created_at_micros),
last_used_at: record
.last_used_at_micros
.map(module_runtime::format_utc_micros),
revoked_at: record
.revoked_at_micros
.map(module_runtime::format_utc_micros),
updated_at: module_runtime::format_utc_micros(record.updated_at_micros),
}
}
fn payment_order_response(record: PaymentOrderRecord) -> PaymentOrderResponse {
let items = serde_json::from_str::<Vec<Value>>(&record.items_json).unwrap_or_default();
PaymentOrderResponse {
order_id: record.order_id,
app_id: record.app_id,
merchant_order_id: record.merchant_order_id,
title: record.title,
items,
amount_cents: record.amount_cents,
currency: record.currency,
provider: record.provider.as_str().to_string(),
provider_trade_no: record.provider_trade_no,
checkout_token: record.checkout_token.clone(),
status: payment_order_status_text(record.status).to_string(),
checkout_url: format!("/pay/{}", record.checkout_token),
provider_qr_code: record.provider_code_url,
created_at: module_runtime::format_utc_micros(record.created_at_micros),
expires_at: module_runtime::format_utc_micros(record.expires_at_micros),
paid_at: record.paid_at_micros.map(module_runtime::format_utc_micros),
updated_at: module_runtime::format_utc_micros(record.updated_at_micros),
}
}
fn payment_order_status_text(status: module_runtime::PaymentOrderStatus) -> &'static str {
match status {
module_runtime::PaymentOrderStatus::Pending => "pending",
module_runtime::PaymentOrderStatus::Paying => "paying",
module_runtime::PaymentOrderStatus::Paid => "paid",
module_runtime::PaymentOrderStatus::Expired => "expired",
module_runtime::PaymentOrderStatus::Closed => "closed",
module_runtime::PaymentOrderStatus::Refunded => "refunded",
}
}
fn require_payment_scope(principal: &ExternalApiPrincipal, scope: &str) -> Result<(), AppError> {
if principal.payment_app_id().is_some() && principal.has_scope(scope) {
return Ok(());
}
Err(AppError::from_status(StatusCode::FORBIDDEN)
.with_message("支付 API Key 没有当前操作权限")
.with_details(json!({ "scope": scope })))
}
fn resolve_payment_client_ip(headers: &HeaderMap) -> String {
headers
.get("x-forwarded-for")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.split(',').next())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("127.0.0.1")
.to_string()
}
fn resolve_payment_notify_url() -> Result<String, AppError> {
let value = std::env::var("WECHAT_PAYMENT_NOTIFY_URL")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
if let Some(value) = value {
return Ok(value);
}
if std::env::var("WECHAT_PAY_PROVIDER").ok().as_deref() == Some("mock") {
return Ok("http://127.0.0.1/api/payment/wechat/notify".to_string());
}
Err(AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
.with_message("缺少 WECHAT_PAYMENT_NOTIFY_URL,无法创建支付服务订单"))
}
fn map_payment_client_error(error: spacetime_client::SpacetimeClientError) -> AppError {
match error {
spacetime_client::SpacetimeClientError::Procedure(message)
if message.contains("PAYMENT_PROVIDER_PENDING") =>
{
AppError::from_status(StatusCode::CONFLICT)
.with_message("支付平台下单处理中,请稍后查单")
}
spacetime_client::SpacetimeClientError::Procedure(message)
| spacetime_client::SpacetimeClientError::Runtime(message) => {
AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("支付服务请求未通过")
.with_details(json!({ "message": message }))
}
other => AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message("支付服务暂时不可用")
.with_details(json!({ "message": other.to_string() })),
}
}