合并最新 master 到作品管理分支
Project CI / Backend tests (pull_request) Failing after 39s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m44s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 3m43s
Project CI / Repository checks (pull_request) Failing after 40s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 5m52s
Project CI / Frontend tests (pull_request) Successful in 3m17s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m12s
Project CI / Native shell tests (pull_request) Successful in 6m41s

This commit is contained in:
2026-10-03 17:34:23 +08:00
105 changed files with 8656 additions and 10 deletions
+10
View File
@@ -2201,6 +2201,7 @@ fn admin_permission_requirement(_method: &Method, path: &str) -> AdminPermission
path if path.starts_with("/admin/api/agc-templates/") => AnyTab(&["agc-templates"]),
"/admin/api/dashboard" => AnyTab(&["dashboard"]),
"/admin/api/overview" => AnyTab(&["overview"]),
"/admin/api/payment/orders" => AnyTab(&["payment-orders"]),
"/admin/api/external-api-keys" => AnyTab(&["tables"]),
"/admin/api/debug/http" => AnyTab(&["debug"]),
"/admin/api/tracking/events" => AnyTab(&["tracking"]),
@@ -2856,6 +2857,15 @@ async fn fetch_admin_dashboard_rows(state: &AppState, sql: &str) -> Result<Vec<V
extract_first_sql_rows(payload)
}
pub(crate) async fn fetch_admin_payment_rows(
state: &AppState,
sql: &str,
) -> Result<Vec<Value>, AppError> {
fetch_admin_dashboard_rows(state, sql)
.await
.map_err(|message| AppError::from_status(StatusCode::BAD_GATEWAY).with_message(message))
}
/// 用户详情累计充值的单次读取上限:单个用户的历史订单远少于该值。
const ADMIN_USER_RECHARGE_SUMMARY_ROW_LIMIT: u32 = 500;
+5
View File
@@ -23,6 +23,7 @@ use crate::{
external_api_auth::ExternalApiPrincipal,
http_error::AppError,
modules,
payment::handle_payment_wechat_notify,
request_context::{RequestContext, attach_request_context, resolve_request_id},
response_headers::propagate_request_id_header,
state::{AppState, BackpressureState},
@@ -71,6 +72,10 @@ pub fn build_router(state: AppState) -> Router {
get(handle_wechat_virtual_payment_message_push_verify)
.post(handle_wechat_virtual_payment_notify),
)
.route(
"/api/payment/wechat/notify",
post(handle_payment_wechat_notify),
)
// HTTP 背压在业务路由外侧快拒绝,避免过载请求继续占用 SpacetimeDB facade 与业务执行资源。
.layer(middleware::from_fn_with_state(
BackpressureState::from_ref(&state),
@@ -8,7 +8,9 @@ use axum::{
response::Response,
};
use serde_json::json;
use spacetime_client::ExternalApiKeyAuthenticateRecordInput;
use spacetime_client::{
ExternalApiKeyAuthenticateRecordInput, PaymentApiKeyAuthenticateRecordInput,
};
use tracing::warn;
use crate::{
@@ -24,6 +26,9 @@ pub struct ExternalApiPrincipal {
owner_user_id: String,
key_id: String,
scopes: Vec<String>,
payment_app_id: Option<String>,
callback_url: Option<String>,
key_hash: String,
}
impl ExternalApiPrincipal {
@@ -33,6 +38,9 @@ impl ExternalApiPrincipal {
owner_user_id: owner_user_id.to_string(),
key_id: "external-api-key-test".to_string(),
scopes: scopes.iter().map(|scope| (*scope).to_string()).collect(),
payment_app_id: None,
callback_url: None,
key_hash: String::new(),
}
}
@@ -44,9 +52,21 @@ impl ExternalApiPrincipal {
self.key_id.as_str()
}
pub fn payment_app_id(&self) -> Option<&str> {
self.payment_app_id.as_deref()
}
pub fn has_scope(&self, scope: &str) -> bool {
self.scopes.iter().any(|item| item == scope)
}
pub fn payment_callback_url(&self) -> Option<&str> {
self.callback_url.as_deref()
}
pub fn payment_key_hash(&self) -> &str {
self.key_hash.as_str()
}
}
pub async fn require_external_api_key(
@@ -60,10 +80,11 @@ pub async fn require_external_api_key(
.map(|context| context.request_id().to_string())
.unwrap_or_else(|| "unknown".to_string());
let raw_key = extract_external_api_bearer(request.headers())?;
let key_hash = hash_external_api_key(raw_key.as_str());
let key = state
.authenticator()
.authenticate_external_api_key(ExternalApiKeyAuthenticateRecordInput {
key_hash: hash_external_api_key(raw_key.as_str()),
key_hash: key_hash.clone(),
used_at_micros: current_utc_micros(),
})
.await
@@ -79,6 +100,55 @@ pub async fn require_external_api_key(
owner_user_id: key.owner_user_id,
key_id: key.key_id,
scopes: key.scopes,
payment_app_id: None,
callback_url: None,
key_hash: String::new(),
};
request.extensions_mut().insert(principal.clone());
let mut response = next.run(request).await;
response.extensions_mut().insert(principal);
Ok(response)
}
pub async fn require_payment_api_key(
State(state): State<ExternalApiAuthState>,
mut request: Request,
next: Next,
) -> Result<Response, AppError> {
let request_id = request
.extensions()
.get::<RequestContext>()
.map(|context| context.request_id().to_string())
.unwrap_or_else(|| "unknown".to_string());
let raw_key = extract_external_api_bearer(request.headers())?;
let key_hash = hash_external_api_key(raw_key.as_str());
let (app, key) = state
.authenticator()
.authenticate_payment_api_key(PaymentApiKeyAuthenticateRecordInput {
key_hash: key_hash.clone(),
used_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
warn!(
%request_id,
error = %error,
"支付 API Key 校验失败"
);
AppError::from_status(StatusCode::UNAUTHORIZED)
.with_message("支付 API Key 不存在或已失效")
})?;
let scopes = serde_json::from_str::<Vec<String>>(key.scopes_json.as_str()).map_err(|_| {
AppError::from_status(StatusCode::UNAUTHORIZED).with_message("支付 API Key 权限配置无效")
})?;
let principal = ExternalApiPrincipal {
owner_user_id: app.owner_user_id,
key_id: key.key_id,
scopes,
payment_app_id: Some(app.app_id),
callback_url: app.callback_url,
key_hash,
};
request.extensions_mut().insert(principal.clone());
+5
View File
@@ -63,6 +63,9 @@ mod modules;
mod openai_image_generation;
mod password_entry;
mod password_management;
mod payment;
mod payment_admin;
mod payment_webhook;
mod phone_auth;
mod platform_errors;
mod process_metrics;
@@ -694,6 +697,8 @@ fn spawn_common_app_state_background_workers(state: &AppState) {
fn spawn_http_app_state_background_workers(state: &AppState, process_role: ProcessRole) {
spawn_common_app_state_background_workers(state);
crate::payment_webhook::PaymentWebhookWorker::new(state.spacetime_client().clone())
.spawn_worker();
crate::error_reports::spawn_cleanup_worker(state.clone());
if should_start_profile_recharge_expiration_listener(process_role) {
spawn_profile_recharge_expiration_listener(state.clone());
@@ -25,6 +25,7 @@ use crate::{
admin_register_recharge_refund, admin_resolve_recharge_refund_manual_review,
admin_update_wallet_restriction,
},
payment_admin::admin_list_payment_orders,
runtime_profile::{
admin_disable_profile_redeem_code, admin_disable_profile_task_config,
admin_get_profile_wallet_config, admin_list_profile_invite_codes,
@@ -83,6 +84,7 @@ pub fn router(state: AppState) -> Router<AppState> {
),
("/admin/api/me", get(admin_me)),
("/admin/api/overview", get(admin_overview)),
("/admin/api/payment/orders", get(admin_list_payment_orders)),
("/admin/api/dashboard", get(admin_dashboard)),
(
"/admin/api/debug/http",
@@ -7,7 +7,9 @@ use axum::{
use crate::{
editor_project::EDITOR_LAYOUT_REQUEST_BODY_MAX_BYTES,
external_api_auth::{require_external_api_key, require_external_mcp_api_key},
external_api_auth::{
require_external_api_key, require_external_mcp_api_key, require_payment_api_key,
},
external_assets_api::{
confirm_external_asset_object, create_external_direct_upload_ticket,
get_external_asset_read_url,
@@ -33,6 +35,7 @@ use crate::{
download_external_skill_archive, get_external_agent_integration_manifest,
get_external_skill_entry,
},
payment::{create_external_payment_order, get_external_payment_order},
state::AppState,
};
@@ -45,7 +48,8 @@ pub fn router(state: AppState) -> Router<AppState> {
require_external_mcp_api_key,
));
let auth = middleware::from_fn_with_state(state, require_external_api_key);
let auth = middleware::from_fn_with_state(state.clone(), require_external_api_key);
let payment_auth = middleware::from_fn_with_state(state.clone(), require_payment_api_key);
let protected_routes = [
(
"/api/external/v1/assets/direct-upload-tickets",
@@ -157,6 +161,15 @@ pub fn router(state: AppState) -> Router<AppState> {
.fold(Router::new(), |router, (path, methods)| {
router.route(path, methods.route_layer(auth.clone()))
});
let payment_router = Router::new()
.route(
"/api/external/v1/payment/orders",
post(create_external_payment_order).route_layer(payment_auth.clone()),
)
.route(
"/api/external/v1/payment/orders/{order_id}",
get(get_external_payment_order).route_layer(payment_auth),
);
Router::new()
.route("/api/external/v1/openapi.json", get(openapi_json))
@@ -173,6 +186,7 @@ pub fn router(state: AppState) -> Router<AppState> {
get(download_external_skill_archive),
)
.merge(protected_router)
.merge(payment_router)
.merge(mcp_router)
}
@@ -9,6 +9,10 @@ use crate::{
create_external_api_key, ensure_llm_router_api_key, list_external_api_keys,
revoke_external_api_key,
},
payment::{
create_payment_app, get_public_payment_checkout, list_payment_apps, revoke_payment_api_key,
update_payment_app,
},
profile_identity::update_profile_identity,
runtime_profile::{
claim_profile_task_reward, confirm_wechat_profile_recharge_order,
@@ -59,6 +63,29 @@ pub fn router(state: AppState) -> Router<AppState> {
require_bearer_auth,
)),
)
.route(
"/api/profile/payment/apps",
get(list_payment_apps).post(create_payment_app).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/profile/payment/apps/{app_id}",
axum::routing::patch(update_payment_app).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/payment/keys/{key_id}",
axum::routing::delete(revoke_payment_api_key).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/payment/checkout/{checkout_token}",
get(get_public_payment_checkout),
)
.route(
"/api/profile/wallet-ledger",
get(get_profile_wallet_ledger).route_layer(middleware::from_fn_with_state(
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,144 @@
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))
}
@@ -0,0 +1,135 @@
use std::sync::Arc;
use std::time::Duration;
use reqwest::header::{CONTENT_TYPE, HeaderName, HeaderValue};
use spacetime_client::{
PaymentWebhookClaimRecordInput, PaymentWebhookCompleteRecordInput,
PaymentWebhookDeliveryRecord, SpacetimeClient,
};
use tokio::time::sleep;
use tracing::{debug, warn};
use crate::editor_project::current_utc_micros;
const PAYMENT_WEBHOOK_WORKER_ID: &str = "api-server-payment-webhook";
const PAYMENT_WEBHOOK_BATCH_SIZE: u32 = 20;
const PAYMENT_WEBHOOK_INTERVAL: Duration = Duration::from_secs(5);
const PAYMENT_WEBHOOK_REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Clone)]
pub struct PaymentWebhookWorker {
spacetime_client: SpacetimeClient,
http_client: reqwest::Client,
}
impl PaymentWebhookWorker {
pub fn new(spacetime_client: SpacetimeClient) -> Arc<Self> {
Arc::new(Self {
spacetime_client,
http_client: reqwest::Client::builder()
.connect_timeout(Duration::from_secs(3))
.timeout(PAYMENT_WEBHOOK_REQUEST_TIMEOUT)
.build()
.expect("payment webhook client should build"),
})
}
pub fn spawn_worker(self: Arc<Self>) {
tokio::spawn(async move {
loop {
if let Err(error) = self.process_once().await {
warn!(error = %error, "支付外部回调 worker 处理失败,将继续重试");
}
sleep(PAYMENT_WEBHOOK_INTERVAL).await;
}
});
}
async fn process_once(&self) -> Result<(), String> {
let now_micros = current_utc_micros();
let lease_expires_at_micros = now_micros.saturating_add(60_000_000);
let deliveries = self
.spacetime_client
.claim_payment_webhooks(PaymentWebhookClaimRecordInput {
worker_id: PAYMENT_WEBHOOK_WORKER_ID.to_string(),
limit: PAYMENT_WEBHOOK_BATCH_SIZE,
now_micros,
lease_expires_at_micros,
})
.await
.map_err(|error| error.to_string())?;
for delivery in deliveries {
self.deliver_one(delivery).await;
}
Ok(())
}
async fn deliver_one(&self, delivery: PaymentWebhookDeliveryRecord) {
let result = self
.http_client
.post(&delivery.callback_url)
.header(CONTENT_TYPE, "application/json")
.header(
HeaderName::from_static("x-genarrative-signature"),
HeaderValue::from_str(delivery.signature.as_str())
.unwrap_or_else(|_| HeaderValue::from_static("invalid")),
)
.header(
HeaderName::from_static("x-genarrative-event"),
"payment.paid",
)
.header(
HeaderName::from_static("x-genarrative-delivery-id"),
delivery.delivery_id.as_str(),
)
.body(delivery.payload_json.clone())
.send()
.await;
let (success, error_message) = match result {
Ok(response) if response.status().is_success() => (true, None),
Ok(response) => (
false,
Some(format!("callback HTTP {}", response.status().as_u16())),
),
Err(error) => (false, Some(format!("callback request failed: {error}"))),
};
let now_micros = current_utc_micros();
let next_available_at_micros = if success {
now_micros
} else {
let delay_seconds = 2_i64
.saturating_pow(delivery.attempt_count.min(7))
.saturating_mul(5)
.min(300);
now_micros.saturating_add(delay_seconds.saturating_mul(1_000_000))
};
if let Err(error) = self
.spacetime_client
.complete_payment_webhook(PaymentWebhookCompleteRecordInput {
delivery_id: delivery.delivery_id.clone(),
worker_id: PAYMENT_WEBHOOK_WORKER_ID.to_string(),
success,
error_message,
now_micros,
next_available_at_micros,
attempt_count: delivery.attempt_count,
})
.await
{
warn!(
delivery_id = %delivery.delivery_id,
error = %error,
"支付外部回调结果写回失败"
);
} else {
debug!(
delivery_id = %delivery.delivery_id,
success,
attempt_count = delivery.attempt_count,
"支付外部回调处理完成"
);
}
}
}
@@ -3,7 +3,8 @@ use std::sync::Arc;
use axum::extract::FromRef;
use futures_util::future::BoxFuture;
use spacetime_client::{
ExternalApiKeyAuthenticateRecordInput, ExternalApiKeyRecord, SpacetimeClient,
ExternalApiKeyAuthenticateRecordInput, ExternalApiKeyRecord,
PaymentApiKeyAuthenticateRecordInput, PaymentApiKeyRecord, PaymentAppRecord, SpacetimeClient,
SpacetimeClientError,
};
@@ -14,6 +15,17 @@ pub(crate) trait ExternalApiKeyAuthenticator: Send + Sync {
&self,
input: ExternalApiKeyAuthenticateRecordInput,
) -> BoxFuture<'_, Result<ExternalApiKeyRecord, SpacetimeClientError>>;
fn authenticate_payment_api_key(
&self,
_input: PaymentApiKeyAuthenticateRecordInput,
) -> BoxFuture<'_, Result<(PaymentAppRecord, PaymentApiKeyRecord), SpacetimeClientError>> {
Box::pin(async {
Err(SpacetimeClientError::Procedure(
"支付 API Key 认证未配置".to_string(),
))
})
}
}
impl ExternalApiKeyAuthenticator for SpacetimeClient {
@@ -23,6 +35,13 @@ impl ExternalApiKeyAuthenticator for SpacetimeClient {
) -> BoxFuture<'_, Result<ExternalApiKeyRecord, SpacetimeClientError>> {
Box::pin(SpacetimeClient::authenticate_external_api_key(self, input))
}
fn authenticate_payment_api_key(
&self,
input: PaymentApiKeyAuthenticateRecordInput,
) -> BoxFuture<'_, Result<(PaymentAppRecord, PaymentApiKeyRecord), SpacetimeClientError>> {
Box::pin(SpacetimeClient::authenticate_payment_api_key(self, input))
}
}
#[derive(Clone)]
@@ -1089,6 +1089,23 @@ pub fn build_wechat_web_payment_request(
description: format!("陶泥儿 - {product_title}"),
amount_cents,
payer_client_ip,
notify_url: None,
}
}
pub fn build_wechat_payment_service_order_request(
order_id: String,
product_title: String,
amount_cents: u64,
payer_client_ip: String,
notify_url: String,
) -> WechatWebOrderRequest {
WechatWebOrderRequest {
order_id,
description: format!("陶泥儿 - {product_title}"),
amount_cents,
payer_client_ip,
notify_url: Some(notify_url),
}
}