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 与使用说明
533 lines
17 KiB
Rust
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()
|
|
);
|
|
}
|
|
}
|