Files
Genarrative/server-rs/crates/module-runtime/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

533 lines
17 KiB
Rust

use serde::{Deserialize, Serialize};
use url::Url;
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
pub const PAYMENT_PROVIDER_WECHAT_NATIVE: &str = "wechat_native";
pub const PAYMENT_SCOPE_ORDERS_CREATE: &str = "payment:orders:create";
pub const PAYMENT_SCOPE_ORDERS_READ: &str = "payment:orders:read";
pub const PAYMENT_SCOPE_ORDERS_CLOSE: &str = "payment:orders:close";
pub const PAYMENT_APP_NAME_MAX_CHARS: usize = 80;
pub const PAYMENT_ORDER_TITLE_MAX_CHARS: usize = 128;
pub const PAYMENT_MERCHANT_ORDER_ID_MAX_CHARS: usize = 96;
pub const PAYMENT_IDEMPOTENCY_KEY_MAX_CHARS: usize = 128;
pub const PAYMENT_CURRENCY_MAX_CHARS: usize = 16;
pub const PAYMENT_ITEMS_MAX_BYTES: usize = 16 * 1024;
pub const PAYMENT_CALLBACK_URL_MAX_CHARS: usize = 512;
pub const PAYMENT_ORDER_MIN_AMOUNT_CENTS: u64 = 1;
pub const PAYMENT_ORDER_MAX_AMOUNT_CENTS: u64 = 9_999_999_999;
pub const PAYMENT_WEBHOOK_MAX_ATTEMPTS: u32 = 8;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum PaymentOrderStatus {
Pending,
Paying,
Paid,
Expired,
Closed,
Refunded,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum PaymentProvider {
WechatNative,
}
impl PaymentProvider {
pub fn as_str(self) -> &'static str {
match self {
Self::WechatNative => PAYMENT_PROVIDER_WECHAT_NATIVE,
}
}
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaymentAppSnapshot {
pub app_id: String,
pub owner_user_id: String,
pub name: String,
pub callback_url: Option<String>,
pub enabled: bool,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaymentApiKeySnapshot {
pub key_id: String,
pub app_id: String,
pub owner_user_id: String,
pub name: String,
pub key_prefix: String,
pub scopes_json: String,
pub created_at_micros: i64,
pub last_used_at_micros: Option<i64>,
pub revoked_at_micros: Option<i64>,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaymentOrderSnapshot {
pub order_id: String,
pub app_id: String,
pub owner_user_id: String,
pub merchant_order_id: String,
pub idempotency_lookup: String,
pub title: String,
pub items_json: String,
pub amount_cents: u64,
pub currency: String,
pub provider: PaymentProvider,
pub provider_trade_no: Option<String>,
pub provider_code_url: Option<String>,
pub callback_url: Option<String>,
pub callback_signing_key_hash: Option<String>,
pub provider_attempted_at_micros: Option<i64>,
pub checkout_token: String,
pub status: PaymentOrderStatus,
pub created_at_micros: i64,
pub expires_at_micros: i64,
pub paid_at_micros: Option<i64>,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaymentAppCreateInput {
pub app_id: String,
pub owner_user_id: String,
pub name: String,
pub callback_url: Option<String>,
pub now_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaymentApiKeyCreateInput {
pub key_id: String,
pub app_id: String,
pub owner_user_id: String,
pub name: String,
pub key_prefix: String,
pub key_hash: String,
pub scopes_json: String,
pub now_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaymentAppListInput {
pub owner_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaymentAppUpdateInput {
pub app_id: String,
pub owner_user_id: String,
pub enabled: bool,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaymentApiKeyListInput {
pub owner_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaymentApiKeyRevokeInput {
pub key_id: String,
pub owner_user_id: String,
pub revoked_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaymentApiKeyAuthenticateInput {
pub key_hash: String,
pub used_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaymentOrderCreateInput {
pub order_id: String,
pub app_id: String,
pub owner_user_id: String,
pub merchant_order_id: String,
pub idempotency_lookup: String,
pub title: String,
pub items_json: String,
pub amount_cents: u64,
pub currency: String,
pub provider: PaymentProvider,
pub checkout_token: String,
pub now_micros: i64,
pub expires_at_micros: i64,
pub callback_url: Option<String>,
pub callback_signing_key_hash: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaymentOrderGetInput {
pub order_id: String,
pub owner_user_id: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaymentOrderCheckoutGetInput {
pub checkout_token: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaymentOrderListInput {
pub app_id: Option<String>,
pub owner_user_id: String,
pub limit: u32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaymentOrderMarkPaidInput {
pub order_id: String,
pub provider_trade_no: String,
pub amount_cents: u64,
pub paid_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaymentOrderProviderCodeInput {
pub order_id: String,
pub provider_code_url: String,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaymentOrderProviderAttemptInput {
pub order_id: String,
pub attempted_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaymentWebhookDeliverySnapshot {
pub delivery_id: String,
pub order_id: String,
pub app_id: String,
pub owner_user_id: String,
pub callback_url: String,
pub payload_json: String,
pub signature: String,
pub status: String,
pub attempt_count: u32,
pub available_at_micros: i64,
pub lease_worker_id: Option<String>,
pub lease_expires_at_micros: Option<i64>,
pub last_error_message: Option<String>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaymentWebhookEnqueueInput {
pub order_id: String,
pub payload_json: String,
pub signature: String,
pub now_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaymentWebhookClaimInput {
pub worker_id: String,
pub limit: u32,
pub now_micros: i64,
pub lease_expires_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaymentWebhookCompleteInput {
pub delivery_id: String,
pub worker_id: String,
pub success: bool,
pub error_message: Option<String>,
pub now_micros: i64,
pub next_available_at_micros: i64,
pub attempt_count: u32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaymentWebhookProcedureResult {
pub ok: bool,
pub delivery: Option<PaymentWebhookDeliverySnapshot>,
pub deliveries: Vec<PaymentWebhookDeliverySnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaymentAppProcedureResult {
pub ok: bool,
pub app: Option<PaymentAppSnapshot>,
pub key: Option<PaymentApiKeySnapshot>,
pub apps: Vec<PaymentAppSnapshot>,
pub keys: Vec<PaymentApiKeySnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaymentOrderProcedureResult {
pub ok: bool,
pub order: Option<PaymentOrderSnapshot>,
pub orders: Vec<PaymentOrderSnapshot>,
pub error_message: Option<String>,
}
pub fn build_payment_app_create_input(
app_id: String,
owner_user_id: String,
name: String,
callback_url: Option<String>,
now_micros: i64,
) -> Result<PaymentAppCreateInput, String> {
let app_id = required(app_id, "payment_app.app_id")?;
let owner_user_id = required(owner_user_id, "payment_app.owner_user_id")?;
let name = bounded(name, "payment_app.name", PAYMENT_APP_NAME_MAX_CHARS)?;
let callback_url = callback_url
.map(|value| {
let value = bounded(
value,
"payment_app.callback_url",
PAYMENT_CALLBACK_URL_MAX_CHARS,
)?;
let parsed = Url::parse(value.as_str())
.map_err(|_| "payment_app.callback_url 格式无效".to_string())?;
let host = parsed
.host_str()
.ok_or_else(|| "payment_app.callback_url 缺少主机名".to_string())?;
let loopback_http = parsed.scheme() == "http"
&& (host.eq_ignore_ascii_case("localhost")
|| host
.parse::<std::net::IpAddr>()
.is_ok_and(|address| address.is_loopback()));
if parsed.scheme() != "https" && !loopback_http {
return Err("payment_app.callback_url 必须使用 HTTPS".to_string());
}
if parsed.username() != "" || parsed.password().is_some() || parsed.fragment().is_some()
{
return Err("payment_app.callback_url 不得包含凭据或 fragment".to_string());
}
Ok(value)
})
.transpose()?;
Ok(PaymentAppCreateInput {
app_id,
owner_user_id,
name,
callback_url,
now_micros,
})
}
pub fn build_payment_api_key_create_input(
key_id: String,
app_id: String,
owner_user_id: String,
name: String,
key_prefix: String,
key_hash: String,
scopes_json: String,
now_micros: i64,
) -> Result<PaymentApiKeyCreateInput, String> {
Ok(PaymentApiKeyCreateInput {
key_id: required(key_id, "payment_api_key.key_id")?,
app_id: required(app_id, "payment_api_key.app_id")?,
owner_user_id: required(owner_user_id, "payment_api_key.owner_user_id")?,
name: bounded(name, "payment_api_key.name", PAYMENT_APP_NAME_MAX_CHARS)?,
key_prefix: required(key_prefix, "payment_api_key.key_prefix")?,
key_hash: required(key_hash, "payment_api_key.key_hash")?,
scopes_json: bounded(scopes_json, "payment_api_key.scopes_json", 512)?,
now_micros,
})
}
pub fn build_payment_order_create_input(
order_id: String,
app_id: String,
owner_user_id: String,
merchant_order_id: String,
idempotency_lookup: String,
title: String,
items_json: String,
amount_cents: u64,
currency: String,
provider: PaymentProvider,
checkout_token: String,
now_micros: i64,
expires_at_micros: i64,
callback_url: Option<String>,
callback_signing_key_hash: Option<String>,
) -> Result<PaymentOrderCreateInput, String> {
if !(PAYMENT_ORDER_MIN_AMOUNT_CENTS..=PAYMENT_ORDER_MAX_AMOUNT_CENTS).contains(&amount_cents) {
return Err("payment_order.amount_cents 超出允许范围".to_string());
}
if currency != "CNY" {
return Err("PAYMENT_INVALID_AMOUNT_OR_CURRENCY".to_string());
}
let items: serde_json::Value =
serde_json::from_str(&items_json).map_err(|_| "PAYMENT_INVALID_ITEMS".to_string())?;
if !items.is_array()
|| items
.as_array()
.is_some_and(|rows| rows.iter().any(|v| !v.is_object()))
|| items_json.to_lowercase().contains("data:")
|| items_json.to_lowercase().contains("blob:")
{
return Err("PAYMENT_INVALID_ITEMS".to_string());
}
if items_json.len() > PAYMENT_ITEMS_MAX_BYTES {
return Err("payment_order.items_json 超出大小限制".to_string());
}
if expires_at_micros <= now_micros {
return Err("payment_order.expires_at_micros 必须晚于创建时间".to_string());
}
Ok(PaymentOrderCreateInput {
order_id: required(order_id, "payment_order.order_id")?,
app_id: required(app_id, "payment_order.app_id")?,
owner_user_id: required(owner_user_id, "payment_order.owner_user_id")?,
merchant_order_id: bounded(
merchant_order_id,
"payment_order.merchant_order_id",
PAYMENT_MERCHANT_ORDER_ID_MAX_CHARS,
)?,
idempotency_lookup: bounded(
idempotency_lookup,
"payment_order.idempotency_lookup",
PAYMENT_IDEMPOTENCY_KEY_MAX_CHARS + 128,
)?,
title: bounded(title, "payment_order.title", PAYMENT_ORDER_TITLE_MAX_CHARS)?,
items_json,
amount_cents,
currency: bounded(
currency,
"payment_order.currency",
PAYMENT_CURRENCY_MAX_CHARS,
)?,
provider,
checkout_token: required(checkout_token, "payment_order.checkout_token")?,
now_micros,
expires_at_micros,
callback_url: callback_url
.map(|value| {
bounded(
value,
"payment_order.callback_url",
PAYMENT_CALLBACK_URL_MAX_CHARS,
)
})
.transpose()?,
callback_signing_key_hash: callback_signing_key_hash
.map(|value| required(value, "payment_order.callback_signing_key_hash"))
.transpose()?,
})
}
pub fn build_payment_order_mark_paid_input(
order_id: String,
provider_trade_no: String,
amount_cents: u64,
paid_at_micros: i64,
) -> Result<PaymentOrderMarkPaidInput, String> {
if paid_at_micros <= 0 || amount_cents == 0 {
return Err("PAYMENT_INVALID_PROVIDER_FACT".to_string());
}
Ok(PaymentOrderMarkPaidInput {
order_id: required(order_id, "payment_order.order_id")?,
provider_trade_no: required(provider_trade_no, "payment_order.provider_trade_no")?,
amount_cents,
paid_at_micros,
})
}
fn required(value: String, field: &str) -> Result<String, String> {
let value = value.trim().to_string();
if value.is_empty() {
return Err(format!("{field} 不能为空"));
}
Ok(value)
}
fn bounded(value: String, field: &str, max_chars: usize) -> Result<String, String> {
let value = required(value, field)?;
if value.chars().count() > max_chars {
return Err(format!("{field} 超出长度限制"));
}
Ok(value)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn payment_order_validation_rejects_zero_amount_and_expired_order() {
assert!(
build_payment_order_create_input(
"order".into(),
"app".into(),
"owner".into(),
"merchant".into(),
"lookup".into(),
"商品".into(),
"[]".into(),
0,
"CNY".into(),
PaymentProvider::WechatNative,
"checkout".into(),
10,
20,
None,
None,
)
.is_err()
);
assert!(
build_payment_order_create_input(
"order".into(),
"app".into(),
"owner".into(),
"merchant".into(),
"lookup".into(),
"商品".into(),
"[]".into(),
100,
"CNY".into(),
PaymentProvider::WechatNative,
"checkout".into(),
20,
20,
None,
None,
)
.is_err()
);
}
}