合并最新 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
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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user