diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index ec574237d..e068b3adb 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -49,6 +49,7 @@ import type { AdminLoginResponse, AdminMeResponse, AdminOverviewResponse, + AdminPaymentOrderListResponse, AdminProjectSnapshotChannelsResponse, AdminProjectSnapshotListQuery, AdminProjectSnapshotListResponse, @@ -801,6 +802,12 @@ export function listAdminRechargeOrders( ); } +export function listAdminPaymentOrders(token: string) { + return request('/admin/api/payment/orders', { + token, + }); +} + export function getAdminUserDetail(token: string, query: AdminUserDetailQuery) { return request( `/admin/api/profile/users/detail${buildAdminUserDetailQuery(query)}`, diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 1f7ffeec2..1e540beae 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -1054,6 +1054,39 @@ export interface AdminWechatPaymentCheckPayload { knownRefundsRefreshed: number; } +export interface AdminPaymentOrderEntry { + orderId: string; + appId: string; + ownerUserId: string; + merchantOrderId: string; + title: string; + amountCents: number; + currency: string; + provider: string; + providerTradeNo: string | null; + status: string; + createdAt: string; + expiresAt: string; + paidAt: string | null; +} + +export interface AdminPaymentOrderListResponse { + entries: AdminPaymentOrderEntry[]; + webhooks: AdminPaymentWebhookEntry[]; +} + +export interface AdminPaymentWebhookEntry { + deliveryId: string; + orderId: string; + appId: string; + callbackUrl: string; + status: string; + attemptCount: number; + availableAt: string; + lastErrorMessage: string | null; + updatedAt: string; +} + export interface AdminRechargeRefundPreviewResponse { order: AdminRechargeOrderEntryPayload; paymentCheck: AdminWechatPaymentCheckPayload; diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx index 374bc8e45..52957c245 100644 --- a/apps/admin-web/src/app/AdminApp.tsx +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -36,6 +36,7 @@ import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage' import { AdminInviteCodePage } from '../pages/AdminInviteCodePage'; import { AdminLoginPage } from '../pages/AdminLoginPage'; import { AdminOverviewPage } from '../pages/AdminOverviewPage'; +import { AdminPaymentOrderPage } from '../pages/AdminPaymentOrderPage'; import { AdminProfileWalletConfigPage } from '../pages/AdminProfileWalletConfigPage'; import { AdminProjectSnapshotsPage } from '../pages/AdminProjectSnapshotsPage'; import { AdminRechargeOrderPage } from '../pages/AdminRechargeOrderPage'; @@ -297,6 +298,12 @@ export function AdminApp() { onUnauthorized={handleUnauthorized} /> ) : null} + {activeRouteId === 'payment-orders' ? ( + + ) : null} {activeRouteId === 'editor-generation-pricing' ? ( void; +}; + +function statusTone(status: string): 'ok' | 'pending' | 'error' { + if (status === 'paid') return 'ok'; + if (status === 'closed' || status === 'expired') return 'error'; + return 'pending'; +} + +export function AdminPaymentOrderPage({ + token, + onUnauthorized, +}: AdminPaymentOrderPageProps) { + const [entries, setEntries] = useState([]); + const [webhooks, setWebhooks] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(''); + + const load = useCallback(() => { + setIsLoading(true); + setError(''); + void listAdminPaymentOrders(token) + .then((response) => { + setEntries(response.entries ?? []); + setWebhooks(response.webhooks ?? []); + }) + .catch((nextError: unknown) => { + handlePageError(nextError, onUnauthorized, (message) => + setError(message || formatAdminApiError(nextError)), + ); + }) + .finally(() => setIsLoading(false)); + }, [onUnauthorized, token]); + + useEffect(() => { + load(); + }, [load]); + + return ( + + + 刷新 + + } + /> + 暂无外部回调记录。} + columns={[ + { + key: 'delivery', + label: '投递', + render: (entry) => ( + <> + {entry.deliveryId} + {entry.orderId} + + ), + }, + { key: 'app', label: '应用', render: (entry) => entry.appId }, + { + key: 'callback', + label: '回调地址', + render: (entry) => entry.callbackUrl, + }, + { + key: 'status', + label: '状态', + render: (entry) => ( + + {entry.status} + + ), + }, + { + key: 'attempts', + label: '尝试次数', + render: (entry) => entry.attemptCount, + }, + { + key: 'error', + label: '最近错误', + render: (entry) => entry.lastErrorMessage ?? '-', + }, + { + key: 'updated', + label: '更新时间', + render: (entry) => entry.updatedAt, + }, + ]} + rows={webhooks} + rowKey={(entry) => entry.deliveryId} + /> + {error ? ( +
{error}
+ ) : null} + + 暂无支付订单,外部产品创建订单后会显示在这里。 + + } + columns={[ + { + key: 'order', + label: '订单', + render: (entry) => ( + <> + {entry.merchantOrderId} + {entry.orderId} + + ), + }, + { + key: 'app', + label: '应用', + render: (entry) => ( + <> + {entry.title} + {entry.appId} + + ), + }, + { + key: 'amount', + label: '金额', + render: (entry) => + `${entry.currency} ${(entry.amountCents / 100).toFixed(2)}`, + }, + { key: 'provider', label: '渠道', render: (entry) => entry.provider }, + { + key: 'status', + label: '状态', + render: (entry) => ( + + {entry.status} + + ), + }, + { + key: 'created', + label: '创建时间', + render: (entry) => entry.createdAt, + }, + { + key: 'paid', + label: '支付时间', + render: (entry) => entry.paidAt ?? '-', + }, + ]} + rows={entries} + rowKey={(entry) => entry.orderId} + /> +
+ ); +} diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index fbe737fec..5f3f50505 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -86,7 +86,7 @@ maud = "0.27.0" libc = "0.2" [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", features = ["Wdk_Storage_FileSystem", "Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_IO", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging"] } +windows-sys = { version = "0.61", features = ["Wdk_Storage_FileSystem", "Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_IO", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] } [profile.dev] opt-level = 0 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 8527e5fae..db9db4abf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -45,9 +45,9 @@ pub mod tool; pub(crate) use art_manifest::*; use claude_code_cli::*; pub(crate) use claude_code_cli::{ - cancel_direct_claude_code_turn_at, direct_game_creator_claude_code_chat_at, - direct_game_creator_claude_code_home_chat, game_creator_claude_code_cli_route_error, - game_creator_claude_code_cli_version_identity, + cancel_direct_claude_code_turn_at, claude_code_failure_to_llm_error, + direct_game_creator_claude_code_chat_at, direct_game_creator_claude_code_home_chat, + game_creator_claude_code_cli_route_error, game_creator_claude_code_cli_version_identity, }; pub(crate) use codex_app_server::direct_game_creator_codex_chat_at; pub(crate) use codex_app_server::turn_error::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs index 65b71e4d6..0ea85c3b7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs @@ -747,6 +747,46 @@ fn parse_usage(value: &serde_json::Value) -> Option }) } +/// 把 Claude Code/sidecar 的字符串失败投影到与 Codex app-server 相同的 LlmError 分类。 +/// +/// DirectProject 不能把上游 HTTP 错误统统包装成 Transport:那会把 429、401、5xx 等可识别 +/// 的上游事实显示成“执行通道已断开”。这里只认结构化的状态位置(`Request rejected (N)` / +/// `HTTP N`),其它文本继续保留为 Transport,避免凭关键词猜测。 +pub(crate) fn claude_code_failure_to_llm_error(detail: String) -> platform_llm::LlmError { + if let Some(status_code) = claude_code_failure_status_code(&detail) { + return platform_llm::LlmError::Upstream { + status_code, + message: detail, + }; + } + if detail.contains("Claude Agent SDK sidecar 回合超时") { + return platform_llm::LlmError::Timeout { attempts: 1 }; + } + if detail.contains("Claude Code 缺少最终回复") { + return platform_llm::LlmError::EmptyResponse; + } + if detail.contains("sidecar 输出不是有效 JSON") || detail.contains("stream-json 包含无效 JSON") + { + return platform_llm::LlmError::Deserialize(detail); + } + platform_llm::LlmError::Transport(detail) +} + +fn claude_code_failure_status_code(detail: &str) -> Option { + ["Request rejected (", "HTTP "].iter().find_map(|marker| { + let tail = detail.split_once(marker)?.1; + let digits = tail + .chars() + .take_while(|character| character.is_ascii_digit()) + .collect::(); + if digits.len() != 3 { + return None; + } + let status_code = digits.parse::().ok()?; + (100..=599).contains(&status_code).then_some(status_code) + }) +} + fn parse_claude_code_result( stdout: &[u8], request: &LlmRunRequest, @@ -758,7 +798,7 @@ fn parse_claude_code_result( let detail = claude_result_error_detail(&value) .map(|detail| format!(":{detail}")) .unwrap_or_default(); - return Err(platform_llm::LlmError::Transport(format!( + return Err(claude_code_failure_to_llm_error(format!( "Claude Code 返回失败终态{detail}" ))); } @@ -1156,6 +1196,29 @@ mod tests { assert!(error.to_string().contains("Authentication failed")); } + #[test] + fn claude_failure_reuses_codex_error_categories() { + assert!(matches!( + claude_code_failure_to_llm_error( + "Claude Code 返回失败终态:API Error: Request rejected (429)".into() + ), + platform_llm::LlmError::Upstream { + status_code: 429, + .. + } + )); + assert!(matches!( + claude_code_failure_to_llm_error( + "Claude Agent SDK sidecar 回合超时:连续 180000 ms 没有任何事件".into() + ), + platform_llm::LlmError::Timeout { attempts: 1 } + )); + assert!(matches!( + claude_code_failure_to_llm_error("Claude Code 缺少最终回复".into()), + platform_llm::LlmError::EmptyResponse + )); + } + #[test] fn parses_direct_stream_result_and_final_text() { let mut observed = Vec::new(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 54a6a023c..a1141d90e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -5300,7 +5300,9 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( observer, ) .await - .map_err(|detail| TurnError::from_model_call(&platform_llm::LlmError::Transport(detail))); + .map_err(|detail| { + TurnError::from_model_call(&crate::agent::claude_code_failure_to_llm_error(detail)) + }); } game_creator_codex_app_server_validate_llm_config(&config.llm).map_err(|error| { TurnError::EnvironmentNotReady(EnvironmentNotReady { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/turn_error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/turn_error.rs index 707674fbf..745a9c1c2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/turn_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/turn_error.rs @@ -304,7 +304,7 @@ impl ModelCallKind { native, } => match native { Some(native) => native.is_retryable(), - None => *status_code >= 500, + None => matches!(*status_code, 401 | 408 | 429 | 500..=599), }, Self::PaidCreditsInsufficient => false, Self::EmptyResponse => false, @@ -322,9 +322,16 @@ impl ModelCallKind { } Self::EmptyResponse => Some("模型未返回内容"), Self::PayloadInvalid { .. } => Some("模型回执无法解析"), - Self::RequestRejected { native } | Self::UpstreamFailed { native, .. } => { + Self::RequestRejected { native } => { native.as_ref().and_then(NativeKind::public_summary) } + Self::UpstreamFailed { + status_code, + native, + } => native + .as_ref() + .and_then(NativeKind::public_summary) + .or_else(|| upstream_status_summary(*status_code)), } } @@ -341,13 +348,40 @@ impl ModelCallKind { Self::EmptyResponse | Self::PayloadInvalid { .. } => { Some("模型未给出可用的回执,请重试;如持续失败请检查项目诊断") } - Self::RequestRejected { native } | Self::UpstreamFailed { native, .. } => { - native.as_ref().and_then(NativeKind::recovery_hint) - } + Self::RequestRejected { native } => native.as_ref().and_then(NativeKind::recovery_hint), + Self::UpstreamFailed { + status_code, + native, + } => native + .as_ref() + .and_then(NativeKind::recovery_hint) + .or_else(|| upstream_status_recovery_hint(*status_code)), } } } +fn upstream_status_summary(status_code: u16) -> Option<&'static str> { + Some(match status_code { + 401 => "上游服务拒绝认证(HTTP 401)", + 403 => "上游服务拒绝访问(HTTP 403)", + 408 => "上游请求超时(HTTP 408)", + 429 => "上游服务返回 HTTP 429", + 500..=599 => "上游服务暂时不可用", + _ => return None, + }) +} + +fn upstream_status_recovery_hint(status_code: u16) -> Option<&'static str> { + Some(match status_code { + 401 => "请重新登录陶泥儿后重试;如持续失败请检查项目诊断", + 403 => "请检查当前账号的上游访问权限后重试;如持续失败请检查项目诊断", + 408 => "上游请求超时,请稍后重试;如持续失败请检查项目诊断", + 429 => "上游服务暂时繁忙(HTTP 429),请稍后重试;如持续失败请检查项目诊断", + 500..=599 => "上游服务暂时不可用,请稍后重试;如持续失败请检查项目诊断", + _ => return None, + }) +} + // ══════════════════════════════════════════════════════════════════════════════════════════ // 入队失败:`#[tauri::command]` 的 `Err`,这一轮没有开始也没有进队列 // ══════════════════════════════════════════════════════════════════════════════════════════ @@ -1327,6 +1361,31 @@ mod tests { assert!(!projected.is_model_repairable()); } + #[test] + fn upstream_http_status_keeps_codex_style_summary_and_retry_guidance() { + let rate_limited = TurnError::from_model_call(&LlmError::Upstream { + status_code: 429, + message: "Claude Code 返回失败终态:API Error: Request rejected (429)".into(), + }); + assert_eq!(rate_limited.public_summary(), Some("上游服务返回 HTTP 429")); + assert_eq!( + rate_limited.recovery_hint(), + Some("上游服务暂时繁忙(HTTP 429),请稍后重试;如持续失败请检查项目诊断") + ); + assert!(rate_limited.is_retryable()); + assert!(!rate_limited.is_model_repairable()); + + let unauthorized = TurnError::from_model_call(&LlmError::Upstream { + status_code: 401, + message: "Claude Code 返回失败终态:HTTP 401".into(), + }); + assert_eq!( + unauthorized.public_summary(), + Some("上游服务拒绝认证(HTTP 401)") + ); + assert!(unauthorized.is_retryable()); + } + /// 反馈判据:原生分类里"再跑一次也不会变"的那些不再反馈给模型。 #[test] fn terminal_native_kinds_are_not_fed_back_to_the_model() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index 214a360e5..c45f849b8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -567,6 +567,23 @@ pub(crate) fn upload_local_asset_at( file_name: &str, media_type: &str, bytes: &[u8], +) -> Result { + upload_local_asset_at_with_category(root, file_name, media_type, bytes, None) +} + +/// 带**显式入口栏目**的上传:只给 GUI 工具栏上传用(前端 `targetCategory`)。 +/// +/// 上传的 manifest `kind` 只由内容证据推导(图片 / 视频 / 代码 → `unclassified`), +/// 与栏目不是同一套词汇:在栏目画布里上传素材时,用户选定的栏目才是归属真相, +/// 否则素材会落进「待归类」、在上传它的那一栏里看不见。 +/// 取值校验与失败关闭沿用 [`register_local_asset_entry_with_category`]: +/// 非法值报错、绝不回退到 kind 派生;其它调用方继续走 [`upload_local_asset_at`]。 +pub(crate) fn upload_local_asset_at_with_category( + root: &Path, + file_name: &str, + media_type: &str, + bytes: &[u8], + target_category: Option<&str>, ) -> Result { if bytes.is_empty() { return Err("上传文件不能为空".to_string()); @@ -584,7 +601,7 @@ pub(crate) fn upload_local_asset_at( } crate::write_game_creator_private_file(&absolute_path, bytes, "上传文件")?; - register_local_asset_entry( + register_local_asset_entry_with_category( root, &relative_path, uploaded_asset_kind(file_name, media_type), @@ -602,6 +619,7 @@ pub(crate) fn upload_local_asset_at( generation_kind: None, reference_resource_ids: Vec::new(), }, + target_category, ) } @@ -2375,6 +2393,70 @@ mod tests { ); } + /// Issue 359:工具栏上传带上入口栏目,素材才不会落进「待归类」。 + /// + /// 上传的 manifest `kind` 只由内容证据推导,图片 / 视频 / 代码都派生成 `unclassified`; + /// 在栏目画布(如「角色与对象」)里上传时栏目才是归属真相。不传时保持既有行为, + /// 非法值失败关闭且不留下半成品登记。 + #[test] + fn upload_registers_into_the_explicit_entry_category() { + fn upload_category(root: &Path, asset_id: &str) -> GameCreationAppAssetCategory { + read_existing_manifest_for_project(root) + .expect("read manifest") + .assets + .into_iter() + .find(|asset| asset.id == asset_id) + .expect("uploaded asset is registered") + .category + } + + let temporary = tempfile::tempdir().expect("tempdir"); + let root = temporary.path(); + crate::project::init_local_game_project_at(root, "upload-category-test", "上传入口栏目") + .expect("init project"); + + let uploaded = upload_local_asset_at_with_category( + root, + "hero.png", + "image/png", + b"png-bytes", + Some("character"), + ) + .expect("upload with an entry category"); + assert_eq!( + upload_category(root, &uploaded.id), + GameCreationAppAssetCategory::Character + ); + + let plain = upload_local_asset_at(root, "plain.png", "image/png", b"png-bytes") + .expect("upload without an entry category"); + assert_eq!( + upload_category(root, &plain.id), + GameCreationAppAssetCategory::Unclassified + ); + + // 非法入口栏目(栏目侧伪值 `version` 不在枚举里)失败关闭:不新增登记。 + let assets_before = read_existing_manifest_for_project(root) + .expect("read manifest") + .assets + .len(); + assert!(upload_local_asset_at_with_category( + root, + "bad.png", + "image/png", + b"png-bytes", + Some("version"), + ) + .is_err()); + assert_eq!( + read_existing_manifest_for_project(root) + .expect("read manifest") + .assets + .len(), + assets_before + ); + } + /// 画板导出推断出的 kind 必须已经是 canonical 值。 #[test] fn canvas_export_asset_kind_is_always_canonical() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs index 098f43841..712c41fbd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs @@ -1,4 +1,5 @@ use std::fs; +use std::future::Future; use std::path::PathBuf; use std::time::Duration; #[cfg(windows)] @@ -120,8 +121,19 @@ enum OwnedBrowserLaunchError { } impl OwnedBrowserLaunchError { - fn is_timeout(self) -> bool { - matches!(self, Self::WsTimeout | Self::ConnectTimeout) + fn stage(self) -> BrowserLaunchStage { + match self { + Self::SpawnFailed => BrowserLaunchStage::Spawn, + Self::WsTimeout | Self::WsFailed => BrowserLaunchStage::WsHandshake, + Self::ConnectTimeout | Self::ConnectFailed => BrowserLaunchStage::CdpConnect, + } + } + + fn is_recoverable(self) -> bool { + matches!( + self, + Self::WsTimeout | Self::WsFailed | Self::ConnectTimeout | Self::ConnectFailed + ) } fn code(self) -> &'static str { @@ -135,6 +147,118 @@ impl OwnedBrowserLaunchError { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BrowserLaunchStage { + Setup, + Spawn, + WsHandshake, + CdpConnect, +} + +impl BrowserLaunchStage { + fn as_str(self) -> &'static str { + match self { + Self::Setup => "setup", + Self::Spawn => "spawn", + Self::WsHandshake => "ws-handshake", + Self::CdpConnect => "cdp-connect", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct BrowserLaunchFailure { + code: &'static str, + stage: BrowserLaunchStage, + recoverable: bool, + subprocess_exited: bool, + cleanup_confirmed: bool, +} + +impl BrowserLaunchFailure { + fn setup(code: &'static str) -> Self { + Self { + code, + stage: BrowserLaunchStage::Setup, + recoverable: false, + subprocess_exited: true, + cleanup_confirmed: true, + } + } + + fn from_launch_error( + error: OwnedBrowserLaunchError, + subprocess_exited: bool, + cleanup_confirmed: bool, + ) -> Self { + Self { + code: error.code(), + stage: error.stage(), + recoverable: error.is_recoverable(), + subprocess_exited, + cleanup_confirmed, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct BrowserRecoveryFailure { + first: BrowserLaunchFailure, + final_attempt: Option, +} + +impl BrowserRecoveryFailure { + fn diagnostic(self) -> String { + match self.final_attempt { + Some(final_attempt) => format!( + "browser-recovery-failed: initial-stage={} final-stage={} subprocess-exited={} cleanup-confirmed={} recovery-retried=true initial-cause={} final-cause={}", + self.first.stage.as_str(), + final_attempt.stage.as_str(), + final_attempt.subprocess_exited, + final_attempt.cleanup_confirmed, + self.first.code, + final_attempt.code, + ), + None => format!( + "{}: stage={} subprocess-exited={} cleanup-confirmed={} recovery-retried=false cause={}", + if self.first.recoverable { + "browser-recovery-blocked" + } else { + "browser-launch-failed" + }, + self.first.stage.as_str(), + self.first.subprocess_exited, + self.first.cleanup_confirmed, + self.first.code, + ), + } + } +} + +async fn launch_with_one_recovery(mut launch: F) -> Result +where + F: FnMut() -> Fut, + Fut: Future>, +{ + let first = match launch().await { + Ok(value) => return Ok(value), + Err(error) => error, + }; + if !first.recoverable || !first.subprocess_exited || !first.cleanup_confirmed { + return Err(BrowserRecoveryFailure { + first, + final_attempt: None, + }); + } + match launch().await { + Ok(value) => Ok(value), + Err(final_attempt) => Err(BrowserRecoveryFailure { + first, + final_attempt: Some(final_attempt), + }), + } +} + type BrowserStderrReader = futures::io::BufReader; /// 复刻 chromiumoxide 私有 ws_url_from_output 的语义(读 stderr 直到 @@ -224,22 +348,22 @@ impl BrowserProcessGuard { async fn confirm_reaped(&mut self) -> Result<(), String> { #[cfg(windows)] { - if let Some(job) = &self.job { - let deadline = Instant::now() + BROWSER_CLOSE_TIMEOUT; - loop { - if job - .is_empty() - .map_err(|_| "browser-tree-reap-unconfirmed".to_string())? - { - return Ok(()); - } - if Instant::now() >= deadline { - return Err("browser-tree-reap-unconfirmed".into()); - } - tokio::time::sleep(Duration::from_millis(20)).await; + let Some(job) = &self.job else { + return Err("browser-tree-reap-unconfirmed".into()); + }; + let deadline = Instant::now() + BROWSER_CLOSE_TIMEOUT; + loop { + if job + .is_empty() + .map_err(|_| "browser-tree-reap-unconfirmed".to_string())? + { + return Ok(()); } + if Instant::now() >= deadline { + return Err("browser-tree-reap-unconfirmed".into()); + } + tokio::time::sleep(Duration::from_millis(20)).await; } - Ok(()) } #[cfg(not(windows))] { @@ -281,24 +405,34 @@ impl OwnedBrowser { let waited = tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.process.child.wait()).await; self.handler_task.abort(); self.drain_task.abort(); - if matches!(close, Ok(Ok(_))) && matches!(waited, Ok(Ok(_))) { + if matches!(close, Ok(Ok(_))) + && matches!(waited, Ok(Ok(_))) + && self.process.confirm_reaped().await.is_ok() + { return Ok(()); } - if !self.process.reap().await { + let subprocess_exited = self.process.reap().await; + if !subprocess_exited { // 进程退出未确认:保留目录与 owner.json,留待下次启动或 // 预检时由跨会话清扫收割,而不是删掉证据让清扫失明。 if let Some(temp) = self.temp.take() { std::mem::forget(temp); } - return Err("browser-cleanup-unconfirmed".into()); + return Err( + "browser-cleanup-unconfirmed: stage=shutdown subprocess-exited=false cleanup-confirmed=false recovery-retried=false" + .into(), + ); } - let confirmed = self.process.confirm_reaped().await; - if confirmed.is_err() { + if self.process.confirm_reaped().await.is_err() { if let Some(temp) = self.temp.take() { std::mem::forget(temp); } + return Err( + "browser-cleanup-unconfirmed: stage=shutdown subprocess-exited=true cleanup-confirmed=false recovery-retried=false" + .into(), + ); } - confirmed + Ok(()) } } @@ -310,24 +444,112 @@ impl Drop for OwnedBrowser { } } -/// 自拉浏览器并完成 CDP 连接。spawn 与 Job 绑定之间存在极小的逸出 -/// 窗口:绑定前产生的子进程未入 Job——Unix 上随 root 死亡级联退出; -/// Windows 没有这种级联,但窗口只有毫秒级(Chrome 此时尚未拉起子 -/// 进程),真发生泄漏时临时目录因被占用而保留,留待系统或用户清理。 -/// 绑定之后的一切子进程由 Job 全覆盖。 +async fn cleanup_failed_launch( + mut process: BrowserProcessGuard, + temp: TempDir, + error: OwnedBrowserLaunchError, + tree_control_confirmed: bool, +) -> BrowserLaunchFailure { + let subprocess_exited = process.reap().await; + let tree_reaped = if tree_control_confirmed && subprocess_exited { + #[cfg(windows)] + { + process.job.is_none() || process.confirm_reaped().await.is_ok() + } + #[cfg(not(windows))] + { + process.confirm_reaped().await.is_ok() + } + } else { + false + }; + drop(process); + let cleanup_confirmed = if tree_reaped { + temp.close().is_ok() + } else { + // 保留 Profile 和 owner.json;后续清扫不得因证据丢失猜测归属。 + let _ = temp.keep(); + false + }; + BrowserLaunchFailure::from_launch_error(error, subprocess_exited, cleanup_confirmed) +} + +/// 自拉浏览器并完成 CDP 连接。Windows 先把 root 放入 KILL_ON_JOB_CLOSE +/// 的 Job,再把绑定瞬间已经出现的整棵子树纳入同一 Job;之后由 Job 自动 +/// 覆盖新建子进程。无法证明覆盖完整时先收束整棵已知进程树,不放行浏览器。 async fn launch_owned_browser( config: BrowserConfig, executable: &std::path::Path, temp: TempDir, -) -> Result { - let child = config - .launch() - .map_err(|_| OwnedBrowserLaunchError::SpawnFailed)?; - // 无法证明整树可收割时拒绝放行浏览器;child 随 drop 由 kill_on_drop 收尾。 +) -> Result { + let child = match config.launch() { + Ok(child) => child, + Err(_) => { + return Err(BrowserLaunchFailure::from_launch_error( + OwnedBrowserLaunchError::SpawnFailed, + true, + true, + )); + } + }; + #[cfg(windows)] + let root_identity = child.inner.id().and_then(|pid| { + crate::process_identity::external_agent_runner_process_start_identity(pid) + .ok() + .flatten() + }); #[cfg(windows)] let job = match WindowsProcessJob::assign_tokio(&child.inner) { - Ok(job) => Some(job), - Err(_) => return Err(OwnedBrowserLaunchError::SpawnFailed), + Ok(job) => { + let (Some(root_pid), Some(root_identity)) = + (child.inner.id(), root_identity.as_deref()) + else { + let process = BrowserProcessGuard { + child, + job: Some(job), + }; + return Err(cleanup_failed_launch( + process, + temp, + OwnedBrowserLaunchError::SpawnFailed, + false, + ) + .await); + }; + if super::sweep::ensure_browser_tree_in_job(root_pid, root_identity, &job).is_ok() { + Some(job) + } else { + let tree_reaped = + super::sweep::kill_browser_process_tree(root_pid, root_identity).is_ok(); + let process = BrowserProcessGuard { + child, + job: Some(job), + }; + return Err(cleanup_failed_launch( + process, + temp, + OwnedBrowserLaunchError::SpawnFailed, + tree_reaped, + ) + .await); + } + } + Err(_) => { + let tree_reaped = match (child.inner.id(), root_identity.as_deref()) { + (Some(root_pid), Some(root_identity)) => { + super::sweep::kill_browser_process_tree(root_pid, root_identity).is_ok() + } + _ => false, + }; + let process = BrowserProcessGuard { child, job: None }; + return Err(cleanup_failed_launch( + process, + temp, + OwnedBrowserLaunchError::SpawnFailed, + tree_reaped, + ) + .await); + } }; let mut process = BrowserProcessGuard { child, @@ -336,17 +558,13 @@ async fn launch_owned_browser( }; // 跨会话清扫的身份锚点:写入失败时该目录之后按旧残留只删不杀。 if let Some(pid) = process.child.inner.id() { - super::sweep::write_browser_process_owner(temp.path(), pid, executable); + let _ = super::sweep::write_browser_process_owner(temp.path(), pid, executable); } let (url, reader) = match devtools_ws_url_from_stderr(&mut process.child, BROWSER_TIMEOUT).await { Ok(pair) => pair, Err(error) => { - // 收割未确认时保留目录与 owner.json,交给跨会话清扫。 - if !process.reap().await { - std::mem::forget(temp); - } - return Err(error); + return Err(cleanup_failed_launch(process, temp, error, true).await); } }; let drain_task = spawn_stderr_drain(reader); @@ -359,17 +577,23 @@ async fn launch_owned_browser( Ok(Ok(pair)) => pair, Ok(Err(_)) => { drain_task.abort(); - if !process.reap().await { - std::mem::forget(temp); - } - return Err(OwnedBrowserLaunchError::ConnectFailed); + return Err(cleanup_failed_launch( + process, + temp, + OwnedBrowserLaunchError::ConnectFailed, + true, + ) + .await); } Err(_) => { drain_task.abort(); - if !process.reap().await { - std::mem::forget(temp); - } - return Err(OwnedBrowserLaunchError::ConnectTimeout); + return Err(cleanup_failed_launch( + process, + temp, + OwnedBrowserLaunchError::ConnectTimeout, + true, + ) + .await); } }; let handler_task = tokio::spawn(async move { @@ -388,22 +612,31 @@ async fn launch_owned_browser( }) } +async fn launch_browser_attempt( + executable: &std::path::Path, + proxy_bypass_list: &str, +) -> Result { + let temporary = create_browser_process_temp_dir() + .map_err(|_| BrowserLaunchFailure::setup("browser-temp-unavailable"))?; + let config = browser_config(executable, &temporary, proxy_bypass_list) + .map_err(|_| BrowserLaunchFailure::setup("browser-config-invalid"))?; + launch_owned_browser(config, executable, temporary).await +} + +async fn launch_browser_with_recovery( + executable: &std::path::Path, + proxy_bypass_list: &str, +) -> Result { + launch_with_one_recovery(|| launch_browser_attempt(executable, proxy_bypass_list)) + .await + .map_err(|failure| failure.diagnostic()) +} + /// 仅验证浏览器启动和真实 CDP,不加载项目、不生成试玩凭证。 /// 每次独立 profile;既不串行化其它工具,也不继承 Codex 的临时 HOME。 pub(crate) async fn check_browser_health() -> Result { let discovered = discover_chrome_or_edge().map_err(|_| "browser-not-found")?; - let temporary = create_browser_process_temp_dir().map_err(|_| "browser-temp-unavailable")?; - let config = browser_config(&discovered.executable_path, &temporary, "<-loopback>") - .map_err(|_| "browser-config-invalid")?; - let owned = launch_owned_browser(config, &discovered.executable_path, temporary) - .await - .map_err(|error| { - if error.is_timeout() { - "browser-start-timeout" - } else { - "browser-start-failed" - } - })?; + let owned = launch_browser_with_recovery(&discovered.executable_path, "<-loopback").await?; let version = tokio::time::timeout(Duration::from_secs(5), owned.browser().version()).await; if owned.shutdown().await.is_err() { return Err("browser-cleanup-failed".into()); @@ -460,23 +693,19 @@ pub(crate) async fn validate_local_preview_in_browser_with_cancellation( let preview_url = validate_input(&input)?; prepare_evidence_root(&input.evidence_root)?; let browser_executable = discover_chrome_or_edge()?; - let browser_temp = create_browser_process_temp_dir()?; let proxy_bypass_list = preview_proxy_bypass_list(&preview_url); - let config = browser_config( - &browser_executable.executable_path, - &browser_temp, - &proxy_bypass_list, - )?; - - let owned = launch_owned_browser(config, &browser_executable.executable_path, browser_temp) - .await - .map_err(|error| { - if error.is_timeout() { - "启动浏览器超时".to_string() - } else { - format!("启动浏览器失败:{}", error.code()) - } - })?; + let owned = + launch_browser_with_recovery(&browser_executable.executable_path, &proxy_bypass_list) + .await + .map_err(|error| { + if error.starts_with("browser-recovery-") + || error.starts_with("browser-launch-failed") + { + error + } else { + format!("启动浏览器失败:{error}") + } + })?; let work = run_browser_validation( owned.browser(), @@ -499,10 +728,8 @@ pub(crate) async fn validate_local_preview_in_browser_with_cancellation( _=cancelled => Err("宿主已停止本轮浏览器验证".to_string()), }; - if owned.shutdown().await.is_err() { - return Err( - "browser-cleanup-unconfirmed: 浏览器收束后无法证明退出,请核对本轮验证进程".into(), - ); + if let Err(error) = owned.shutdown().await { + return Err(error); } let mut result = validation?; result.completed_at_unix_ms = unix_time_ms(); @@ -583,6 +810,88 @@ mod health_tests { assert_eq!(config.viewport, Some(Viewport::default())); } + #[tokio::test] + async fn browser_ws_failure_retries_after_confirmed_cleanup_with_new_profile() { + let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let profiles = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let attempts_for_launch = std::sync::Arc::clone(&attempts); + let profiles_for_launch = std::sync::Arc::clone(&profiles); + let result = launch_with_one_recovery(move || { + let attempt = attempts_for_launch.fetch_add(1, std::sync::atomic::Ordering::AcqRel); + let profiles = std::sync::Arc::clone(&profiles_for_launch); + async move { + let temporary = tempfile::tempdir().unwrap(); + profiles + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push(temporary.path().to_path_buf()); + if attempt == 0 { + drop(temporary); + Err(BrowserLaunchFailure::from_launch_error( + OwnedBrowserLaunchError::WsFailed, + true, + true, + )) + } else { + drop(temporary); + Ok(()) + } + } + }) + .await; + assert!(result.is_ok()); + assert_eq!(attempts.load(std::sync::atomic::Ordering::Acquire), 2); + let profiles = profiles + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + assert_eq!(profiles.len(), 2); + assert_ne!(profiles[0], profiles[1]); + } + + #[tokio::test] + async fn browser_recovery_does_not_retry_when_cleanup_is_unconfirmed() { + let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let attempts_for_launch = std::sync::Arc::clone(&attempts); + let failure = launch_with_one_recovery(move || { + attempts_for_launch.fetch_add(1, std::sync::atomic::Ordering::AcqRel); + async { + Err::<(), BrowserLaunchFailure>(BrowserLaunchFailure::from_launch_error( + OwnedBrowserLaunchError::ConnectTimeout, + false, + false, + )) + } + }) + .await + .unwrap_err(); + let diagnostic = failure.diagnostic(); + assert_eq!(attempts.load(std::sync::atomic::Ordering::Acquire), 1); + assert!(diagnostic.contains("stage=cdp-connect")); + assert!(diagnostic.contains("subprocess-exited=false")); + assert!(diagnostic.contains("cleanup-confirmed=false")); + assert!(diagnostic.contains("recovery-retried=false")); + } + + #[tokio::test] + async fn consecutive_browser_failures_keep_both_recovery_stages_and_safe_diagnostics() { + let failure = launch_with_one_recovery(|| async { + Err::<(), BrowserLaunchFailure>(BrowserLaunchFailure::from_launch_error( + OwnedBrowserLaunchError::WsFailed, + true, + true, + )) + }) + .await + .unwrap_err(); + let diagnostic = failure.diagnostic(); + assert!(diagnostic.contains("initial-stage=ws-handshake")); + assert!(diagnostic.contains("final-stage=ws-handshake")); + assert!(diagnostic.contains("subprocess-exited=true")); + assert!(diagnostic.contains("cleanup-confirmed=true")); + assert!(diagnostic.contains("recovery-retried=true")); + assert!(!diagnostic.contains("/tmp")); + } + #[tokio::test] #[ignore = "requires an installed Chrome/Chromium/Edge; local CDP only"] async fn real_browser_health_checks_can_run_concurrently() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/sweep.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/sweep.rs index d33a4a2ef..0a66c5762 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/sweep.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/sweep.rs @@ -12,6 +12,8 @@ use serde::{Deserialize, Serialize}; use super::discovery::system_browser_candidates; use super::process::{browser_process_temp_root, BROWSER_TEMP_PREFIX}; +#[cfg(windows)] +use crate::process_session::WindowsProcessJob; const OWNER_FILE: &str = "owner.json"; const OWNER_SCHEMA: &str = "agc-browser-process.v1"; @@ -34,13 +36,17 @@ pub(super) struct BrowserProcessOwner { /// 运行期 launch 的身份锚点;任何一步拿不到身份证明都不写, /// 该目录之后按旧残留只删不杀。 -pub(super) fn write_browser_process_owner(temp_root: &Path, browser_pid: u32, executable: &Path) { +pub(super) fn write_browser_process_owner( + temp_root: &Path, + browser_pid: u32, + executable: &Path, +) -> bool { let identity = crate::process_identity::external_agent_runner_process_start_identity(browser_pid); let owner_identity = crate::process_identity::external_agent_runner_process_start_identity(std::process::id()); let (Ok(Some(identity)), Ok(Some(owner_identity))) = (identity, owner_identity) else { - return; + return false; }; let owner = BrowserProcessOwner { schema_version: OWNER_SCHEMA.into(), @@ -52,9 +58,9 @@ pub(super) fn write_browser_process_owner(temp_root: &Path, browser_pid: u32, ex created_unix_ms: super::evidence::unix_time_ms(), }; let Ok(bytes) = serde_json::to_vec_pretty(&owner) else { - return; + return false; }; - let _ = fs::write(temp_root.join(OWNER_FILE), bytes); + fs::write(temp_root.join(OWNER_FILE), bytes).is_ok() } fn read_browser_process_owner(dir: &Path) -> Option { @@ -115,34 +121,142 @@ fn trusted_browser_executable(path: &Path) -> bool { } /// 杀前复核:PID 对应的活进程镜像必须就是 owner.json 声明的那个可执行 -/// 文件。仅核对字符串不够——/tmp 全局可写时,同机其他用户可以伪造 -/// owner.json 把 browser_pid 指到本用户的任意进程借清扫杀之。 -/// Linux 进一步要求命令行声明本目录的 profile,把 PID 绑到这份配置 -/// 目录,挡住同用户伪造 owner.json 指向正在使用的浏览器。 +/// 文件,命令行还必须绑定同一份临时 Profile;只核对镜像会把其它 AGC +/// 实例或用户 Edge 误认成本轮浏览器。 #[cfg(windows)] -fn live_process_executable_matches(pid: u32, expected: &Path, _profile_dir: &Path) -> bool { - // 已知残余风险:Windows 读他进程命令行成本高(PEB/WMI),此处只核对 - // 镜像;同用户伪造 owner.json 指向正在使用的浏览器时可误杀它。 +fn windows_process_command_line(pid: u32) -> Option { + use std::ffi::c_void; + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::System::Threading::{ + OpenProcess, PROCESS_QUERY_INFORMATION, PROCESS_VM_READ, + }; + + #[repr(C)] + struct UnicodeString { + length: u16, + maximum_length: u16, + buffer: *const u16, + } + + #[link(name = "ntdll")] + unsafe extern "system" { + fn NtQueryInformationProcess( + process: *mut c_void, + information_class: u32, + information: *mut c_void, + information_length: u32, + return_length: *mut u32, + ) -> i32; + } + + let process = unsafe { OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, 0, pid) }; + if process.is_null() { + return None; + } + let mut required = 0; + let _ = + unsafe { NtQueryInformationProcess(process, 60, std::ptr::null_mut(), 0, &mut required) }; + if required == 0 || required > 64 * 1024 { + unsafe { CloseHandle(process) }; + return None; + } + let mut buffer = vec![0u8; required as usize]; + let status = unsafe { + NtQueryInformationProcess( + process, + 60, + buffer.as_mut_ptr().cast(), + required, + &mut required, + ) + }; + unsafe { CloseHandle(process) }; + if status != 0 { + return None; + } + let command_line = unsafe { &*(buffer.as_ptr().cast::()) }; + if command_line.buffer.is_null() || command_line.length == 0 || command_line.length % 2 != 0 { + return None; + } + let text = unsafe { + std::slice::from_raw_parts(command_line.buffer, command_line.length as usize / 2) + }; + String::from_utf16(text).ok() +} + +#[cfg(windows)] +fn windows_command_line_arguments(command_line: &str) -> Option> { + use windows_sys::Win32::Foundation::LocalFree; + use windows_sys::Win32::UI::Shell::CommandLineToArgvW; + + let wide = command_line + .encode_utf16() + .chain(std::iter::once(0)) + .collect::>(); + let mut count = 0; + let argv = unsafe { CommandLineToArgvW(wide.as_ptr(), &mut count) }; + if argv.is_null() || count < 0 { + if !argv.is_null() { + unsafe { LocalFree(argv.cast()) }; + } + return None; + } + let result = unsafe { std::slice::from_raw_parts(argv, count as usize) } + .iter() + .map(|argument| { + if argument.is_null() { + return None; + } + let mut length = 0; + unsafe { + while *argument.add(length) != 0 { + length += 1; + } + String::from_utf16(std::slice::from_raw_parts(*argument, length)).ok() + } + }) + .collect::>>(); + unsafe { LocalFree(argv.cast()) }; + result +} + +#[cfg(windows)] +fn command_line_contains_profile(command_line: &str, profile_dir: &Path) -> bool { + const MARKER: &str = "--user-data-dir="; + let Some(arguments) = windows_command_line_arguments(command_line) else { + return false; + }; + arguments.iter().enumerate().any(|(index, argument)| { + let value = argument.strip_prefix(MARKER).or_else(|| { + (argument == "--user-data-dir") + .then(|| arguments.get(index + 1).map(String::as_str)) + .flatten() + }); + value.is_some_and(|value| same_executable_path(Path::new(value), profile_dir)) + }) +} + +#[cfg(windows)] +fn live_process_executable_matches(pid: u32, expected: &Path, profile_dir: &Path) -> bool { use windows_sys::Win32::Foundation::CloseHandle; use windows_sys::Win32::System::Threading::{ OpenProcess, QueryFullProcessImageNameW, PROCESS_QUERY_LIMITED_INFORMATION, }; - // SAFETY: 句柄非空时由 CloseHandle 释放;打开失败按不匹配处理(fail-closed)。 let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; if handle.is_null() { return false; } let mut buffer = [0u16; 1024]; let mut length = buffer.len() as u32; - // SAFETY: buffer 可写,length 先传入容量、返回实际长度。 let okay = unsafe { QueryFullProcessImageNameW(handle, 0, buffer.as_mut_ptr(), &mut length) }; - // SAFETY: handle 是本函数持有的合法句柄。 unsafe { CloseHandle(handle) }; if okay == 0 || length == 0 { return false; } let actual = std::path::PathBuf::from(String::from_utf16_lossy(&buffer[..length as usize])); same_executable_path(&actual, expected) + && windows_process_command_line(pid) + .is_some_and(|command_line| command_line_contains_profile(&command_line, profile_dir)) } #[cfg(target_os = "linux")] @@ -181,7 +295,10 @@ fn directory_owned_by_current_user(dir: &Path) -> bool { .unwrap_or(false) } -fn kill_browser_process_tree(root_pid: u32, expected_identity: &str) -> Result<(), String> { +pub(super) fn kill_browser_process_tree( + root_pid: u32, + expected_identity: &str, +) -> Result<(), String> { #[cfg(windows)] { // 追踪所有经确认属于这棵树的 PID;只有它们全部从快照中消失才判 @@ -190,15 +307,17 @@ fn kill_browser_process_tree(root_pid: u32, expected_identity: &str) -> Result<( for _ in 0..TREE_KILL_MAX_PASSES { let snapshot = windows_process_snapshot()?; let root_present = snapshot.iter().any(|(pid, _)| *pid == root_pid); - if root_present && process_identity_matches(root_pid, expected_identity) { + if root_present { + if !process_identity_matches(root_pid, expected_identity) { + return Err("browser-sweep-root-identity-mismatch".into()); + } // root 仍是目标浏览器:发现并追踪当前整棵子树。 for pid in windows_process_tree_pids_from(&snapshot, root_pid)? { track_process(&mut tracked, pid); } - } else if !root_present { + } else { // root 已退出且 PID 未被复用:发现临终前才拉起、仍挂在旧父 - // PID 上的孤儿子进程。PID 已被复用时不做发现,避免误认 - // 复用者的子进程。 + // PID 上的孤儿子进程。PID 已被复用时不会进入此分支。 for (pid, ppid) in &snapshot { if *ppid == root_pid { track_process(&mut tracked, *pid); @@ -323,6 +442,62 @@ fn windows_process_tree_pids_from( Ok(tree) } +#[cfg(windows)] +pub(super) fn ensure_browser_tree_in_job( + root_pid: u32, + root_identity: &str, + job: &WindowsProcessJob, +) -> Result<(), String> { + for _ in 0..TREE_KILL_MAX_PASSES { + let snapshot = windows_process_snapshot()?; + if !snapshot.iter().any(|(pid, _)| *pid == root_pid) + || !process_identity_matches(root_pid, root_identity) + { + return Err("browser-job-root-identity-unconfirmed".into()); + } + let tree = windows_process_tree_pids_from(&snapshot, root_pid)?; + if tree.is_empty() { + return Err("browser-job-root-exited-before-coverage".into()); + } + let mut members = Vec::with_capacity(tree.len()); + for pid in tree { + let identity = if pid == root_pid { + root_identity.to_string() + } else { + crate::process_identity::external_agent_runner_process_start_identity(pid) + .ok() + .flatten() + .ok_or_else(|| "browser-job-process-identity-unconfirmed".to_string())? + }; + members.push((pid, identity)); + } + for (pid, identity) in members { + if !process_identity_matches(pid, &identity) { + return Err("browser-job-process-identity-changed".into()); + } + if !job.contains_pid(pid)? { + job.assign_pid(pid)?; + } + if !process_identity_matches(pid, &identity) { + return Err("browser-job-process-identity-changed".into()); + } + } + + let verified = windows_process_snapshot()?; + let verified_tree = windows_process_tree_pids_from(&verified, root_pid)?; + if process_identity_matches(root_pid, root_identity) + && !verified_tree.is_empty() + && verified_tree + .iter() + .all(|pid| job.contains_pid(*pid).unwrap_or(false)) + { + return Ok(()); + } + std::thread::sleep(TREE_KILL_PASS_INTERVAL); + } + Err("browser-job-tree-coverage-unconfirmed".into()) +} + #[cfg(windows)] fn windows_terminate_process(pid: u32) { use windows_sys::Win32::Foundation::CloseHandle; @@ -468,6 +643,18 @@ mod tests { } } + #[test] + fn owner_write_failure_leaves_no_partial_ownership_record() { + let root = tempfile::tempdir().unwrap(); + let missing_root = root.path().join("missing"); + assert!(!write_browser_process_owner( + &missing_root, + std::process::id(), + &std::env::current_exe().unwrap(), + )); + assert!(!missing_root.join(OWNER_FILE).exists()); + } + #[test] fn legacy_directory_without_owner_file_is_removed_only_when_old_enough() { let root = tempfile::tempdir().unwrap(); @@ -548,6 +735,90 @@ mod tests { assert!(trusted_browser_executable(&installed)); } + #[cfg(windows)] + #[test] + fn windows_browser_cleanup_requires_the_exact_profile_argument() { + let profile = Path::new(r#"C:\Users\tester\App Data\ga-browser-1\profile"#); + assert!(command_line_contains_profile( + r#"msedge.exe --headless --user-data-dir="C:\Users\tester\App Data\ga-browser-1\profile""#, + profile + )); + assert!(command_line_contains_profile( + r#""msedge.exe" "--user-data-dir=C:\Users\tester\App Data\ga-browser-1\profile""#, + profile + )); + assert!(!command_line_contains_profile( + r#"msedge.exe --headless --user-data-dir="C:\Users\tester\App Data\ga-browser-2\profile""#, + profile + )); + assert!(!command_line_contains_profile( + r#"msedge.exe --headless --user-data-dir="C:\Users\tester\App Data\ga-browser-1\profile-copy""#, + profile + )); + } + + #[cfg(windows)] + #[test] + fn browser_job_adopts_children_created_before_assignment() { + use std::process::{Command, Stdio}; + + let mut child = Command::new("cmd.exe") + .args([ + "/c", + "ping", + "127.0.0.1", + "-n", + "60", + "&", + "ping", + "127.0.0.1", + "-n", + "60", + ]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn fixture"); + let pid = child.id(); + let identity = crate::process_identity::external_agent_runner_process_start_identity(pid) + .expect("fixture identity") + .expect("fixture identity present"); + std::thread::sleep(Duration::from_millis(500)); + let job = WindowsProcessJob::assign_std(&child).expect("assign fixture job"); + ensure_browser_tree_in_job(pid, &identity, &job).expect("adopt fixture tree"); + let snapshot = windows_process_snapshot().expect("snapshot after adoption"); + let tree = windows_process_tree_pids_from(&snapshot, pid).expect("tree after adoption"); + assert!(tree + .iter() + .all(|member| job.contains_pid(*member).expect("job membership"))); + job.terminate().expect("terminate fixture job"); + let _ = child.wait(); + assert!(job.is_empty().expect("fixture job empty")); + } + + #[cfg(windows)] + #[test] + fn kill_browser_process_tree_rejects_root_identity_mismatch() { + use std::process::{Command, Stdio}; + + let mut child = Command::new("cmd.exe") + .args(["/c", "ping", "127.0.0.1", "-n", "60"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn fixture"); + let pid = child.id(); + let result = kill_browser_process_tree(pid, "not-the-fixture-identity"); + let _ = child.kill(); + let _ = child.wait(); + assert_eq!( + result.expect_err("identity mismatch must fail closed"), + "browser-sweep-root-identity-mismatch" + ); + } + #[cfg(windows)] #[test] fn kill_browser_process_tree_reaps_fixture_tree() { @@ -600,10 +871,8 @@ mod tests { fn live_process_executable_matches_current_process_image() { let exe = std::env::current_exe().unwrap(); let profile = Path::new("C:\\fixture\\ga-browser-x\\profile"); - // Windows 只核对镜像;Linux 还要求命令行绑定 profile,本测试进程 - // 不具备该标记,正例只在 Windows 断言。 - #[cfg(windows)] - assert!(live_process_executable_matches( + // 当前测试进程不携带目标 Profile 标识,即使镜像一致也不能清理。 + assert!(!live_process_executable_matches( std::process::id(), &exe, profile diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs index 57a598049..8b7da7ad0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs @@ -493,12 +493,19 @@ pub(crate) fn upload_local_asset( file_name: String, media_type: String, bytes: Vec, + target_category: Option, ) -> Result { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "asset.upload")?; let _lock = acquire_project_write_lock(root, "asset.upload")?; advance_agent_runtime_project_revision_locked(root)?; - upload_local_asset_at(root, file_name.trim(), media_type.trim(), &bytes) + upload_local_asset_at_with_category( + root, + file_name.trim(), + media_type.trim(), + &bytes, + target_category.as_deref(), + ) } #[tauri::command] diff --git a/apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs b/apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs index 8fa08dcd6..018e9ff92 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs @@ -311,6 +311,13 @@ async fn host_npm( fn browser_validation_failure_code(error: &str) -> &'static str { if error.contains("未发现可用的") { "web-preflight-browser-missing" + } else if error.starts_with("browser-recovery-") { + "web-preflight-browser-recovery-failed" + } else if error.contains("browser-temp-unavailable") || error.contains("browser-config-invalid") + { + "web-preflight-browser-environment-failed" + } else if error.starts_with("browser-launch-failed:") { + "web-preflight-browser-launch-failed" } else if error.contains("启动浏览器超时") { "web-preflight-browser-launch-timeout" } else if error.contains("启动浏览器失败") { @@ -342,6 +349,13 @@ fn browser_validation_failure_code(error: &str) -> &'static str { } } +fn browser_validation_diagnostic(error: &str) -> Option<&str> { + (error.starts_with("browser-recovery-") + || error.starts_with("browser-launch-failed:") + || error.starts_with("browser-cleanup-unconfirmed:")) + .then_some(error) +} + pub(crate) async fn host_web_creation_preflight() -> Value { let started = Instant::now(); // 预检前顺手清扫陈旧的无头浏览器,避免残留进程放大本轮超时。 @@ -362,18 +376,60 @@ pub(crate) async fn host_web_creation_preflight() -> Value { let validation = crate::browser::validate_local_preview_in_browser(BrowserValidationInput { url: preview.url, viewports: vec![BrowserValidationViewport::Desktop, BrowserValidationViewport::Mobile], expected_text: vec![], settle_ms: 100, fail_on_console_error: true, playtest_scenario: None, evidence_root: root.join("evidence"), }).await; - let result = validation.map_err(|error| browser_validation_failure_code(&error).to_string())?; - if !result.passed || result.viewport_results.len() != 2 { return Err("web-preflight-page-check-failed".into()); } + let result = validation + .map_err(|error| { + let code = browser_validation_failure_code(&error); + browser_validation_diagnostic(&error) + .map(|diagnostic| format!("{code};diagnostic={diagnostic}")) + .unwrap_or_else(|| code.to_string()) + })?; + if !result.passed || result.viewport_results.len() != 2 { + return Err("web-preflight-page-check-failed".into()); + } let mut pngs = Vec::new(); for viewport in result.viewport_results { - let bytes = fs::read(&viewport.screenshot_path).map_err(|_| "web-preflight-screenshot-missing")?; - if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") || bytes.len() < 128 { return Err("web-preflight-screenshot-invalid".into()); } - image::load_from_memory(&bytes).map_err(|_| "web-preflight-screenshot-invalid")?; - pngs.push(json!({"viewport":viewport.viewport,"passed":viewport.passed,"pngBytes":bytes.len()})); + let bytes = fs::read(&viewport.screenshot_path) + .map_err(|_| "web-preflight-screenshot-missing")?; + if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") || bytes.len() < 128 { + return Err("web-preflight-screenshot-invalid".into()); + } + image::load_from_memory(&bytes) + .map_err(|_| "web-preflight-screenshot-invalid")?; + pngs.push(json!({ + "viewport": viewport.viewport, + "passed": viewport.passed, + "pngBytes": bytes.len(), + })); } - Ok::<_, String>(json!({"schemaVersion":"agc-web-creation-preflight.v1","status":"ready","runtime":{"source":runtime.source,"nodeVersion":node,"npmVersion":npm},"build":{"status":"ready","kind":"npm-node-fixture"},"browser":{"status":"ready","viewports":pngs}})) - }.await; - let mut result = run.unwrap_or_else(|code| json!({"schemaVersion":"agc-web-creation-preflight.v1","status":"blocked","code":code})); + Ok::<_, String>(json!({ + "schemaVersion": "agc-web-creation-preflight.v1", + "status": "ready", + "runtime": { + "source": runtime.source, + "nodeVersion": node, + "npmVersion": npm, + }, + "build": {"status": "ready", "kind": "npm-node-fixture"}, + "browser": {"status": "ready", "viewports": pngs}, + })) + } + .await; + let mut result = run.unwrap_or_else(|error| { + let (code, diagnostic) = error + .split_once(";diagnostic=") + .map_or((error.as_str(), None), |(code, diagnostic)| { + (code, Some(diagnostic)) + }); + let mut blocked = json!({ + "schemaVersion": "agc-web-creation-preflight.v1", + "status": "blocked", + "code": code, + }); + if let Some(diagnostic) = diagnostic { + blocked["diagnostic"] = json!(diagnostic); + } + blocked + }); result["elapsedMs"] = json!(started.elapsed().as_millis()); result } @@ -382,9 +438,14 @@ pub(crate) async fn host_web_creation_preflight() -> Value { pub(crate) async fn preflight_web_game_creation() -> Result { let result = host_web_creation_preflight().await; if result["status"] != "ready" { + let diagnostic = result["diagnostic"] + .as_str() + .map(|value| format!(";诊断:{value}")) + .unwrap_or_default(); return Err(format!( - "Web 游戏环境预检未通过:{};尚未启动生成", - result["code"].as_str().unwrap_or("web-preflight-failed") + "Web 游戏环境预检未通过:{}{};尚未启动生成", + result["code"].as_str().unwrap_or("web-preflight-failed"), + diagnostic, )); } Ok(result) @@ -548,16 +609,40 @@ mod tests { browser_validation_failure_code("启动浏览器失败:2"), "web-preflight-browser-launch-failed" ); + + assert_eq!( + browser_validation_failure_code( + "browser-launch-failed: stage=setup cause=browser-temp-unavailable" + ), + "web-preflight-browser-environment-failed" + ); + + let recovery_diagnostic = "browser-recovery-failed: initial-stage=ws-handshake final-stage=cdp-connect subprocess-exited=true cleanup-confirmed=true recovery-retried=true"; + assert_eq!( + browser_validation_failure_code(recovery_diagnostic), + "web-preflight-browser-recovery-failed" + ); + assert_eq!( + browser_validation_diagnostic(recovery_diagnostic), + Some(recovery_diagnostic) + ); + assert_eq!( + browser_validation_diagnostic("启动浏览器失败:原始错误"), + None + ); assert_eq!( browser_validation_failure_code("宿主已停止本轮浏览器验证"), "web-preflight-cancelled" ); + let cleanup_diagnostic = "browser-cleanup-unconfirmed: stage=shutdown subprocess-exited=true cleanup-confirmed=false recovery-retried=false"; assert_eq!( - browser_validation_failure_code( - "browser-cleanup-unconfirmed: 浏览器收束后无法证明退出,请核对本轮验证进程" - ), + browser_validation_failure_code(cleanup_diagnostic), "web-preflight-browser-cleanup-failed" ); + assert_eq!( + browser_validation_diagnostic(cleanup_diagnostic), + Some(cleanup_diagnostic) + ); assert_eq!( browser_validation_failure_code("创建浏览器临时目录失败:拒绝访问"), "web-preflight-browser-environment-failed" diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs index 6d086e49c..f8d02848c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs @@ -236,7 +236,42 @@ impl WindowsProcessJob { .ok_or_else(|| "子进程缺少 Windows process handle".to_string())?; Self::assign_handle(handle as windows_sys::Win32::Foundation::HANDLE) } + pub(crate) fn contains_pid(&self, pid: u32) -> Result { + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::System::JobObjects::IsProcessInJob; + use windows_sys::Win32::System::Threading::{ + OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, + }; + let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; + if process.is_null() { + return Err("无法打开进程核对 Windows Job 归属".into()); + } + let mut in_job = 0; + let okay = unsafe { IsProcessInJob(process, self.0, &mut in_job) }; + unsafe { CloseHandle(process) }; + if okay == 0 { + return Err("无法核对进程 Windows Job 归属".into()); + } + Ok(in_job != 0) + } + pub(crate) fn assign_pid(&self, pid: u32) -> Result<(), String> { + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::System::JobObjects::AssignProcessToJobObject; + use windows_sys::Win32::System::Threading::{ + OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE, + }; + let process = unsafe { OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid) }; + if process.is_null() { + return Err("无法打开进程加入 Windows Job".into()); + } + let okay = unsafe { AssignProcessToJobObject(self.0, process) }; + unsafe { CloseHandle(process) }; + if okay == 0 { + return Err("无法将进程加入 Windows Job".into()); + } + Ok(()) + } pub(crate) fn assign_tokio_named( child: &tokio::process::Child, name: &str, diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasBottomToolbarModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasBottomToolbarModel.ts index 429d83a34..9b848ccbc 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasBottomToolbarModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasBottomToolbarModel.ts @@ -99,7 +99,13 @@ export type ResourceCanvasAudioToolAction = audioKind: ResourceCanvasGenerationKind; }; -/** 上传入口:复用既有 `upload_local_asset`。 */ +/** + * 上传入口:复用既有 `upload_local_asset`。 + * + * 调用方按当前栏目带 `targetCategory`(见 `uploadResourceCanvasToolbarFiles`)——上传的 + * manifest `kind` 只由内容证据推导,图片 / 视频 / 代码都会派生成 `unclassified`, + * 不带入口栏目素材就落「待归类」、在上传它的那一栏里看不见。 + */ export type ResourceCanvasUploadToolAction = ResourceCanvasBottomToolActionBase & { route: 'upload'; diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index df69d90a0..50e0c00dc 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -3348,8 +3348,9 @@ export default function ProjectDevelopmentView({ * 两条判据缺一不可: * 1. 这张卡已经进了布局(`resourcePositionById`),否则 reconcile 还没补位、写坐标是空操作; * 2. 这张卡已经有正式归类(投影里的 `category`)——`commitPosition` 的 `section` 必须与该资源 - * 在 sidecar 里的 section 一致,否则会被静默跳过。生成结果的实际归类与入口栏目本来就可能不同 - * (普通图片落「待归类」、规范落「文档」),所以这里按**正式归类**写,而不是入口栏目。 + * 在 sidecar 里的 section 一致,否则会被静默跳过。生成结果的实际归类与入口栏目**不一定相同** + * (Agent / Direct 路径不传 `targetCategory`,普通图片仍落「待归类」),所以这里按**正式归类**写, + * 而不是入口栏目。 * * 写完撤掉占位:结果已经接管了它的位置。占位被用户先删掉时同样清掉这条意图(没有位置可接管)。 */ @@ -9062,9 +9063,15 @@ export default function ProjectDevelopmentView({ [selectResourceCanvasPage], ); - /** 工具栏「上传」:与资源面板上传同一条「上传 + 配对读清单」链路。 */ + /** + * 工具栏「上传」:与资源面板上传同一条「上传 + 配对读清单」链路。 + * + * 入口栏目随上传一起交给原生(`targetCategory`):上传的 kind 只由内容证据推导 + * (图片 / 视频 / 代码 → `unclassified`),不带上它,素材会落进「待归类」、 + * 在上传它的那一栏里看不见。取值就是工具栏自己的栏目,与生成入口同一口径。 + */ const uploadResourceCanvasToolbarFiles = useCallback( - async (files: readonly File[]) => { + async (files: readonly File[], targetCategory: ResourceCategory | null) => { const invoke = window.__TAURI__?.core?.invoke; if (!invoke) { setResourceWorkbenchNotice('上传素材需要在客户端内打开'); @@ -9088,6 +9095,7 @@ export default function ProjectDevelopmentView({ bytes: Array.from(new Uint8Array(await file.arrayBuffer())), })), ), + targetCategory, invoke, }); setResourceWorkbenchNotice(`已上传 ${uploadable.length} 个素材`); @@ -10548,7 +10556,10 @@ export default function ProjectDevelopmentView({ } onSelectAction={handleResourceBottomToolAction} onUploadFiles={(files) => { - void uploadResourceCanvasToolbarFiles(files); + void uploadResourceCanvasToolbarFiles( + files, + resourceCanvasBottomToolbarCategory, + ); }} uploading={resourceBottomToolbarUploading} /> diff --git a/apps/ai-game-creator-shell/src/view/project-development/projectResourceLiveUpdateModel.ts b/apps/ai-game-creator-shell/src/view/project-development/projectResourceLiveUpdateModel.ts index 79b322249..35b7163bd 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/projectResourceLiveUpdateModel.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/projectResourceLiveUpdateModel.ts @@ -1,4 +1,7 @@ -import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import type { + GameCreationAppManifest, + ProjectResourceCanvasCategory, +} from '../../../../../packages/shared/src/contracts/gameCreationApp'; export type ProjectManifestSnapshotSource = | 'initial' @@ -285,6 +288,14 @@ export async function uploadProjectAssetFilesAndReadSnapshot(input: { mediaType: string; bytes: number[]; }[]; + /** + * 入口栏目:栏目画布里上传时由调用方显式给出当前栏目,原生按它登记归类。 + * + * 上传的 manifest `kind` 只由内容证据推导(图片 / 视频 / 代码 → `unclassified`), + * 不传这一项时素材会落进「待归类」、在上传它的那一栏里看不见。 + * 判定与失败关闭都在原生:非法值报错,前端不做伪分类。 + */ + targetCategory?: ProjectResourceCanvasCategory | null; invoke(command: string, args: Record): Promise; }): Promise { for (const file of input.files) { @@ -293,6 +304,7 @@ export async function uploadProjectAssetFilesAndReadSnapshot(input: { fileName: file.fileName, mediaType: file.mediaType, bytes: file.bytes, + ...(input.targetCategory ? { targetCategory: input.targetCategory } : {}), }); } const fresh = await rereadAuthoritativeProjectManifestSnapshot({ diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index fd8e5bebf..b66ab4c34 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -8839,6 +8839,52 @@ export function registerProjectAgentStatusTests() { ); }, 20_000); + it('uploads toolbar files into the entry column so they stay visible where they were uploaded', async () => { + // Issue 359:图片只由内容证据派生成 `unclassified`,在「角色与对象」栏目里上传后 + // 会落进「待归类」、在上传它的那一栏里看不见。工具栏上传必须把当前栏目交给原生。 + const manifest = createGameCreationAppManifest( + 'workbench-bottom-toolbar-upload-category', + '底部工具栏上传归类项目', + ); + const { calls } = installResourceBookBottomToolbarInvoke(manifest); + + render( + React.createElement(ProjectDevelopmentView, { + projectName: manifest.name, + projectPath: '/tmp/workbench-bottom-toolbar-upload-category', + manifest, + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + chat: React.createElement('div', null, '项目总控'), + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), + }), + ); + + await openResourceBookCategory('角色与对象'); + const uploadInput = screen.getByLabelText( + '上传素材文件', + ) as HTMLInputElement; + const file = new File(['png'], 'hero.png', { type: 'image/png' }); + if (typeof file.arrayBuffer !== 'function') { + Object.defineProperty(file, 'arrayBuffer', { + value: async () => new Uint8Array([112, 110, 103]).buffer, + }); + } + fireEvent.change(uploadInput, { target: { files: [file] } }); + + await waitFor(() => + expect(uploadCall(calls, 'hero.png')).toMatchObject({ + projectPath: '/tmp/workbench-bottom-toolbar-upload-category', + fileName: 'hero.png', + mediaType: 'image/png', + targetCategory: 'character', + bytes: [112, 110, 103], + }), + ); + }, 20_000); + it('keeps the bottom toolbar clear of the zoom dock and above the book scene', () => { const styles = readFileSync( repoPath('apps/ai-game-creator-shell/src/styles.css'), diff --git a/docs/README.md b/docs/README.md index 3a24aad02..07185677f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -128,6 +128,8 @@ - [预览画布缩放滑杆](./【交互设计】预览画布缩放滑杆-2026-09-05.md) - [图片画布撤销、恢复范围与操作提示方案](./【图片画布】撤销范围与操作提示方案-2026-07-17.md) - [微信虚拟支付接入](./【技术方案】微信虚拟支付接入-2026-05-26.md) +- [外部产品支付服务接入](./【技术方案】外部产品支付服务接入-2026-10-03.md) +- [支付服务接入与收银台使用说明](./【使用说明】支付服务接入与收银台使用说明-2026-10-03.md) - [SSE 客户端传输层收口约定](./technical/【前端架构】SSE客户端传输层收口约定-2026-06-03.md) - [全站客服悬浮入口接入约定](./technical/【前端架构】全站客服悬浮入口接入约定-2026-06-23.md) - [图片画布素材导出方案](./technical/【前端架构】图片画布素材导出方案-2026-06-15.md) diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index aea0588eb..c5a7b82bc 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -43,6 +43,10 @@ { "name": "Agent Integration", "description": "远程 MCP、OpenAPI 和完整 Skill 包发现" + }, + { + "name": "Payment", + "description": "平台统一收款与 Web 收银台" } ], "paths": { @@ -1614,6 +1618,52 @@ } } } + }, + "/api/external/v1/payment/orders": { + "post": { + "tags": ["Payment"], + "operationId": "createExternalPaymentOrder", + "summary": "创建支付订单并返回收银台地址", + "description": "使用支付应用 API Key 创建平台统一收款订单。支付金额按整数分传入;支付成功必须以平台通知或服务端查单为准。", + "security": [{"PaymentApiKey": []}], + "parameters": [{"$ref": "#/components/parameters/IdempotencyKey"}], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/PaymentOrderCreateRequest"} + } + } + }, + "responses": { + "200": { + "description": "支付订单与收银台信息", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PaymentOrderCreateResponse"}}} + }, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "502": {"$ref": "#/components/responses/UpstreamError"} + } + } + }, + "/api/external/v1/payment/orders/{orderId}": { + "get": { + "tags": ["Payment"], + "operationId": "getExternalPaymentOrder", + "summary": "查询支付订单", + "security": [{"PaymentApiKey": []}], + "parameters": [{"name": "orderId", "in": "path", "required": true, "schema": {"type": "string"}}], + "responses": { + "200": { + "description": "支付订单", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PaymentOrderCreateResponse"}}} + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"} + } + } } }, "components": { @@ -1622,6 +1672,11 @@ "type": "http", "scheme": "bearer", "bearerFormat": "tnr_sk" + }, + "PaymentApiKey": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "tnr_pay" } }, "parameters": { @@ -1747,6 +1802,47 @@ } }, "schemas": { + "PaymentOrderCreateRequest": { + "type": "object", + "required": ["merchantOrderId", "title", "amountCents"], + "properties": { + "merchantOrderId": {"type": "string", "minLength": 1, "maxLength": 96}, + "title": {"type": "string", "minLength": 1, "maxLength": 128}, + "items": {"type": "array", "items": {"type": "object", "additionalProperties": true}}, + "amountCents": {"type": "integer", "minimum": 1}, + "currency": {"type": "string", "default": "CNY"}, + "provider": {"type": "string", "enum": ["wechat_native"], "default": "wechat_native"} + }, + "additionalProperties": false + }, + "PaymentOrder": { + "type": "object", + "required": ["orderId", "appId", "merchantOrderId", "title", "items", "amountCents", "currency", "provider", "checkoutToken", "status", "checkoutUrl", "createdAt", "expiresAt", "updatedAt"], + "properties": { + "orderId": {"type": "string"}, + "appId": {"type": "string"}, + "merchantOrderId": {"type": "string"}, + "title": {"type": "string"}, + "items": {"type": "array", "items": {"type": "object", "additionalProperties": true}}, + "amountCents": {"type": "integer", "minimum": 1}, + "currency": {"type": "string"}, + "provider": {"type": "string", "enum": ["wechat_native"]}, + "providerTradeNo": {"type": ["string", "null"]}, + "checkoutToken": {"type": "string"}, + "status": {"type": "string", "enum": ["pending", "paying", "paid", "expired", "closed", "refunded"]}, + "checkoutUrl": {"type": "string"}, + "providerQrCode": {"type": ["string", "null"]}, + "createdAt": {"type": "string", "format": "date-time"}, + "expiresAt": {"type": "string", "format": "date-time"}, + "paidAt": {"type": ["string", "null"], "format": "date-time"}, + "updatedAt": {"type": "string", "format": "date-time"} + } + }, + "PaymentOrderCreateResponse": { + "type": "object", + "required": ["order"], + "properties": {"order": {"$ref": "#/components/schemas/PaymentOrder"}} + }, "ExternalEditorProjectCreateRequest": { "type": "object", "properties": { diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index 14b25c309..9e38ea713 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -164,7 +164,7 @@ - 位置与层级:画布左下角(`left: 14px; bottom: 14px; z-index: 40`)。右下角是既有的缩放 / 撤销 Dock(`right: 14px; bottom: 14px`),左下角是画布上唯一两者都不占的稳定空位。工具栏是管理区 `.game-resource-book-manager` 的**直接子节点**、与画本场景并列,不进带 `scale()` 的场景层;二级菜单与「入口不可用原因」都贴着工具栏上沿弹出,不做内嵌内容。 - 外壳用共享 chrome(`packages/image-canvas-react` 的 `CanvasToolbar / CanvasToolbarGroup / CanvasChromeButton`),样式落在 AGC 的 `resourceCanvasChrome.css`;共享包只承接通用表现,不含业务规则。 -- 接线:图片类入口走本地 IPC `start_local_project_asset_generation`(`kind` ∈ `image / character / spec / icon-spec / ui-prototype / art-spritesheet`;**提交即返回任务记录**,生成由 Rust 后台任务跑完写回项目,进度用 `list_local_project_asset_generations` 读回项目内账本 `.agent/runtime/asset-generation-tasks/tasks.json`);音频入口也走 `start_local_project_asset_generation`(同一份项目内任务账本;`kind` = `sound-effect` / `background-music`,并额外携带该次生成的请求身份 `idempotencyKey`,任务 id 即该次生成的 operation id;生成仍复用既有音频无源生成链路,`generationMode: 'create'`、`editKind` = `sound-effect` / `background-music`,不新增平台路由与请求体口径);「上传」复用 `upload_local_asset`。生成 / 上传成功后一律用「配对读 `(revision, manifest)`」交给 `onManifestChange`,走既有 manifest 刷新与资源投影链路,不重算依赖图、不另写布局。 +- 接线:图片类入口走本地 IPC `start_local_project_asset_generation`(`kind` ∈ `image / character / spec / icon-spec / ui-prototype / art-spritesheet`;**提交即返回任务记录**,生成由 Rust 后台任务跑完写回项目,进度用 `list_local_project_asset_generations` 读回项目内账本 `.agent/runtime/asset-generation-tasks/tasks.json`);音频入口也走 `start_local_project_asset_generation`(同一份项目内任务账本;`kind` = `sound-effect` / `background-music`,并额外携带该次生成的请求身份 `idempotencyKey`,任务 id 即该次生成的 operation id;生成仍复用既有音频无源生成链路,`generationMode: 'create'`、`editKind` = `sound-effect` / `background-music`,不新增平台路由与请求体口径);「上传」复用 `upload_local_asset`,并带上当前栏目 `targetCategory`(上传的 manifest `kind` 只由内容证据推导,图片 / 视频 / 代码都派生成 `unclassified`;不带入口栏目素材就落进「待归类」、在上传它的那一栏里看不见,Issue 359)。生成 / 上传成功后一律用「配对读 `(revision, manifest)`」交给 `onManifestChange`,走既有 manifest 刷新与资源投影链路,不重算依赖图、不另写布局。 - 本地排队与进度可见:AGC 本地 durable 输出槽已按**精确动作指纹**分槽(不同 prompt / 素材名各自独立成槽,具备并行能力),但本批前端仍按「同一时刻只派发一条」排队——真并行派发需要并发收口设计(配对读 + manifest CAS + 聚焦意图互不覆盖),留待下一批;所以第一条未终态时第二条提交停在**前端本地队列**里(不调用提交 IPC,显示本地排队的「排队中。」),前一条终态后自动补发;任务状态与阶段文案(后端 `phaseDetail`)由任务账本提供,前端不拼阶段、不做百分比。进度面是**画布上常驻的可折叠任务侧栏**(位置与开合形态照抄网页端美术画布的任务侧栏的右上角锚点,颜色与外形仍走 AGC 平台 token;2026-09-21 由「左侧贴边 + 工具条入口」改为「右上角锚点 + 常驻开关」):展开是两个分栏「排队/生成中」与「已完成」(各带条数,「已完成」封顶 20 条 + 列表滚动 + 高度有界),关闭入口只保留头部那一枚 ×(底部重复的关闭按钮与其分割线已删除)、折叠即整块让出画布、只留那一枚右上角开关(工具条上不再有重复入口),开合只走画布右上角那一枚「生成任务 · N」开关(两个页签下都在;**展开后开关让位、只留面板**,收起走面板头部 × 或点画布外部);锚点是画布那一格网格里的条目(不是写死 `top` 的绝对定位),工具条换行变高也不会压上去;每项显示状态徽标 / 阶段文案 / 已耗时 / 素材名,可「定位到素材」。侧栏非模态(不铺全屏遮罩、不做焦点陷阱、不参与模态遮挡判据),位置在画布右上角锚点里(照抄美术画布那一处)、**覆盖式**(不 reflow 挤窄画布视口),提交受理后自动展开。锚点按工作面分档:资源栏目画布与 UI 编辑器用画布顶边那一档,运行表现层下移让开右上角的版本入口;锚点是**画布那一格网格里的条目**(不是写死 `top` 的绝对定位),所以工具条换行变高也不会压上去。定位动作**每次点击都终局化**:能定位就定位并选中;素材在别的栏目先切栏目;不在投影里给「素材已不在项目里 / 已登记但尚未同步」的结论;3 秒内有界兜底,不允许提示条永久停在「正在定位生成的素材…」。 - 面板形态:独立浮层(`ThemedModal`),**不在当前面板下面追加内容**;面板内不写功能说明或规则解释文案。**点「生成」即同步关闭面板**(不等 IPC、不等排队、不等生成),面板里**不出现**「排队中。」「正在生成。」「提交中…」这类阶段文案——阶段文案的唯一去处是任务侧栏与工具栏提示条。**只有「点击瞬间就失败」**(校验不过、权限拒绝、start IPC 立即报错)才自动重开面板并带回草稿与原因;**受理之后才失败**只在侧栏把该任务收口为失败 + 原因,不重开面板。关闭 ≠ 取消请求(请求挂在任务与账本上,不挂在面板生命周期上)。 - 参数口径:比例 / 尺寸选项来自网页端美术画布的纯模型(`src/components/image-editor/ImageCanvasGenerationModel.ts`),并按本地 IPC 白名单收窄(本地通道明确拒绝 `4:3`);默认档 `1:1 · 1K`,生成 UI 设计图沿用网页端 UI 设计面板的默认 `16:9 · 1K`。本地 IPC 没有 `model` 入参,因此面板**不渲染模型选择器**(渲染一个改不了请求的控件就是假控件)。 diff --git a/docs/project-memory/plans/【实施计划】Web预检浏览器失败恢复-2026-10-02.md b/docs/project-memory/plans/【实施计划】Web预检浏览器失败恢复-2026-10-02.md new file mode 100644 index 000000000..aaa18517a --- /dev/null +++ b/docs/project-memory/plans/【实施计划】Web预检浏览器失败恢复-2026-10-02.md @@ -0,0 +1,31 @@ +# Web 预检浏览器失败恢复实现计划 + +- Version: `1` +- Status: `active` +- Date: `2026-10-02` +- Milestone: [`Web 预检浏览器失败恢复`](./【里程碑】Web预检浏览器失败恢复-2026-10-02.md) + +## 实现顺序 + +1. 将浏览器启动失败建模为带阶段、原因、子进程退出确认、清理确认的内部结果;Windows root spawn 后立即绑定 Job,并把绑定瞬间已经出现的子树逐 PID 纳入同一 Job,失败收束必须复用本轮 `BrowserProcessGuard`,并在成功收束时确认 Windows Job 为空。 +2. 在 Web 预检使用的一次浏览器启动入口加入一次有界恢复:仅 WS/CDP 瞬态失败且首次清理确认后创建新的临时目录/Profile 重试;其余失败直接失败关闭。 +3. 保留/加强 owner 与 sweep 的归属门禁,使用 Windows argv 解析核对完整 Profile 参数;无 owner、身份未知、PID 退出或复用均不得按猜测杀进程。 +4. 将启动失败和清理失败的安全诊断保留到预检 blocked 报告和 Tauri 错误中;稳定错误码独立于诊断文本,前端继续区分宿主阻塞与 IPC 故障。 +5. 补充 Rust 纯策略测试、清理边界测试和现有真实 Edge/Chromium ignored smoke;补充首页错误展示的定向测试。 + +## 验证命令 + +- `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell browser::` +- `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell environment_check::web_creation::tests` +- `npx vitest run apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx` +- `npm run typecheck --workspace apps/ai-game-creator-shell` +- `npm run check:encoding` +- `git diff --check` + +可选真实环境证据:安装 Windows Edge 时运行现有 `real_browser_health_checks_can_run_concurrently` 及新增恢复 smoke;无浏览器时保持 ignored,不把缺失环境写成通过。 + +## 风险与回滚 + +- Windows 进程命令行读取失败按不匹配处理,不执行杀进程;这可能留下临时目录,但保证不误杀。 +- 首次失败进程树收束未确认时不自动重试,避免第二个 Edge 与残留树并存;错误返回安全诊断。 +- 回滚点为浏览器启动恢复入口和 owner/sweep 归属校验,不触及 Web 预检的 Node/npm 或项目写入流程。 diff --git a/docs/project-memory/plans/【实施计划】支付应用与订单收银台-2026-10-03.md b/docs/project-memory/plans/【实施计划】支付应用与订单收银台-2026-10-03.md new file mode 100644 index 000000000..8694d80bb --- /dev/null +++ b/docs/project-memory/plans/【实施计划】支付应用与订单收银台-2026-10-03.md @@ -0,0 +1,52 @@ +# 【实施计划】支付应用与订单收银台 + +| 字段 | 值 | +| --- | --- | +| Milestone | `docs/project-memory/plans/【里程碑】支付应用与订单收银台-2026-10-03.md` | +| Status | completed | +| Owner | Codex | + +## 修改边界 + +允许修改: + +- 主规范、shared-contracts、OpenAPI、支付领域 module、spacetime-module / migration / bindings、spacetime-client facade、api-server payment module、platform-wechat provider。 +- `src` 的 profile 支付入口、收银台页面、支付客户端和页面样式。 +- `apps/admin-web` 的支付 API 类型、路由、页面和后台样式。 +- 与本里程碑直接相关的测试和文档索引。 + +明确不修改: + +- 现有个人钱包充值、微信虚拟支付和退款状态机的行为。 +- 生产商户密钥、环境文件、个人配置和构建产物。 +- 支付宝 provider、分账、提现、手续费和自动结算。 + +## 实现顺序 + +1. 从现有充值订单、微信 Native provider、外部 API Key、profile 路由和后台列表组件提取可复用边界,确认没有重复的公开订单模型。 +2. 冻结支付 DTO、状态枚举、错误码、幂等语义和 OpenAPI,然后补契约测试。 +3. 增加支付领域表、migration、procedure / facade 和 schema 绑定,先完成订单创建、查询、关闭与幂等。 +4. 在 `platform-wechat` 增加支付服务需要的 Native 请求 / 响应和通知确认复用,保持商户配置只在服务端。 +5. 在 `api-server` 接入外部 API、收银台 read model、微信通知、查单和外部回调 outbox;所有到账入口复用统一确认事务。 +6. 增加个人中心支付应用 / Key / 订单页面和公共收银台,按截图参考实现桌面双栏与移动端纵向布局。 +7. 增加后台支付概览、应用、订单、通知和回调投递页面,复用 `packages/shared` 后台公共组件。 +8. 运行定向测试、schema / OpenAPI / 编码检查,启动 api-server 做 healthz 和 mock provider smoke;条件具备时执行真实沙箱二维码 smoke。 + +## 验证命令 + +1. `npm run check:encoding` +2. `git diff --check` +3. `npm run check:spacetime-schema` +4. `npm run typecheck` +5. `npm run admin-web:typecheck` +6. 支付领域和 api-server 定向 `cargo test` +7. 外部 API、profile、收银台和 admin 页面定向 Vitest +8. `npm run dev:api-server` 后检查 `/healthz` +9. mock provider + Playwright 收银台 smoke;真实商户沙箱可用时再补动态二维码验证 + +## 风险与回滚点 + +- 商户 API 证书或通知地址不可用时,保留 provider mock 证据,不把 mock 结果标成真实支付通过。 +- schema 变更失败时停止在绑定生成前,保留主规范和计划,不能通过删除数据绕过迁移。 +- 订单确认事务或回调 outbox 未完成时,关闭新支付渠道开关,不影响现有充值渠道。 +- 发现需要余额、提现、分账或支付宝真实接入时,先更新主规范和后续里程碑,再扩展实现。 diff --git a/docs/project-memory/plans/【里程碑】Web预检浏览器失败恢复-2026-10-02.md b/docs/project-memory/plans/【里程碑】Web预检浏览器失败恢复-2026-10-02.md new file mode 100644 index 000000000..e56e78d5c --- /dev/null +++ b/docs/project-memory/plans/【里程碑】Web预检浏览器失败恢复-2026-10-02.md @@ -0,0 +1,41 @@ +# Web 预检浏览器失败恢复 + +- Version: `1` +- Status: `active` +- Date: `2026-10-02` +- Parent Spec: [`AI游戏创作智能体 App 实施计划`](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md#自动预检与可信脚手架) + +## 目标 + +修复 Issue #584:Web 游戏环境预检在受控 Edge/Chrome 的 WS 握手或 CDP 连接失败时,能够只针对本轮 AGC 浏览器实例完成清理、使用新的隔离 Profile 重试一次,并在仍失败时保留安全、可行动的诊断信息。 + +## 边界 + +- 只改 AGC Rust 浏览器启动/清理、Web 预检错误投影及其定向测试、对应现行专题文档。 +- 清理对象必须同时满足 AGC 临时 Profile、进程启动身份、可信浏览器可执行文件和 Windows 进程树归属;无法确认时 fail-closed。 +- 不执行全量 `taskkill /IM msedge.exe`,不影响用户 Edge、其它 AGC 实例、其它项目或 worktree。 +- 不改变 Web 预检的失败关闭、Node/npm 检查、桌面/移动双视口检查和首次生成顺序。 +- 不新增网络、自动下载、跳过浏览器验证或伪造 ready 的旁路。 + +## 行为合同 + +1. WS 握手失败、WS 超时、CDP 连接失败或 CDP 连接超时属于可恢复的浏览器启动瞬态失败。 +2. 第一次失败后,宿主必须先确认本轮进程树已退出并清理本轮 Profile / owner 记录 / 临时目录;清理未确认时不得启动第二次浏览器。 +3. 清理确认后,第二次启动必须创建新的隔离临时目录和 Profile;第二次成功后继续现有 desktop/mobile 预检。 +4. 连续失败或清理被阻断时,结果保持 blocked,并带有阶段(WS 握手或 CDP 连接)、子进程是否退出、清理是否确认、是否执行恢复重试等安全诊断。 +5. owner.json 缺失/写入失败、目录过新、进程已退出、PID 被复用、身份未知或归属不明时只能按保守路径处理:不杀不明进程;可安全删除的临时目录才删除。 + +## 验收标准 + +- 定向测试构造 `browser-ws-failed` 后证明只在清理确认时重试,且重试使用新的 Profile;第二次成功返回成功。 +- 定向测试覆盖 WS/CDP 阶段、连续失败、清理未确认、owner.json 缺失/无效、刚创建目录、进程退出、PID 复用和非 AGC 进程跳过。 +- Windows 真实 Edge 安装版 smoke 保留在现有真实浏览器测试入口中;无 Edge 的环境明确跳过而不是伪造通过。 +- 现有首页预检、正常 Edge 使用、首次生成失败关闭和前端 IPC/宿主错误区分回归通过。 + +## 依赖 + +- `apps/ai-game-creator-shell/src-tauri/src/browser/process.rs` +- `apps/ai-game-creator-shell/src-tauri/src/browser/sweep.rs` +- `apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs` +- `apps/ai-game-creator-shell/src/features/app-shell/homeWebPreflight.ts` +- 对应 Rust 与 Vitest 测试。 diff --git a/docs/project-memory/plans/【里程碑】支付应用与订单收银台-2026-10-03.md b/docs/project-memory/plans/【里程碑】支付应用与订单收银台-2026-10-03.md new file mode 100644 index 000000000..7cf9e4f34 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】支付应用与订单收银台-2026-10-03.md @@ -0,0 +1,59 @@ +# 【里程碑】支付应用与订单收银台 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | completed | +| Date | 2026-10-03 | +| Parent Spec | `docs/【技术方案】外部产品支付服务接入-2026-10-03.md` | + +## 目标 + +建立首个可运行的支付服务闭环:账号创建支付应用并管理 API Key,客户端或外部产品创建订单,用户进入 Web 收银台,平台通过微信 Native provider 生成动态二维码,通知 / 查单确认订单,之后通过签名回调通知接入方,后台可查询应用和订单。 + +## 范围 + +- 支付应用、API Key 元数据和账号归属。 +- 外部支付订单、商品快照、支付尝试和状态机。 +- 微信 Native 动态二维码 provider 适配;支付宝只保留渠道扩展结构,不纳入本里程碑的可用渠道。 +- 公共收银台和移动端纵向布局。 +- 支付平台通知、主动查单、确认幂等和接入方回调 outbox。 +- 个人中心支付服务入口、应用配置页和订单列表。 +- 后台支付总览、应用列表、订单列表、通知 / 回调失败列表。 +- `/api/external/v1/payment/*` OpenAPI、共享 DTO、契约测试和支付领域 schema。 + +## 不在范围内 + +- 支付宝真实下单、退款、分账和沙箱联调。 +- 外部产品余额、手续费、提现、自动结算和自动分账。 +- 用户钱包充值商品的迁移或现有微信虚拟支付协议改造。 +- 自动履约发货、数字权益回收和支付争议处理。 + +## 依赖与前置条件 + +- 平台微信 Native 商户号、API 证书 / 密钥和正式或沙箱通知地址可由部署环境注入。 +- 当前微信支付 Native provider、充值订单状态与验签测试可复用。 +- 外部回调域名必须通过应用配置的 HTTPS 和白名单校验。 +- SpacetimeDB schema、migration、生成绑定和 API OpenAPI 可以在同一里程碑同步更新。 + +## 验收标准 + +- [x] 账号能创建、停用支付应用,API Key 仅在创建响应中展示一次,撤销后立即拒绝请求。 +- [x] 外部 API 使用 `Idempotency-Key` 创建订单,重放返回原订单,字段冲突返回业务错误。 +- [x] 订单金额以整数分保存,商品和金额快照创建后不可被应用配置改写。 +- [x] 收银台使用不可猜测 token,桌面端双栏、移动端纵向显示商品、金额、二维码、过期和状态。 +- [x] 微信 Native 下单返回的二维码对应当前本地平台订单号和金额。 +- [x] 支付平台通知 / 查单确认前,客户端轮询或伪造回调不能把订单改为 `paid`。 +- [x] 重复、乱序、错误金额、错误签名、错误应用归属的通知不会重复确认或改变订单。 +- [x] 接入方回调异步投递、签名、重试和失败状态可查询。 +- [x] 个人中心和后台均能按归属和权限查看应用、订单及失败记录。 +- [x] `shared-contracts`、OpenAPI、schema 绑定和契约测试同步通过。 + +## 证据要求 + +2026-10-03 验收复核:支付专用 schema、typed facade、API、应用管理、provider-attempt claim、通知确认、签名回调 outbox、后台投递状态、profile/payment 路由和前端 build 已完成。项目 dev stack 发布后 `GET /healthz` 返回 HTTP 200,live schema 包含 `payment_order` / `payment_webhook_delivery`;真实商户沙箱二维码和真实微信通知因环境未注入商户凭据未验证,按“有可用沙箱时补充”处理。 + +- 自动化:Rust 领域 / API / provider 测试、前端页面与契约测试、schema 检查、OpenAPI 检查、编码和 diff 检查。 +- 运行时:项目 dev stack 发布当前 schema 后,api-server 使用正确端口和 bootstrap 配置,`GET /healthz` 返回 HTTP 200;live schema 已包含 `payment_order` 与 `payment_webhook_delivery`;微信 provider mock 由现有 provider 测试覆盖。 +- 未验证:真实商户沙箱动态二维码和真实微信通知,当前环境没有可安全使用的商户凭据;代码保留 provider 配置失败和支付状态失败关闭语义。 +- 边界:权限、幂等、重放、金额不一致、回调失败、过期、未知通知和敏感日志检查。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 29d76aed1..4aa91f9be 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,13 @@ # 决策记录 +## 2026-10-03 AGC 栏目画布上传素材按入口栏目登记(Issue 359) + +- 背景:AGC 客户端在资源栏目子画布(「UI 交互 / 角色与对象 / 场景与环境 / 音频」)左下角工具栏点「上传」后,提示条给出「已上传 1 个素材」,但当前栏目计数不变(仍「0 项」)、素材出现在「待归类」,用户看到的是"上传成功了但它从这一页消失了"。原因是上传登记的 manifest `kind` 只由**内容证据**推导(`assets.rs::uploaded_asset_kind`:图片 / 视频 / 代码 → `unclassified`,音频 → `audio`,文档 / 字体 → `document`),kind 派生分类与栏目词汇(`ui-interaction` / `character` / `scene` / `audio`)不是同一套,而 `upload_local_asset` 原先不接受入口栏目。 +- 决策:`upload_local_asset` 增加可选 `targetCategory`,Rust 走既有的 `register_local_asset_entry_with_category`(与生成入口 `start_local_project_asset_generation` 的 `targetCategory` **同一口径**:GUI 完成登记以入口栏目为准);前端 `uploadProjectAssetFilesAndReadSnapshot` 透传该字段,栏目画布工具栏上传取工具栏自己的栏目(`resourceCanvasBottomToolbarCategory`)。取值只接受共享分类枚举,非法值由原生失败关闭,前端不做伪分类。 +- 边界:不传 `targetCategory` 时保持既有 kind 派生行为——资源面板(`ResourceCanvasPanelView`,跨栏目列表而不是栏目工具)、UI 编辑器图片导入(`import_ui_editor_local_files`)、聊天附件上传都不变。`upload_local_asset_at` 签名保持不变(委托到新的 `upload_local_asset_at_with_category`),既有约 25 处调用点与 Rust 单测零改动。 +- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/{assets.rs,commands/desktop.rs}`、`apps/ai-game-creator-shell/src/view/project-development/{projectResourceLiveUpdateModel.ts,index.tsx}`、`apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts`、PRD §3.10、`docs/technical/【AGC】栏目画布底部工具栏入口矩阵-2026-09-13.md`(§4 载荷表)、`docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md`(S11a)、`pitfalls.md`。 +- 验证:`cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --features=cocos-editor-execute,unity-editor-execute,godot-editor-execute --bin genarrative-ai-game-creator-shell assets::tests`(新增 `upload_registers_into_the_explicit_entry_category`:显式栏目 → `character`、不传 → `unclassified`、非法 `version` 失败关闭且不新增登记);`npx vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts`(196 passed / 9 skipped,含新增「uploads toolbar files into the entry column so they stay visible where they were uploaded」断言工具栏上传载荷带 `targetCategory: 'character'`);`npm run agc:typecheck`、`npm run check:encoding`、`git diff --check`。 + ## 2026-10-03 生成绑定不再经 prettier:ts-rs 原始输出即提交形态 - 背景:`scripts/check-generated-bindings.mjs` 对 AGC 的 `chat/generated` / `services/generated` 在重生成后会就地跑一遍 `npx prettier --write` 再比较。这会直接改写生成文件,还把「Rust 声明真的变了」与「prettier 版本 / 配置造成的格式漂移」混在同一条告警里——本次报出的 `ThreadRequestKind.ts` / `TurnCompletedStatus.ts`「内容变化」无法复现为语义变化(已提交内容与当前 Rust 枚举一致),prettier 归一化把格式差异也报成了「与 Rust 声明不一致」;生成物被仓库格式化工具二次改写后,重跑 `cargo test export_bindings` 也不再幂等。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 521c0a27b..75a6d0d4f 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2,6 +2,14 @@ 这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。 +## 2026-10-03 AGC 栏目画布上传素材落「待归类」:kind 派生分类不等于入口栏目 + +- **现象**(Issue 359):在 AGC 资源栏目子画布(如「UI 交互」「角色与对象」)左下角工具栏点「上传」选图片 / 视频 / 代码类文件,提示条给出「已上传 1 个素材」,但当前栏目计数纹丝不动(仍「0 项」),素材出现在「待归类」。用户看到的是"上传成功了,可它就消失在这个页面里"。 +- **原因**:上传登记的 manifest `kind` 只由**内容证据**推导(`assets.rs::uploaded_asset_kind`:图片 / 视频 / 代码 → `unclassified`,音频 → `audio`,文档 / 字体 → `document`),kind 派生分类与栏目词汇(`ui-interaction` / `character` / `scene` / `audio`)不是同一套;`upload_local_asset` 原先不接受入口栏目,GUI 工具栏上传只能落 kind 派生分类。 +- **处理(现行口径)**:`upload_local_asset` 增加可选 `targetCategory`,Rust 走既有的 `register_local_asset_entry_with_category`(非法值失败关闭,不回退 kind 派生);栏目画布工具栏上传时取工具栏自己的栏目(`resourceCanvasBottomToolbarCategory`)。生成入口 `start_local_project_asset_generation` 的 `targetCategory` 是同一口径——GUI 完成登记以入口栏目为准。 +- **判据/取证**:`cargo test --locked ... --bin genarrative-ai-game-creator-shell upload_` 的 `assets::tests::upload_registers_into_the_explicit_entry_category`(显式栏目 → `character`;不传 → `unclassified`;非法 `version` 失败关闭且不新增登记);appSurface「uploads toolbar files into the entry column so they stay visible where they were uploaded」断言工具栏上传载荷带 `targetCategory: 'character'`。 +- **边界**:资源面板(跨栏目列表)、UI 编辑器图片导入、聊天附件上传都**不带**入口栏目,保持 kind 派生。任何新增的「某个栏目里的上传入口」都必须显式带上当前栏目,否则又会复现本坑。 + ## 2026-10-01 用户输错一次密码被当成"客户端出问题了"引导上报 - **现象**:登录页密码输错(或密码长度不合规)后弹出「发现问题」,报告面板「错误事件(2)」列出 `密码长度需要在 6 到 128 位之间 — auth · 1 次` 与 `手机号或密码错误 — auth · 1 次`,默认全选,与 react-render / 5xx / agent-runtime 终态失败视觉等价。 @@ -6280,7 +6288,8 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **根因 3(工具被拒)**:sidecar 用 `permissionMode: 'dontAsk'` 且没有 `allowedTools`,宿主 MCP 工具(`mcp__agc__*`)一律被直接拒绝,模型只能回"没有权限"。 - **根因 4(界面看不到回复)**:聊天区是按 `item.completed` 事件流投影的(codex 路径在 `rawResponseItem/completed` 时下发 `ThreadItem::Message`),只把回复落进 `project.jsonl` 不会让本轮出现在界面上——用户看到"用户气泡 + 本轮结束于 … · 耗时",回复只在重进项目时从历史读出来。 - **根因 5(验收反馈复用 assistant ID)**:同一 client turn 进入 `ReviewRequired` 后会再次调用 cc。首次回复已经占用 `direct-codex::assistant`,第二次不同正文沿用该 ID 会被历史层正确拒绝为冲突,随后却被错误投影成 `runtime-unclassified`。真实诊断中可见「写入本项目对话历史失败:…assistant」且历史已经有该条回复。 -- **现行口径**:cc 成功出口由放行侧补写 `DirectTurnTerminal::completed()`(`finish_if_unfinished` 幂等,codex 已写过终态时是空操作);cc 每次解析成功后都把实际落盘的回复 item id 同步下发 `ThreadEvent::item_completed(ThreadItem::Message{role:"assistant"})`。首个回复沿用 `direct-codex::assistant`,同一回合的反馈回复遇到内容冲突时追加 `:assistant:`,相同内容仍按原 ID 幂等;落盘失败按回合失败收口。sidecar 按 `mcp__` 前缀整体放行请求里声明的 MCP 服务器(权限策略在宿主侧执行)。 +- **根因 6(cc 字符串错误覆盖了上游分类)**:DirectProject 的 Claude Code 路由原本把 sidecar 返回的所有字符串都包装成 `LlmError::Transport`,因此 HTTP 429、401、408、5xx、sidecar 超时、空回执和无效 JSON 都显示成「执行通道未能建立或已断开」。 +- **现行口径**:cc 成功出口由放行侧补写 `DirectTurnTerminal::completed()`(`finish_if_unfinished` 幂等,codex 已写过终态时是空操作);cc 每次解析成功后都把实际落盘的回复 item id 同步下发 `ThreadEvent::item_completed(ThreadItem::Message{role:"assistant"})`。首个回复沿用 `direct-codex::assistant`,同一回合的反馈回复遇到内容冲突时追加 `:assistant:`,相同内容仍按原 ID 幂等;落盘失败按回合失败收口。Claude Code 的失败文本先投影到与 Codex 相同的 `LlmError` 分类:HTTP 状态、sidecar 超时、空回执和无效 JSON 分别复用上游、超时、空响应和反序列化语义;上游状态摘要与重试建议按状态码给出。sidecar 按 `mcp__` 前缀整体放行请求里声明的 MCP 服务器(权限策略在宿主侧执行)。 - **诊断口径**:`agent.direct_turn.host_dropped` / `agent.direct_turn.panic` 里的令牌字段必须写 `tt=`,写 `turnToken=` 会命中脱敏标记,整行变成 ``,离线只剩"说不出原因"的 HostDropped。 - **验证**:dev 栈里用 CDP 注入真实回合(`node %TEMP%\agc-cdp.mjs `):①读文件轮 `claude-parse-done chars=108`,`.agent/conversations/project.jsonl` 出现 `direct-codex:cdp-…:assistant` 条目,回复内容与 `game/index.html` 前两行(`` / ``)逐字一致(证明宿主工具真的执行了);②聊天视图打开时注入 `只回三个字:收到了`,DOM 断言(`document.body.innerText`)同时出现用户气泡 `11:40:05`、助手回复 `收到了` 与 `本轮结束于 11:40:16 · 耗时 10.7秒`(证明 `item.completed` 实时投影生效,不必重进项目);同一日志不再出现新的 `host_dropped`。另有 `agent::claude_code_cli::tests::direct_claude_feedback_reply_does_not_fail_on_a_reused_client_turn_id` 回归覆盖同一回合两次不同回复。`cargo test … -- claude_code_cli::tests direct_turn_failure::tests` 19 passed。 - **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs`、`apps/ai-game-creator-shell/agent-sidecar/src/index.mjs`。 diff --git a/docs/technical/assets/payment-service-20261003/payment-checkout-mobile.png b/docs/technical/assets/payment-service-20261003/payment-checkout-mobile.png new file mode 100644 index 000000000..999f6e55c Binary files /dev/null and b/docs/technical/assets/payment-service-20261003/payment-checkout-mobile.png differ diff --git a/docs/technical/assets/payment-service-20261003/payment-checkout.png b/docs/technical/assets/payment-service-20261003/payment-checkout.png new file mode 100644 index 000000000..84956ac72 Binary files /dev/null and b/docs/technical/assets/payment-service-20261003/payment-checkout.png differ diff --git a/docs/technical/assets/payment-service-20261003/profile-payment-login.png b/docs/technical/assets/payment-service-20261003/profile-payment-login.png new file mode 100644 index 000000000..aebcb3ff6 Binary files /dev/null and b/docs/technical/assets/payment-service-20261003/profile-payment-login.png differ diff --git a/docs/technical/【AGC】栏目画布底部工具栏入口矩阵-2026-09-13.md b/docs/technical/【AGC】栏目画布底部工具栏入口矩阵-2026-09-13.md index 1de78d7c0..ab8643343 100644 --- a/docs/technical/【AGC】栏目画布底部工具栏入口矩阵-2026-09-13.md +++ b/docs/technical/【AGC】栏目画布底部工具栏入口矩阵-2026-09-13.md @@ -6,7 +6,7 @@ ## 1. 一句话 -功能画布 = 资源栏目页;栏目页左下角渲染一条由栏目 `category` 决定的工具栏,图片类与音频入口都走本地 `start_local_project_asset_generation`(提交即返回、后台生成,见 2026-09-20 音频并入后台任务账本),上传复用 `upload_local_asset`;生成 / 上传成功后一律走既有 manifest 刷新与资源定位。 +功能画布 = 资源栏目页;栏目页左下角渲染一条由栏目 `category` 决定的工具栏,图片类与音频入口都走本地 `start_local_project_asset_generation`(提交即返回、后台生成,见 2026-09-20 音频并入后台任务账本),上传复用 `upload_local_asset`(带当前栏目 `targetCategory`,素材登记进上传它的那一栏);生成 / 上传成功后一律走既有 manifest 刷新与资源定位。 ## 2. 入口矩阵(事实源) @@ -42,7 +42,7 @@ | 生成 UI 设计图 | 同上 | `kind: 'ui-design'`,默认 `16:9 · 1K`;前置同上 | | 生成背景音乐 | `start_local_project_asset_generation` | `kind: 'background-music'`、`idempotencyKey`;任务 id 即该次生成的 operation id | | 生成音效 | `start_local_project_asset_generation` | `kind: 'sound-effect'`、其余同上 | -| 上传 | `upload_local_asset` | `{ projectPath, fileName, mediaType, bytes }` | +| 上传 | `upload_local_asset` | `{ projectPath, fileName, mediaType, bytes, targetCategory }`;`targetCategory` = 当前栏目(Issue 359) | `start_local_project_asset_generation` 的完整参数是 `{ projectPath, projectId, taskId, kind, prompt, aspectRatio, imageSize, assetName, outputPath, idempotencyKey }`(Rust `src-tauri/src/asset_generation_tasks.rs`);图片类入口沿用 `{ projectPath, projectId, taskId, kind, prompt, aspectRatio, imageSize, assetName, outputPath }` 逐字不变,音频入口只带 `{ projectPath, projectId, taskId, kind, prompt, assetName, idempotencyKey }`(`idempotencyKey` = 该次生成的幂等键,任务 id 即 operation id;原生命令内部仍复用既有音频无源生成链路,不新增平台路由与请求体口径),**提交即返回**一条任务记录(`taskId / status / phaseDetail / assetId / error` 等);生成在 `tauri::async_runtime::spawn` 出来的后台任务里跑,账本落在项目内 `.agent/runtime/asset-generation-tasks/tasks.json`,进度由 `list_local_project_asset_generations` 读回。截图面板不再「等生成结束」,所以**点「生成」即同步关闭面板**(不等 IPC),面板内不出现阶段文案;只有「点击瞬间就失败」才带草稿重开(关闭 ≠ 取消)。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 39ea7a151..b9c3bb849 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -134,7 +134,7 @@ UI 编辑器的“分析参考图”步骤、Rust 命令 `suggest_ui_design_sema - 对客户端刚创建且内容仍匹配可信模板的 Web 脚手架,在正式生成前由宿主执行受控依赖准备和真实 Vite 构建。依赖安装禁用生命周期脚本;不自动安装或覆盖导入/用户修改过的工程。 - 准备凭证的 `ready` 只证明初次环境准备成功,不代表当前游戏已验收,后续正常修改不得因此重新安装。`preparing` 中断恢复必须证明原拥有者已结束且其执行子树已回收;身份或归属未知时保留阻断,不重复执行。 - 输出明确区分“Node/npm 构建能力通过”与“本项目 Vite 构建通过”;任何一步失败保留可行动错误,不降级为预检成功。 -- 首页必须区分宿主预检阻塞与 Tauri IPC 调用失败:Rust 返回的安全错误码可透传到状态栏;无错误码的瞬态 IPC 失败只允许一次有界重试后显示独立的客户端连接故障,不得统一伪装成 Node/npm 或浏览器故障。预检仍保持失败关闭,不得因为展示错误变得可绕过。 +- 首页必须区分宿主预检阻塞与 Tauri IPC 调用失败:Rust 返回的安全错误码可透传到状态栏;无错误码的瞬态 IPC 失败只允许一次有界重试后显示独立的客户端连接故障,不得统一伪装成 Node/npm 或浏览器故障。预检仍保持失败关闭,不得因为展示错误变得可绕过。浏览器 WS 握手、WS 超时和 CDP 连接失败只允许在确认本轮 AGC 浏览器进程树已退出、Profile/owner/临时目录已按归属清理后使用新的隔离 Profile 自动恢复一次;清理未确认时不得重试,连续失败必须返回包含阶段、子进程退出确认、清理确认和是否重试的安全诊断。 - 安装载荷提供相同的安全预检 CLI,验证无系统 Node 的独立运行、缺包/篡改失败关闭。NSIS 解包载荷 smoke 与真实安装器注册流程分开报告,不覆盖当前用户的既有安装。 ### 跑酷固定基线 @@ -188,7 +188,7 @@ UI 编辑器的“分析参考图”步骤、Rust 命令 `suggest_ui_design_sema ### 环境与工作流 - 客户端交付配套 Node/npm;发布包从本机已安装且与目标平台/架构一致的工具链制作受校验资源,保留许可并校验内容摘要。安装态不依赖系统 PATH 的 Node;开发态可使用已验证的宿主运行时。不得从项目或相对 PATH 加载伪造运行时。随包运行时是**单架构**官方发行版,因此 macOS 当前只构建 `aarch64-apple-darwin` 单架构包;要出 universal 必须先让 staging 支持按架构各带一份同版本运行时,在此之前 universal 目标失败关闭,不得只带宿主架构那一份糊过去。 -- 新建 Web 游戏在生图和大量实现前执行客户端环境预检,检查 Node/npm 的实际版本、浏览器启动和 CDP 可用性。报告只包含安全状态、版本、耗时和错误码;错误码必须按真实原因分流,浏览器验证只允许在确有证据时使用 `web-preflight-browser-missing` / `-launch-timeout` / `-launch-failed` / `-browser-cleanup-failed`,取消、证据写入、输入与页面校验各有独立码,未识别原因落回专用 `web-preflight-unclassified`,不得用一个具体子系统码兜底。缺失或异常必须尽早返回阻塞,不能指示模型改宿主环境、全盘搜索或自行下载一套运行时。编辑器工程不强制 Web 工具链。 +- 新建 Web 游戏在生图和大量实现前执行客户端环境预检,检查 Node/npm 的实际版本、浏览器启动和 CDP 可用性。报告只包含安全状态、版本、耗时和错误码;错误码必须按真实原因分流,浏览器验证只允许在确有证据时使用 `web-preflight-browser-missing` / `-launch-timeout` / `-launch-failed` / `-browser-environment-failed` / `-browser-cleanup-failed` / `-browser-recovery-failed`,取消、证据写入、输入与页面校验各有独立码,未识别原因落回专用 `web-preflight-unclassified`,不得用一个具体子系统码兜底。浏览器恢复诊断只保留阶段、进程退出、清理确认和重试状态等安全字段,不暴露命令行、路径或上游原文。缺失或异常必须尽早返回阻塞,不能指示模型改宿主环境、全盘搜索或自行下载一套运行时。编辑器工程不强制 Web 工具链。 - 预检不安装依赖、不修改项目 revision、不请求平台生成;构建仍执行项目自己的 npm 脚本。Codex 隔离 HOME 与平台凭据边界保持不变,客户端把已验证的运行时加入执行 PATH,不能把宿主凭据目录交给模型。 - 第一轮先明确本次必需玩法、素材和验收项。同批独立读取尽量合并,必需图片一次规划;已有且可用的资产复用。已有目标全部通过后给出交付结果,非阻塞的新点子列为后续工作,不在收尾时主动开启新的生产链。 @@ -331,7 +331,7 @@ Rust 侧在 `server-rs/crates/shared-contracts` 维护唯一权威 `GameCreation - 参考选择范围为同一项目已登记图片,可跨栏目、多选,无规范前置时最多 5 张,有规范前置时最多 4 张用户参考(总计最多 5 张);复用资源引用选择组件,不允许文档、音视频、占位或跨项目素材。本地生成命令补最小引用 ID 参数并转换为当前账号绑定下的远端资源 ID,沿用图片生成 API 已有 `referenceImageSrcs`。需要规范图的普通图片请求合并并去重规范引用,总数不超过现有 API 限制;只接受单规范引用的图集操作不显示用户参考选择器,原生提交拒绝额外参考而非静默丢弃。不得降级成纯提示词。 - 占位由宿主按项目与独立草稿 ID 管理,提交后关联任务 ID;失败重试使用同一占位。切项目清理未提交草稿与界面位置,已提交任务继续沿用账本恢复,重开后不承诺恢复未持久化的占位位置。迟到结果先核对项目和任务归属;只有本会话仍存在的占位才应用最新位置。删除占位只隐藏展示,不取消后台任务或丢弃正式结果。 - 参考必须是原生可解码的栅格图片,SVG 不进入参考候选;原生在上传任何引用前预校验整组素材的归属、受控路径、文件及解码,失败不静默丢图。需要重新上传当前账号绑定的参考遵循 `asset.upload` 权限。manifest 读侧的引用形状检查不证明远端账号归属,实际生成始终通过当前账号 binding 解析,不凭历史来源 ID 发起请求。 -- 图片类 GUI 生成通过可选 `targetCategory` 在原生登记时写入入口栏目,使用既有 manifest `category` 字段及合法分类词表;不传时保留按 kind 派生的行为,Agent 不传。该值不改变远端生成内容及计费幂等槽,仅决定本地生成结果分类;重试保持原占位栏目。同路径重新生成时,主产物按本次入口栏目更新分类(包含覆盖此前手动分类),图集附属切片保持既有独立分类规则。 +- 图片类 GUI 生成通过可选 `targetCategory` 在原生登记时写入入口栏目,使用既有 manifest `category` 字段及合法分类词表;不传时保留按 kind 派生的行为,Agent 不传。该值不改变远端生成内容及计费幂等槽,仅决定本地生成结果分类;重试保持原占位栏目。同路径重新生成时,主产物按本次入口栏目更新分类(包含覆盖此前手动分类),图集附属切片保持既有独立分类规则。GUI 上传(栏目画布底部工具栏的 `upload_local_asset`)与图片类生成**同一口径**:带可选 `targetCategory` 在当前栏目登记,不传(资源面板、UI 编辑器导入、聊天附件)时保持按内容证据派生 kind 的行为。 - 生成面板打开后,占位和面板需处于当前画布标题栏与底部工具栏之间;面板复用公共外观,空间不足时面板内部滚动,提交按钮可达。音频/BGM 占位绑定原有 operation/idempotency 身份,进行中不允许换身份重复提交;失败可用原身份重试,成功结果与图片一样接管占位。关闭未提交浮层保留可继续编辑的草稿,删除占位才丢弃该草稿。 - 整理范围为当前栏目页全部资源;“所有资源”页为当前项目所有可展示资源,总览不新增整理行为。重排结果成为自动坐标,可撤销恢复原坐标与手动标记;历史仅保留当前会话,切项目清空。多选仅作用于当前画布可见选中资源,不携带筛选隐藏或跨栏目残留选择;取消手势恢复拖动前坐标,切项目清空选择。 - 当前素材名以现有正式命名链路为准:生成时 assetName 参与落盘名称,重命名更新文件名;卡片消费正式资源 label,不从临时输入或历史任务名覆盖后续重命名,不新增平行显示名持久化。若原有命名链路丢失 assetName,则修复原链路,而非只在卡片本地伪造。文档卡不显示任何正文摘要,但详情原文与 JSON 识别读取不变。 diff --git a/docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md b/docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md index 08df2cc5f..07b6c5b88 100644 --- a/docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md +++ b/docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md @@ -57,7 +57,7 @@ | **S15** 删除 | 工具条 / 标签面板「删除」→ 确认弹窗 | 三分支:① 无引用→直接删;② 被引用未勾选→只删素材、版本保留**悬空绑定**、界面不合成幽灵资源卡;③ 勾选→素材与该批版本在**同一次 manifest 写入**内一起删 | L413(版本只追加的唯一例外)、L532–533;#309 C5 | 弹窗 `ariaLabel="确认删除资源"`;被引用时出现 `被 N 个游戏版本使用` + 版本列表 + checkbox `把相关游戏版本一并删除`;无引用时该 body 整块不渲染 | **只摘登记、不删磁盘文件**;读引用命令精确名是 `read_local_project_asset_references` | | **S15a** 替换素材(面板内选 + 画布点选目标) | 选中被**当前版本**绑定的素材 → 工具条「替换素材」→ 画布右上角出现非模态候选面板(面板内可筛可选)→ 直接在画布上点目标素材(或点面板里的候选)→ 面板「确认」 | 面板确认与画布点选是**同一条**写入链路(`replace_local_project_version_resource`,载荷逐字一致):改该版本绑定、不建新版本;画布点选只把目标落成面板的当前选择,确认后才写入 | PRD §5.3 / §7.8 第 8 条;#309「直接替换」口径 | 面板判据:`role="dialog"` 名为「选择替换素材」、挂 `.image-canvas-editor__project-asset-picker--floating`、**没有** `.platform-overlay` 遮罩;点画布候选后面板里该候选 `aria-selected="true"`、面板里的搜索词与分类筛选保持原样、零写入;非法目标(源素材本身 / 不在权威候选里 / 分类不同 / 未登记资源)在面板里出现 `role="alert"`(「替换素材与源素材相同」/「替换素材未登记或已被删除」/「替换素材不兼容:分类不同」)且零写入;面板关闭 = 卸载;Esc 只收面板(画布全局 Esc 的清选中不随之触发) | 点选只能点**当前画布上可见**的卡:目标在别的栏目时先切栏目(替换会话跨栏目存活,不因「收起资源」或切栏目结束);面板开着时空白处仍可框选 / 平移,卡片单击语义的恢复发生在面板关闭之后 | -| **S11a** 栏目画布底部工具栏 | 进「UI 交互 / 角色与对象 / 场景与环境 / 音频」任一栏目 → 点左下角工具栏里的入口生成 → 「收起资源」回总览看工具栏消失 | 工具栏只在矩阵四个栏目(功能画布)渲染;图片类入口走 `start_local_project_asset_generation`(提交即返回、生成在后台跑),音频入口走同一条命令的音频载荷,上传复用 `upload_local_asset`;生成 / 上传成功后走既有 manifest 刷新与资源定位 | PRD §3.10、§7.9 | DOM 判据 `[data-resource-bottom-toolbar=""]`(资源总览、「所有资源」展开态、文档、待归类、项目版本都**没有**这个节点);工具栏是 `.game-resource-book-manager` 的直接子节点(`closest('.game-resource-book-scene')` 为 `null`);每个入口一次 `start_local_project_asset_generation`:图片类载荷逐字为 `{ projectPath, projectId, taskId, kind, prompt, aspectRatio, imageSize, assetName, outputPath }`(`kind` 映射见 PRD §7.9 第 3 条;`taskId` 是前端每次提交新铸的本地任务 id),音频类载荷为 `{ projectPath, projectId, taskId, kind, prompt, assetName, idempotencyKey }`(`taskId` 即该次生成的 operation id,不发图片类那套比例 / 尺寸 / 参考 / 落点参数);该命令提交即返回,之后有 `list_local_project_asset_generations` 轮询与 `get_local_game_project_revision` + `get_local_game_manifest` 的配对读;点「生成」即把这次输入交给后台账本并**同步关闭**面板(不等 IPC、不等排队、不等生成,画布立即恢复可交互;面板 DOM 里没有阶段文案与「后台运行并关闭」这类在途按钮),关闭不等于取消,关闭后任务仍出现在「生成任务」面板(入口按钮 `aria-label="生成任务"`,非模态浮层、无 `aria-modal`)并显示后端 `phaseDetail`;第一条未终态时提交第二条 → 第二条显示「排队中。」且生成提交 IPC 次数仍为 1,第一条终态后自动补发(次数变 2);缺 `assets/art-spec.png` 时「生成图标素材 / 生成 UI 设计图」仍可点击(`aria-disabled="true"` 但**不是**原生 disabled)并给出含该路径的 `role="alert"` 原因、零生成请求;音频入口面板标题即「生成背景音乐」/「生成音效」且没有类型选择器 | ① 本地通道没有 `model` / `specType` / `replaceExisting` 入参:面板不渲染模型选择器,角色规范与自定义规范共用 `spec` 通道(靠 `assetName` 与提示词区分),「图标规范」在项目已有权威规范图时不再指向 `assets/art-spec.png`(否则会被 Rust 的防覆盖校验硬拒);② 本轮不做生成视频 / 宣发素材 / 生成游戏场景 / 选择工具 / 抓手工具;③ 既有「生成素材」浮层入口只保留生成视频,音频入口只在音频栏目工具栏出现 | +| **S11a** 栏目画布底部工具栏 | 进「UI 交互 / 角色与对象 / 场景与环境 / 音频」任一栏目 → 点左下角工具栏里的入口生成 → 「收起资源」回总览看工具栏消失 | 工具栏只在矩阵四个栏目(功能画布)渲染;图片类入口走 `start_local_project_asset_generation`(提交即返回、生成在后台跑),音频入口走同一条命令的音频载荷,上传复用 `upload_local_asset` 并带上当前栏目 `targetCategory`(素材落在上传它的那一栏,不落「待归类」);生成 / 上传成功后走既有 manifest 刷新与资源定位 | PRD §3.10、§7.9 | DOM 判据 `[data-resource-bottom-toolbar=""]`(资源总览、「所有资源」展开态、文档、待归类、项目版本都**没有**这个节点);工具栏是 `.game-resource-book-manager` 的直接子节点(`closest('.game-resource-book-scene')` 为 `null`);每个入口一次 `start_local_project_asset_generation`:图片类载荷逐字为 `{ projectPath, projectId, taskId, kind, prompt, aspectRatio, imageSize, assetName, outputPath }`(`kind` 映射见 PRD §7.9 第 3 条;`taskId` 是前端每次提交新铸的本地任务 id),音频类载荷为 `{ projectPath, projectId, taskId, kind, prompt, assetName, idempotencyKey }`(`taskId` 即该次生成的 operation id,不发图片类那套比例 / 尺寸 / 参考 / 落点参数);该命令提交即返回,之后有 `list_local_project_asset_generations` 轮询与 `get_local_game_project_revision` + `get_local_game_manifest` 的配对读;点「生成」即把这次输入交给后台账本并**同步关闭**面板(不等 IPC、不等排队、不等生成,画布立即恢复可交互;面板 DOM 里没有阶段文案与「后台运行并关闭」这类在途按钮),关闭不等于取消,关闭后任务仍出现在「生成任务」面板(入口按钮 `aria-label="生成任务"`,非模态浮层、无 `aria-modal`)并显示后端 `phaseDetail`;第一条未终态时提交第二条 → 第二条显示「排队中。」且生成提交 IPC 次数仍为 1,第一条终态后自动补发(次数变 2);缺 `assets/art-spec.png` 时「生成图标素材 / 生成 UI 设计图」仍可点击(`aria-disabled="true"` 但**不是**原生 disabled)并给出含该路径的 `role="alert"` 原因、零生成请求;音频入口面板标题即「生成背景音乐」/「生成音效」且没有类型选择器 | ① 本地通道没有 `model` / `specType` / `replaceExisting` 入参:面板不渲染模型选择器,角色规范与自定义规范共用 `spec` 通道(靠 `assetName` 与提示词区分),「图标规范」在项目已有权威规范图时不再指向 `assets/art-spec.png`(否则会被 Rust 的防覆盖校验硬拒);② 本轮不做生成视频 / 宣发素材 / 生成游戏场景 / 选择工具 / 抓手工具;③ 既有「生成素材」浮层入口只保留生成视频,音频入口只在音频栏目工具栏出现 | ### D 阶段 · 版本与运行 diff --git a/docs/【使用说明】支付服务接入与收银台使用说明-2026-10-03.md b/docs/【使用说明】支付服务接入与收银台使用说明-2026-10-03.md new file mode 100644 index 000000000..5083fc382 --- /dev/null +++ b/docs/【使用说明】支付服务接入与收银台使用说明-2026-10-03.md @@ -0,0 +1,310 @@ +# 支付服务接入与收银台使用说明 + +> 适用版本:2026-10-03 支付应用与订单收银台里程碑 +> +> 本说明面向三类使用者:平台账户管理员、接入平台支付的游戏或外部产品开发者、负责查看订单与回调的后台运营人员。 + +## 1. 服务范围 + +平台提供一套统一支付服务:接入方提交商品、订单和金额,平台使用已配置的微信支付商户号创建 Native 支付订单,并返回网页收银台地址与动态二维码。用户在网页中扫码付款,平台收到微信支付结果后更新订单,再向接入方配置的回调地址通知。 + +当前里程碑的边界如下: + +| 能力 | 当前状态 | +| --- | --- | +| 微信 Native 扫码支付 | 已支持 | +| 游戏客户端或外部产品接入 | 已支持,统一使用 External v1 API | +| 网页收银台 | 已支持,桌面端双栏、移动端上下布局 | +| 订单记账、查单、回调通知 | 已支持 | +| 后台订单与回调投递记录 | 已支持 | +| 自动分账、自动提现、人工结算工作流 | 本里程碑不包含,暂由平台人工处理 | +| 支付宝、退款、周期扣款 | 本里程碑不包含 | + +金额以整数分传递,默认币种为 `CNY`。生产环境必须使用平台已经开通接口的微信支付商户号;前端和外部产品都不能直接持有商户证书或调用微信支付接口。 + +## 2. 一次完整支付流程 + +1. 平台账户管理员在“我的 → 支付服务”创建一个支付应用。 +2. 平台生成该应用的支付 API Key。完整 Key 只在创建成功时显示一次,应立即复制到接入方服务端的密钥管理系统。 +3. 外部产品服务端使用 API Key 和 `Idempotency-Key` 创建订单。 +4. 平台服务端向微信支付商户号下单,返回 `checkoutUrl` 和 `providerQrCode`。 +5. 外部产品将用户跳转到 `checkoutUrl`,或在自己的客户端中打开该网页。 +6. 用户使用微信扫描收银台二维码付款。 +7. 微信支付通知平台,平台校验订单号、金额和支付状态后将订单改为 `paid`。 +8. 如果应用配置了成功回调地址,平台通过回调投递器通知外部产品;外部产品再按自己的业务规则发放道具、虚拟币或权益。 +9. 外部产品遇到网络超时或回调延迟时,使用查单接口恢复订单状态,不要换一个幂等键重新下单。 + +## 3. 创建和管理支付应用 + +### 3.1 打开入口 + +登录平台后进入“我的”,选择“支付服务”。未登录时会看到登录入口: + +![支付服务未登录入口](technical/assets/payment-service-20261003/profile-payment-login.png) + +图 1:支付服务入口截图。截图来自本地前端预览环境,未登录状态不会读取或展示任何支付应用。 + +### 3.2 创建应用 + +在“创建接入应用”中填写: + +| 字段 | 填写规则 | +| --- | --- | +| 应用名称 | 用于区分游戏或外部产品,例如“星港商城”或“我的游戏支付” | +| 支付成功回调地址 | 可选。建议填写接入方服务端的 HTTPS 地址,例如 `https://game.example.com/payment/callback` | + +回调地址必须使用 HTTPS;本地开发可以使用 loopback HTTP 地址。不能包含用户名、密码或 URL fragment。保存后平台会创建应用和默认支付 API Key。 + +### 3.3 保存 API Key + +创建成功后页面会显示完整 API Key,并提示“完整 API Key 只显示这一次”。操作要求: + +1. 立即点击复制按钮。 +2. 只保存到接入方服务端的环境变量或密钥管理服务。 +3. 不要提交到 Git、客户端安装包、网页源码、截图或工单。 +4. API Key 泄露时,在“支付 API Key”区域撤销旧 Key,再创建新的支付应用 Key。 + +页面只展示 Key 前缀、所属应用、权限、创建时间、最后使用时间和撤销状态,不再次展示完整密钥。 + +### 3.4 停用应用和撤销 Key + +- “我的支付应用”中的“停用”会阻止该应用继续创建支付订单;历史订单和后台记录保留。 +- “支付 API Key”中的“撤销 Key”会使该 Key 立即失效;已经创建的订单不因撤销 Key 自动取消。 +- 更换 Key 后,必须同步更新接入方服务端配置,并保留一次查单验证。 + +## 4. 外部产品接入 + +### 4.1 公共地址和鉴权 + +以部署域名作为 API 根地址,以下示例中的 `$BASE_URL` 替换为实际地址: + +```text +$BASE_URL/api/external/v1/payment/orders +``` + +请求头: + +```http +Authorization: Bearer tnr_pay_xxxxxxxxxxxxxxxxx +Content-Type: application/json +Idempotency-Key: game-order-20261003-000001 +``` + +支付 API Key 的格式前缀为 `tnr_pay_`。`Idempotency-Key` 要求为 1-128 个可打印 ASCII 字符且不能包含空格;它代表一次业务下单意图。网络结果不确定时,必须复用原值。 + +### 4.2 创建订单 + +```bash +curl -X POST "$BASE_URL/api/external/v1/payment/orders" \ + -H "Authorization: Bearer $PAYMENT_API_KEY" \ + -H "Content-Type: application/json" \ + -H "Idempotency-Key: game-order-20261003-000001" \ + -d '{ + "merchantOrderId": "game-order-20261003-000001", + "title": "星港商城 - 600 水晶", + "items": [ + {"sku": "crystal-600", "name": "600 水晶", "quantity": 1} + ], + "amountCents": 19600, + "currency": "CNY", + "provider": "wechat_native" + }' +``` + +请求字段: + +| 字段 | 必填 | 说明 | +| --- | --- | --- | +| `merchantOrderId` | 是 | 接入方自己的订单号;建议使用可追踪的 ASCII 字符串,微信 Native 外部订单号最终不超过 32 个字符 | +| `title` | 是 | 收银台展示的商品或订单标题 | +| `items` | 否 | 商品明细,会被原样记账;不参与平台金额计算 | +| `amountCents` | 是 | 应付金额,整数分;例如 `19600` 表示人民币 `196.00` | +| `currency` | 否 | 当前使用 `CNY` | +| `provider` | 否 | 当前使用 `wechat_native` | + +金额由服务端订单快照决定。客户端传来的商品明细不能作为最终收款金额,接入方应在自己的服务端完成价格校验后再调用创建订单。 + +### 4.3 创建响应 + +成功响应为 HTTP `200`,核心字段如下: + +```json +{ + "order": { + "orderId": "6e7d8c9a0b1c4d2e9f8a7b6c5d4e3f2a", + "merchantOrderId": "game-order-20261003-000001", + "title": "星港商城 - 600 水晶", + "amountCents": 19600, + "currency": "CNY", + "provider": "wechat_native", + "status": "paying", + "checkoutToken": "pay_xxxxxxxxx", + "checkoutUrl": "/pay/pay_xxxxxxxxx", + "providerQrCode": "weixin://wxpay/bizpayurl?...", + "expiresAt": "2026-10-03T07:05:00Z" + } +} +``` + +接入方应优先使用完整部署域名拼接 `checkoutUrl`,例如 `https://www.example.com/pay/pay_xxxxxxxxx`。不要把 `providerQrCode` 直接当作支付成功依据;它只是动态二维码内容,支付状态仍以平台订单为准。 + +### 4.4 查询订单 + +```bash +curl "$BASE_URL/api/external/v1/payment/orders/6e7d8c9a0b1c4d2e9f8a7b6c5d4e3f2a" \ + -H "Authorization: Bearer $PAYMENT_API_KEY" +``` + +建议在收银台打开后每 3-5 秒查询一次,直到状态进入终态: + +| 状态 | 含义 | 接入方动作 | +| --- | --- | --- | +| `pending` | 已创建,尚未完成支付平台下单 | 继续查单;如长时间不变,查看后台或服务日志 | +| `paying` | 已拿到支付平台二维码,等待用户付款 | 展示收银台并等待通知或查单 | +| `paid` | 已确认到账 | 只在此状态发放游戏商品或权益 | +| `expired` | 订单超过有效期 | 不发货,提示用户重新下单 | +| `closed` | 订单已关闭 | 不发货 | +| `refunded` | 已退款 | 按接入方规则撤销或冻结权益 | + +同一个 `Idempotency-Key` 携带相同请求体会返回首次订单;同一个 Key 携带不同请求体会返回 `409`。遇到连接超时,不要生成新 Key 盲目重试。 + +## 5. 网页收银台 + +外部产品拿到 `checkoutUrl` 后,可以: + +- 游戏客户端使用系统浏览器或内嵌 WebView 打开。 +- Web 产品使用新窗口、弹窗或当前页面跳转。 +- 自己展示商品说明,但最终金额应以平台收银台显示金额为准。 + +桌面端收银台左侧展示订单号、商品标题、付款提示和等待状态,右侧展示应付金额、微信 Native 动态二维码和支付渠道: + +![支付收银台桌面版](technical/assets/payment-service-20261003/payment-checkout.png) + +图 2:支付收银台桌面版。此图使用浏览器会话内的模拟订单 `测试游戏道具 / ¥196.00`,用于说明布局,不对应真实商户订单。 + +移动端会自动改为上下布局,用户可以在较窄屏幕中滚动查看金额、二维码和支付说明: + +![支付收银台移动版](technical/assets/payment-service-20261003/payment-checkout-mobile.png) + +图 3:支付收银台移动版。二维码内容由页面的 QR 组件根据模拟支付链接生成;生产环境二维码由微信支付商户接口返回。 + +收银台的支付提示: + +1. 用户必须完全按照页面显示金额付款,少付或多付都可能无法自动确认。 +2. 付款后页面会轮询订单并自动更新状态,但接入方仍应以服务端查单或回调为最终依据。 +3. 订单过期后不要继续使用旧二维码,应重新创建订单。 + +## 6. 支付成功回调 + +### 6.1 平台支付通知 + +微信支付通知地址由平台环境变量 `WECHAT_PAYMENT_NOTIFY_URL` 配置,生产环境必须是外网可访问的 HTTPS 地址,例如: + +```text +https://www.example.com/api/payment/wechat/notify +``` + +该地址接收微信支付商户通知。平台会校验微信通知签名、商户订单号、金额、支付状态和交易号,校验通过后才把本地订单改为 `paid`。 + +### 6.2 外部产品回调 + +如果创建支付应用时填写了“支付成功回调地址”,平台会向该地址投递 JSON: + +```json +{ + "event": "payment.paid", + "orderId": "6e7d8c9a0b1c4d2e9f8a7b6c5d4e3f2a", + "appId": "payapp_xxxxxxxxx", + "merchantOrderId": "game-order-20261003-000001", + "amountCents": 19600, + "currency": "CNY", + "provider": "wechat_native", + "providerTradeNo": "4200000000000000000000000000", + "status": "paid", + "paidAt": "2026-10-03T07:01:12Z" +} +``` + +回调请求头: + +```http +X-Genarrative-Signature: sha256= +X-Genarrative-Event: payment.paid +X-Genarrative-Delivery-Id: delivery_xxxxxxxxx +``` + +首期签名规则为:使用创建该支付应用时生成的 API Key 哈希作为 HMAC 密钥,对原始请求体字节执行 `HMAC-SHA256`,并将结果编码为小写十六进制;请求头值为 `sha256=` 加十六进制结果。接入方必须使用原始 body 验签,不要先反序列化再重新序列化。独立可轮换的 callback secret 属于后续安全增强,不要在当前版本按未实现字段接入。 + +回调处理要求: + +1. 先验签,再按 `orderId` 或 `merchantOrderId` 查询自己的订单。 +2. 对 `deliveryId` 和业务订单号做幂等处理,重复通知返回成功,不重复发货。 +3. 成功处理后返回任意 HTTP `2xx`,建议返回 `204`。 +4. 非 `2xx` 会进入平台回调重试;平台最多尝试 8 次,最终失败会出现在后台“外部回调投递”表中。 + +## 7. 后台查看订单和回调 + +管理员进入后台,在侧边栏选择“支付订单”,或打开后台路由 `#payment-orders`。页面分为两张表: + +### 7.1 外部回调投递 + +用于确认平台是否成功通知了接入方,包含:投递 ID、订单号、应用、回调地址、投递状态、尝试次数、最近错误和更新时间。 + +常见状态: + +- `pending`:等待投递或等待下一次重试。 +- `delivered`:接入方返回了成功状态码。 +- `failed`:达到重试上限,需人工排查回调地址、验签和接入方服务状态。 + +### 7.2 订单记录 + +用于查看外部订单号、平台订单号、应用、商品标题、金额、渠道、订单状态、创建时间和支付时间。金额显示为币种和两位小数,订单状态以服务端记录为准。 + +本阶段后台用于收款记账和通知核对,不提供自动给外部产品打款或分账按钮。需要结算时,按订单记录和商户账单人工处理。 + +## 8. 生产配置清单 + +部署前由后端运维配置,不要把下列值写入前端: + +```text +WECHAT_PAY_ENABLED=true +WECHAT_PAY_PROVIDER=real +WECHAT_PAYMENT_NOTIFY_URL=https://<公开域名>/api/payment/wechat/notify +``` + +同时配置现有微信支付 V3 商户信息、商户证书、私钥、平台证书或平台证书密钥等环境变量。真实商户配置必须通过部署系统的密钥注入完成。`WECHAT_PAY_PROVIDER=mock` 只用于本地开发和页面联调,不能用于生产收款。 + +上线前至少验证: + +1. 使用真实商户沙箱或小额测试单创建 Native 订单。 +2. 微信扫码后平台订单能变为 `paid`。 +3. `WECHAT_PAYMENT_NOTIFY_URL` 能收到并确认微信通知。 +4. 接入方回调能通过验签,并且重复回调不会重复发货。 +5. 后台订单表和回调投递表都有对应记录。 + +## 9. 常见问题 + +| 现象 | 优先检查 | +| --- | --- | +| 返回 `401` | Authorization 是否是支付应用的 Key;Key 是否已撤销;是否误用了普通 External API Key | +| 返回 `403` | 支付应用是否已停用;Key 是否具备订单创建或查询 scope | +| 返回 `409` | 是否复用了已有 `Idempotency-Key` 但请求体不同;网络超时场景应复用原请求体和原 Key | +| 返回“缺少 `WECHAT_PAYMENT_NOTIFY_URL`” | 生产真实 provider 必须配置公开 HTTPS 通知地址;本地 mock 才使用默认 loopback 地址 | +| 一直是 `pending` | 查看微信下单是否成功、后台订单是否有 provider 码、部署日志和商户配置;不要直接给用户发货 | +| 用户已付款但接入方未发货 | 先调用查单接口;再看后台“外部回调投递”的状态和最近错误;必要时人工补偿,但要保留幂等记录 | +| 二维码无法识别 | 检查是否把 `providerQrCode` 原样交给 QR 组件;不要对内容做 URL 编码或截断 | +| 本地截图能显示、生产无二维码 | 截图使用的是 mock 数据;生产必须确认微信商户接口返回了 `code_url`,并检查支付服务配置 | + +## 10. 安全与责任边界 + +- API Key 只能放在外部产品服务端,不能放进游戏客户端、浏览器 localStorage 或公开仓库。 +- 客户端传回的“支付成功”事件只能作为 UI 提示,不能作为发货凭据。 +- 发货必须由外部产品服务端根据平台查单或验签回调执行,并以订单号做幂等。 +- 不要记录完整 API Key、微信商户私钥、微信通知原文中的敏感字段或用户支付凭据。 +- 截图中的订单号、金额和二维码均为演示数据;生产环境会由后端订单和平台微信商户接口动态生成。 + +## 11. 相关契约 + +- [外部产品支付服务接入技术方案](./【技术方案】外部产品支付服务接入-2026-10-03.md) +- [External v1 OpenAPI](./openapi/genarrative-external-v1.openapi.json) +- [支付应用与订单收银台里程碑](./project-memory/plans/【里程碑】支付应用与订单收银台-2026-10-03.md) diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 1a743e4d6..64db5e8fe 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -59,7 +59,7 @@ npm run check:server-rs-ddd `spacetime-client` 的 Cargo `lib.path` 指向 `src/active.rs`,现役 mapper 聚合入口是 `src/active/mapper.rs`;原旧 facade 和 mapper 已删除。 -当前 mapper 按现役调用领域拆分为 `admin_account.rs`、`admin_dashboard.rs`、`ai.rs`、`assets.rs`、`auth.rs`、`editor_agent.rs`、`editor_project.rs`、`error_reports.rs`、`external_api_key.rs`、`external_generation.rs`、`game_distribution.rs`、`llm_router_account.rs`、`runtime.rs`、`runtime_profile.rs` 和 `story.rs`;每个模块只承接对应现役 procedure / result 类型的映射。跨领域轻量 helper 和共享 record 放在 `common.rs`;不得把领域聚合或跨层 JSON 兼容结构放入 mapper。 +当前 mapper 按现役调用领域拆分为 `admin_account.rs`、`admin_dashboard.rs`、`ai.rs`、`assets.rs`、`auth.rs`、`editor_agent.rs`、`editor_project.rs`、`error_reports.rs`、`external_api_key.rs`、`external_generation.rs`、`game_distribution.rs`、`llm_router_account.rs`、`payment.rs`、`runtime.rs`、`runtime_profile.rs` 和 `story.rs`;每个模块只承接对应现役 procedure / result 类型的映射。跨领域轻量 helper 和共享 record 放在 `common.rs`;不得把领域聚合或跨层 JSON 兼容结构放入 mapper。 ## API 路由分组 @@ -451,6 +451,30 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - 2026-09-01 修订:每次账号认证、Router Key 准备或 LLM 请求解析既有账号时,api-server 都会在同一 owner 级 provisioning 锁内检查固定套餐 `plan_id=1`。没有 active 订阅、订阅已过期或 `end_time` 距当前 Unix 秒不超过 24 小时时,使用管理员接口新建一条订阅;超过 24 小时则复用现有订阅。订阅检查不进入 Responses 流式 chunk,管理员 Token 缺失时仅保留启动告警并跳过续期检查。 - LLM Router 计费:读取 Router 用户 `used_quota`,以 `50000 quota = 1 泥点` 结算(`500000 quota = 1 USD`,美元数值直接乘 10,不乘汇率)。上游调用前建立首次基线并补结算,成功响应后同步;不足整数部分、扣费失败和余额不足未支付部分留到后续累计同步。扣钱包、写 `llm_router_consume` 流水与推进 checkpoint 在同一事务完成;流水展示“LLM 调用消耗”。历史费用不追扣。详细契约见 `technical/【技术方案】LLM累计额度结算-2026-09-05.md`。 - 2026-09-05 修订:`/api/llm/responses` 与 `/api/llm/chat/completions` 仍在 Router provisioning 前用钱包总余额阻止零余额账号创建或续期;解析凭据后、上游调用前再执行累计额度同步,以扣除退款占用后的剩余可消费余额为准。余额为 `0` 时返回 `409 MUD_POINTS_INSUFFICIENT`;余额或额度同步失败时失败关闭。上游已成功时,后置同步失败只记录错误并留待下次调用前补结算,不把成功模型响应改写为失败。 + +### `payment_app` + +- Rust 结构体:`PaymentApp`;源码:`server-rs/crates/spacetime-module/src/payment_storage.rs`。 +- 作用:平台账号创建的外部支付应用,保存应用归属、展示名称、回调地址、启用状态和更新时间。应用使用平台统一商户主体,不保存支付平台私钥。 +- 索引:`by_payment_app_owner_user_id` 用于个人中心应用列表。 + +### `payment_api_key` + +- Rust 结构体:`PaymentApiKey`;源码:`server-rs/crates/spacetime-module/src/payment_storage.rs`。 +- 作用:支付应用 API Key 元数据。明文只在创建支付应用响应中返回一次,表内仅保存摘要、可见前缀、作用域、使用时间和撤销状态。 +- 索引:`by_payment_api_key_app_id`、`by_payment_api_key_owner_user_id`;`key_hash` 唯一用于支付 API 鉴权。 + +### `payment_order` + +- Rust 结构体:`PaymentOrder`;源码:`server-rs/crates/spacetime-module/src/payment_storage.rs`。 +- 作用:客户端游戏和外部产品的统一收款订单。保存不可变商品 / 金额快照、应用归属、接入方订单号、幂等查找键、微信 Native `code_url`、平台交易号、支付状态、过期时间和到账时间。订单确认只能由验签通知或服务端查单进入 `paid`,不与 `profile_recharge_order` 共享入账路径。 +- 索引:`by_payment_order_app_id`、`by_payment_order_owner_user_id`、`by_payment_order_status`;`idempotency_lookup`、`merchant_order_lookup`、`checkout_token` 唯一用于幂等和公共收银台访问。 + +### `payment_webhook_delivery` + +- Rust 结构体:`PaymentWebhookDelivery`;源码:`server-rs/crates/spacetime-module/src/payment_storage.rs`。 +- 作用:支付成功后的外部回调 durable outbox。保存 callback URL、签名、固定 payload、租约、尝试次数、下次可投递时间、最近错误和 delivered / failed 终态;api-server worker 通过 claim / complete procedure 投递。 +- 索引:`by_payment_webhook_delivery_status_available`、`by_payment_webhook_delivery_owner_user_id`、`by_payment_webhook_delivery_order_id`;`delivery_id` 按订单唯一幂等。 - Windows 私有文件准备:AGC 自有 AppData、凭据目录和 `.agent` 运行态继续使用 managed 范围;用户通过原生选择器明确选中的项目根或文件,若 owner/DACL 仅因权限不足无法读取,则由一次性 UAC helper 在严格复核普通文件/目录、非 reparse/symlink、路径类型和目标 TokenUser 后接管并收紧为当前用户私有 DACL。项目放在当前 profile 之外(例如其他磁盘)不再因为路径位置被拒绝;未经过原生选择器或 AGC 项目根入口的内部路径仍不获得任意提权资格。 ### `game_distribution_game` diff --git a/docs/【技术方案】外部产品支付服务接入-2026-10-03.md b/docs/【技术方案】外部产品支付服务接入-2026-10-03.md new file mode 100644 index 000000000..00143f3bb --- /dev/null +++ b/docs/【技术方案】外部产品支付服务接入-2026-10-03.md @@ -0,0 +1,202 @@ +# 外部产品支付服务接入 + +更新时间:`2026-10-03` + +## 目标 + +为 Genarrative 提供一套由平台统一收款的支付服务,支持: + +- 客户端制作的游戏通过公开接口创建订单并打开 Web 收银台。 +- 外部产品通过应用身份、API Key 和签名调用同一套订单接口。 +- 平台使用已开通的微信支付 / 支付宝商户接口生成动态支付二维码,并以服务端验签、查单或支付通知确认到账。 +- 用户在自己的账户中查看支付服务,创建和配置接入应用,管理 API Key、回调地址和订单。 +- 后台查看支付应用、订单、支付通知、回调投递和异常状态。 +- 首期只负责收款、记账和结果通知;外部产品的余额、手续费、提现和自动分账不在首期范围内,人工结算依据后台账单处理。 + +## 非目标 + +- 不把外部产品订单记入现有的个人钱包充值订单,不向购买者自动发放泥点或会员权益。 +- 不允许客户端或外部产品直接声明“支付成功”改变订单状态。 +- 不保存或向前端下发商户私钥、API v3 密钥、支付宝应用私钥、支付平台 access token 等敏感凭据。 +- 不在首期实现平台余额、外部产品提现、手续费规则、自动分账、佣金和税务结算。 +- 不使用个人收款码、静态收款码或无法验签查单的人工转账作为支付服务正式渠道。 + +## 入口与边界 + +### 参与方 + +- 平台:持有并配置自己的微信支付 / 支付宝商户账户,维护支付服务、订单和通知。 +- 接入方:平台账号下创建的游戏或外部产品应用,使用平台发放的 API Key 创建订单。 +- 购买者:在公共 Web 收银台查看商品信息并完成扫码支付,可不登录平台账号。 +- 支付平台:微信支付 Native、支付宝当面付等已开通的商户 API,负责实际收款、通知和查单。 + +### 入口 + +- 账号侧:`/profile/payment`,展示支付服务状态、应用列表、API Key、回调配置和订单入口。 +- 公共收银台:`/pay/{checkoutToken}`,由服务端返回订单快照、可用支付方式和动态二维码。 +- 外部 API:`/api/external/v1/payment/orders` 及同一资源下的查单、关闭和收银台地址接口。 +- 支付平台通知:按渠道配置独立的 HTTPS 通知入口,统一进入支付订单确认流程。 +- 后台:增加支付服务概览、接入应用、支付订单、通知与回调投递页面,并受现有后台 Tab 权限控制。 + +### 职责边界 + +- `api-server`:HTTP 鉴权、公开 API、收银台 BFF、支付平台通知、回调投递编排和错误 envelope。 +- `module-*`:金额、订单状态、幂等、归属、回调重放和人工复核等领域规则。 +- `spacetime-module`:支付应用、订单、支付尝试、通知观察和投递 outbox 的持久化与原子事务。 +- `spacetime-client`:为 api-server 提供支付领域 procedure 的 typed facade。 +- `platform-*`:微信 / 支付宝的下单、二维码、验签、解密、查单和响应解析。 +- `shared-contracts` / `packages/shared`:账号页、后台页和外部 API 共用的 DTO,不保存业务真相。 +- 前端:展示后端返回的订单状态,收银台轮询或 SSE 只用于刷新,不自行判定到账。 + +## 接入应用与账户配置 + +### 应用 + +每个应用属于一个平台账号,至少包含: + +- `appId`、应用名称、产品类型、启用状态和创建 / 更新时间。 +- 允许使用的支付渠道和金额上下限。 +- 支付成功回调地址、回调签名密钥版本、回调重试策略。 +- 可选的返回地址;必须按 HTTPS、域名白名单和协议校验,不能接受任意用户提交的跳转地址。 +- 应用级订单前缀和展示品牌信息,不能覆盖平台商户主体。 + +### API Key + +- 创建时只展示一次完整 secret,数据库只保存 hash、可见前缀、scope、创建时间、最后使用时间、撤销时间和版本。 +- 首期 scope 至少区分 `payment:orders:create`、`payment:orders:read`、`payment:orders:close`。 +- Key 只属于一个应用,撤销后立即禁止创建订单、查单和关闭操作。 +- 请求使用 `Authorization: Bearer` 或约定的 API Key 头,并要求 `Idempotency-Key`。 +- 管理员页面可以查看安全元数据,不能查看完整 secret。 + +### 商户配置 + +- 商户号、应用 ID、证书序列号、通知地址和支付平台密钥只由服务端环境变量或受控密文配置提供。 +- 用户账户页面只展示已启用的支付渠道和配置状态,不允许用户上传或读取平台级商户私钥。 +- 后台只展示脱敏的商户配置和连通性状态;密钥写入日志、追踪事件和错误响应均禁止。 +- 每个渠道必须明确 `enabled / unavailable / misconfigured` 状态,未完成配置的渠道不出现在收银台可选项。 + +## 订单与金额契约 + +### 创建订单 + +接入方提交: + +- 应用 API Key、幂等键和接入方订单号。 +- 商品标题、商品明细、商品数量、币种和金额,金额使用整数分。 +- 接入方用户标识和可选的业务 metadata;metadata 有字节上限,禁止放入凭据、Data URL 或大段正文。 +- 支付成功后的业务回调要求由应用配置提供,不能在每次请求中绕过已审核的地址白名单。 + +服务端执行: + +1. 校验应用、API Key、scope、幂等键、金额范围、币种和商品字段。 +2. 以 `appId + idempotencyKey` 建立唯一幂等记录;相同请求重放返回原订单,相同幂等键但字段不一致则失败。 +3. 生成平台订单号、不可猜测的 `checkoutToken` 和过期时间,保存不可变商品与金额快照。 +4. 调用选定的支付平台创建订单,要求平台返回的商户订单号、金额和渠道与本地快照一致。 +5. 返回平台无关的订单摘要、收银台 URL、过期时间和可用渠道,不返回商户请求签名或支付平台敏感字段。 + +### 状态 + +订单状态为: + +`pending → paying → paid` + +并允许从 `pending / paying` 进入 `expired / closed`,从 `paid` 进入 `refunded`(退款能力在后续里程碑接入)。支付通知、查单和人工复核只能按允许的状态转移推进;重复通知、乱序通知和重复查单必须幂等。 + +本地 `paid` 必须同时满足: + +- 平台通知验签或主动查单成功。 +- 商户订单号、平台交易号、应用、渠道、金额和币种与本地订单一致。 +- 支付平台确认时间合法且来自平台响应,不能使用服务器当前时间补齐。 + +## Web 收银台 + +收银台使用服务端生成的 `checkoutToken`,页面展示: + +- 商品名称、商品明细、订单号、应付金额和过期时间。 +- 可用支付渠道,例如“微信支付 / 支付宝”,渠道由商户配置和订单约束共同决定。 +- 当前渠道的动态二维码。二维码由平台商户 API 以当前订单金额和平台订单号生成,不复用静态码。 +- “待支付、支付确认中、支付成功、已关闭、已过期、支付异常”等后端状态。 +- 查询状态、复制金额、返回应用等操作;返回地址只使用应用白名单中的地址。 + +收银台不展示平台密钥、用户 API Key、完整回调地址和内部错误文本。浏览器轮询或 SSE 只能读取公开订单状态;出现“支付成功”页面前仍需经过平台回调或服务端查单确认。 + +页面视觉采用截图中的双栏收银台结构作为参考:左侧是订单信息和金额提示,右侧是二维码和渠道状态;移动端改为纵向布局。截图只作为布局参考,不作为支付协议、金额或商户身份依据。 + +## 通知、查单与外部回调 + +- 平台支付通知入口按渠道独立配置,先验签 / 解密,再解析订单和金额,再进入统一的订单确认事务。 +- 通知成功后快速返回平台要求的成功响应;外部产品回调通过 outbox 异步投递,不能让第三方回调慢速阻塞支付平台通知。 +- 外部回调 payload 至少包含平台订单号、接入方订单号、应用 ID、支付状态、金额、币种、平台交易号和确认时间。 +- 首期回调签名使用 `HMAC-SHA256(sha256(rawApiKey), rawBody)`,HTTP 头为 `X-Genarrative-Signature: sha256=`、`X-Genarrative-Event` 和 `X-Genarrative-Delivery-Id`;接入方验签后按订单号和 delivery id 幂等消费。独立可轮换 callback secret 作为后续安全增强项,不在本里程碑伪称已实现。 +- 投递按有限次数退避重试,记录 HTTP 状态、响应摘要、下一次重试时间和最终失败原因;不得记录完整 secret、签名原文或敏感请求体。 +- 查询订单只返回应用归属范围内的订单;公共收银台只返回公开展示字段和状态。 +- 支付确认与唯一回调 outbox 在同一数据库事务提交,回调 payload 从已确认订单构建;失败不会留下 paid 但缺 outbox 的半完成状态。每个投递使用唯一租约代次,过期 worker 不能覆盖新 worker 结果。 +- 外部下单使用不超过 32 字符的平台订单号和冻结的过期时间,独立通知 URL 为 `/api/payment/wechat/notify`;下单 claim 在库内完成,结果不确定时先查单,同一商户单号不因重试更换。 +- 应用、订单、回调 procedure 都要求已注册后端运行服务身份,匿名 SpacetimeDB 调用不得创建或确认订单。回调地址要求 HTTPS、禁止凭据和内网地址,发送时再次检查解析地址且禁止跳转;仅隔离测试环境可以允许 loopback fixture。 +- 支付平台通知、主动查单和后台人工重试全部复用同一条幂等确认路径,不各自实现入账。 + +## 后台数据页 + +后台新增支付服务入口,至少包含: + +- 总览:订单数、已支付金额、待确认订单、通知失败、外部回调失败和按渠道统计。 +- 接入应用:应用状态、所属账号、渠道、回调地址脱敏、Key 状态和最近使用时间。 +- 支付订单:应用、商品、接入方订单号、平台订单号、金额、渠道、状态、创建 / 支付时间和平台交易号。 +- 通知与投递:验签结果、通知类型、订单关联、重复次数、投递状态和下次重试时间。 +- 详情与人工处理:展示本地快照和平台事实的对照;人工操作必须记录管理员、原因、时间和期望状态,不能直接改订单金额或伪造支付成功。 + +分页列表必须使用后端分页和排序参数;金额使用整数分并在展示层格式化,时间统一转成人可读时间,不能把 SATS enum、Option 或微秒 Timestamp 原样展示。 + +## 安全、幂等与失败关闭 + +- 所有金额和商品快照以服务端持久化值为准,客户端传回的金额只作为展示输入,不能作为支付成功依据。 +- 订单创建、支付平台下单、通知确认和外部回调投递分别设置幂等键,重放不会重复创建订单、记账或发放商品。 +- 支付平台超时、网络断开、响应解析失败或结果不确定时,订单保持待确认并进入查单 / 人工复核,不自动标记失败或再次创建新订单。 +- 未知通知、金额不一致、订单归属不一致、签名失败和平台交易号冲突必须拒绝确认,并保留脱敏诊断事实。 +- 日志只记录稳定 HMAC 引用、状态、金额、渠道、阶段和错误分类,不记录二维码原文、密钥、签名、完整订单请求或敏感 metadata。 +- 所有用户、应用、订单、回调和后台查询都检查归属和权限;外部 API 不允许通过猜测订单号访问其它应用数据。 + +## 契约与迁移 + +### API / DTO / OpenAPI + +- 新增支付服务公开 API 时同步更新 `docs/openapi/genarrative-external-v1.openapi.json`、`shared-contracts`、契约测试和公开发现文档。 +- 内部账号接口放在 `/api/profile/payment/*`,后台接口放在 `/admin/api/payment/*`,支付平台通知不走用户鉴权。 +- 公开 DTO 不暴露 provider 原始响应、密钥、完整二维码 payload 或内部数据库 ID。 + +### SpacetimeDB + +计划增加独立的支付领域表,具体表结构在里程碑实施前冻结: + +- 支付接入应用和 API Key 元数据。 +- 平台支付订单和不可变商品快照。 +- 渠道支付尝试与平台交易号。 +- 支付通知观察记录。 +- `payment_webhook_delivery` 外部回调投递 outbox 与投递结果。 + +新增字段必须追加到已有 Rust 表结构体末尾并设置明确默认值;不得重排、改名或删除现有充值表字段。修改 schema 时同步 migration、表目录、生成绑定并运行 `npm run check:spacetime-schema`。 + +### 兼容策略 + +现有个人钱包充值、微信虚拟支付和充值退款链路保持原契约。支付服务用独立订单前缀、表和 API namespace 标识,不把历史充值订单迁移成外部支付订单。 + +## 首期验收标准 + +| 条款 | 验收方式 | 证据 | +| --- | --- | --- | +| 账号可创建支付应用并管理 API Key | 浏览器 smoke、接口测试 | 个人中心页面、Key 只展示一次、撤销立即生效 | +| 客户端游戏和外部产品可创建订单 | API 契约测试 | 同一幂等键返回原订单,字段冲突被拒绝 | +| 收银台展示不可篡改的商品和金额 | 浏览器 smoke | `checkoutToken` 只能读取公开字段,金额来自服务端快照 | +| 商户账户生成动态二维码 | provider mock + 真实沙箱 smoke | 二维码订单号和金额与本地订单一致 | +| 支付结果以服务端事实确认 | 通知 / 查单测试 | 客户端伪造成功不会改变订单,通知重放不重复确认 | +| 外部产品收到成功通知 | outbox 测试与回调 smoke | 签名、重试、最终失败和幂等投递可观测 | +| 后台可查看运营数据 | 后台页面测试 | 应用、订单、通知和回调列表分页、筛选、详情可用 | +| 权限与敏感数据安全 | 负向测试、日志检查 | 跨应用访问、失效 Key、错误金额、错误签名均失败且无密钥泄漏 | +| 首期不产生自动分账 | 领域测试与文档核对 | 没有余额、提现或自动分账写路径 | + +## 未决问题与决策 + +- 首期接入渠道:优先复用现有微信 Native 动态二维码链路;支付宝作为同一收银台的第二个 provider,只有商户 API 配置和验签协议完成后才显示。 +- 首期结算:平台收款、记账、通知,外部产品人工结算;手续费、余额和提现另立规范。 +- 商户主体:使用平台自己的商户号;接入应用不能选择或替换商户主体。 +- 退款:首期只展示支付事实和人工处理入口,正式退款与外部产品权益回收另立里程碑。 diff --git a/packages/shared/src/contracts/payment.ts b/packages/shared/src/contracts/payment.ts new file mode 100644 index 000000000..4683dd077 --- /dev/null +++ b/packages/shared/src/contracts/payment.ts @@ -0,0 +1,55 @@ +export type PaymentApp = { + appId: string; + name: string; + callbackUrl: string | null; + enabled: boolean; + createdAt: string; + updatedAt: string; +}; + +export type PaymentApiKey = { + keyId: string; + appId: string; + name: string; + keyPrefix: string; + scopes: string[]; + createdAt: string; + lastUsedAt: string | null; + revokedAt: string | null; + updatedAt: string; +}; + +export type PaymentAppCreateResponse = { + app: PaymentApp; + apiKey: string; + key: PaymentApiKey; +}; + +export type PaymentAppListResponse = { + apps: PaymentApp[]; + keys: PaymentApiKey[]; +}; + +export type PaymentOrder = { + orderId: string; + appId: string; + merchantOrderId: string; + title: string; + items: unknown[]; + amountCents: number; + currency: string; + provider: string; + providerTradeNo: string | null; + checkoutToken: string; + status: 'pending' | 'paying' | 'paid' | 'expired' | 'closed' | 'refunded'; + checkoutUrl: string; + providerQrCode: string | null; + createdAt: string; + expiresAt: string; + paidAt: string | null; + updatedAt: string; +}; + +export type PaymentOrderCreateResponse = { + order: PaymentOrder; +}; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 87fb5dc00..568bb3382 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -7,6 +7,7 @@ export * from './contracts/editorScene'; export * from './contracts/externalGeneration'; export * from './contracts/gameDistribution'; export type * from './contracts/hyper3d'; +export * from './contracts/payment'; export * from './contracts/runtime'; export * from './http'; export * from './llm/narrativeLanguage'; diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index eb4b766ac..33140ee88 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -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 Result, 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; diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 659686d1f..390031ef9 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -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), diff --git a/server-rs/crates/api-server/src/external_api_auth.rs b/server-rs/crates/api-server/src/external_api_auth.rs index 307203bb1..54f349b53 100644 --- a/server-rs/crates/api-server/src/external_api_auth.rs +++ b/server-rs/crates/api-server/src/external_api_auth.rs @@ -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, + payment_app_id: Option, + callback_url: Option, + 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, + mut request: Request, + next: Next, +) -> Result { + let request_id = request + .extensions() + .get::() + .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::>(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()); diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 8515721f5..477b7df9c 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -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()); diff --git a/server-rs/crates/api-server/src/modules/admin.rs b/server-rs/crates/api-server/src/modules/admin.rs index e16b4a82b..8e40ebb3c 100644 --- a/server-rs/crates/api-server/src/modules/admin.rs +++ b/server-rs/crates/api-server/src/modules/admin.rs @@ -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 { ), ("/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", diff --git a/server-rs/crates/api-server/src/modules/external_api.rs b/server-rs/crates/api-server/src/modules/external_api.rs index 415a20777..f88171505 100644 --- a/server-rs/crates/api-server/src/modules/external_api.rs +++ b/server-rs/crates/api-server/src/modules/external_api.rs @@ -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 { 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 { .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 { get(download_external_skill_archive), ) .merge(protected_router) + .merge(payment_router) .merge(mcp_router) } diff --git a/server-rs/crates/api-server/src/modules/profile.rs b/server-rs/crates/api-server/src/modules/profile.rs index de6768102..e558fe7a1 100644 --- a/server-rs/crates/api-server/src/modules/profile.rs +++ b/server-rs/crates/api-server/src/modules/profile.rs @@ -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 { 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( diff --git a/server-rs/crates/api-server/src/payment.rs b/server-rs/crates/api-server/src/payment.rs new file mode 100644 index 000000000..666edb588 --- /dev/null +++ b/server-rs/crates/api-server/src/payment.rs @@ -0,0 +1,556 @@ +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, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, 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, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Path(app_id): Path, + Json(payload): Json, +) -> Result, 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, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Path(key_id): Path, +) -> Result, 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, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(payload): Json, +) -> Result, 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, + Extension(request_context): Extension, + Extension(principal): Extension, + headers: HeaderMap, + Json(payload): Json, +) -> Result, 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, + Extension(request_context): Extension, + Extension(principal): Extension, + Path(order_id): Path, +) -> Result, 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, + Extension(request_context): Extension, + Path(checkout_token): Path, +) -> Result, 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, + headers: HeaderMap, + body: Bytes, +) -> Result { + 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::::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::>(&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 { + 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() })), + } +} diff --git a/server-rs/crates/api-server/src/payment_admin.rs b/server-rs/crates/api-server/src/payment_admin.rs new file mode 100644 index 000000000..3a08bc206 --- /dev/null +++ b/server-rs/crates/api-server/src/payment_admin.rs @@ -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, + pub status: String, + pub created_at: String, + pub expires_at: String, + pub paid_at: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminPaymentOrderListResponse { + pub entries: Vec, + pub webhooks: Vec, +} + +#[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, + pub updated_at: String, +} + +pub async fn admin_list_payment_orders( + State(state): State, + Extension(_admin): Extension, + Extension(_request_context): Extension, +) -> Result, 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 { + 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 { + 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 { + 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 { + 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 { + cell_text(value)?.parse().ok() +} + +fn cell_timestamp(value: &Value) -> String { + cell_text(value) + .and_then(|text| text.parse::().ok()) + .map(module_runtime::format_utc_micros) + .unwrap_or_else(|| "-".to_string()) +} + +fn cell_option_timestamp(value: &Value) -> Option { + if matches!(value, Value::Array(values) if values.first().and_then(Value::as_u64) == Some(1)) { + return None; + } + Some(cell_timestamp(value)) +} diff --git a/server-rs/crates/api-server/src/payment_webhook.rs b/server-rs/crates/api-server/src/payment_webhook.rs new file mode 100644 index 000000000..ae6ba9692 --- /dev/null +++ b/server-rs/crates/api-server/src/payment_webhook.rs @@ -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 { + 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) { + 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, + "支付外部回调处理完成" + ); + } + } +} diff --git a/server-rs/crates/api-server/src/state/external_api_auth.rs b/server-rs/crates/api-server/src/state/external_api_auth.rs index 2e7f7cb85..93b6d14ac 100644 --- a/server-rs/crates/api-server/src/state/external_api_auth.rs +++ b/server-rs/crates/api-server/src/state/external_api_auth.rs @@ -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>; + + 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> { 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)] diff --git a/server-rs/crates/api-server/src/wechat/pay.rs b/server-rs/crates/api-server/src/wechat/pay.rs index 3cc839884..85ebf9583 100644 --- a/server-rs/crates/api-server/src/wechat/pay.rs +++ b/server-rs/crates/api-server/src/wechat/pay.rs @@ -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), } } diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index c6698f7a6..fd5f7252c 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -2,6 +2,8 @@ pub mod agc_analytics; mod agc_models; mod llm_billing; pub use llm_billing::*; +mod payment; +pub use payment::*; mod application; pub use agc_models::*; mod commands; diff --git a/server-rs/crates/module-runtime/src/payment.rs b/server-rs/crates/module-runtime/src/payment.rs new file mode 100644 index 000000000..2a08a934a --- /dev/null +++ b/server-rs/crates/module-runtime/src/payment.rs @@ -0,0 +1,532 @@ +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, + 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, + pub revoked_at_micros: Option, + 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, + pub provider_code_url: Option, + pub callback_url: Option, + pub callback_signing_key_hash: Option, + pub provider_attempted_at_micros: Option, + pub checkout_token: String, + pub status: PaymentOrderStatus, + pub created_at_micros: i64, + pub expires_at_micros: i64, + pub paid_at_micros: Option, + 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, + 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, + pub callback_signing_key_hash: Option, +} + +#[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, +} + +#[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, + 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, + pub lease_expires_at_micros: Option, + pub last_error_message: Option, + 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, + 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, + pub deliveries: Vec, + pub error_message: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaymentAppProcedureResult { + pub ok: bool, + pub app: Option, + pub key: Option, + pub apps: Vec, + pub keys: Vec, + pub error_message: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaymentOrderProcedureResult { + pub ok: bool, + pub order: Option, + pub orders: Vec, + pub error_message: Option, +} + +pub fn build_payment_app_create_input( + app_id: String, + owner_user_id: String, + name: String, + callback_url: Option, + now_micros: i64, +) -> Result { + 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::() + .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 { + 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, + callback_signing_key_hash: Option, +) -> Result { + 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 { + 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 { + 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 { + 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() + ); + } +} diff --git a/server-rs/crates/platform-wechat/src/pay.rs b/server-rs/crates/platform-wechat/src/pay.rs index d3d862883..075c163ce 100644 --- a/server-rs/crates/platform-wechat/src/pay.rs +++ b/server-rs/crates/platform-wechat/src/pay.rs @@ -192,6 +192,7 @@ pub struct WechatWebOrderRequest { pub description: String, pub amount_cents: u64, pub payer_client_ip: String, + pub notify_url: Option, } #[derive(Clone, Debug)] @@ -985,7 +986,7 @@ impl RealWechatPayClient { description: &request.description, out_trade_no: &request.order_id, time_expire: &expires_at_text, - notify_url: &self.notify_url, + notify_url: request.notify_url.as_deref().unwrap_or(&self.notify_url), amount: WechatJsapiAmount { total: amount_total, currency: "CNY", @@ -1040,7 +1041,7 @@ impl RealWechatPayClient { description: &request.description, out_trade_no: &request.order_id, time_expire: &expires_at_text, - notify_url: &self.notify_url, + notify_url: request.notify_url.as_deref().unwrap_or(&self.notify_url), amount: WechatJsapiAmount { total: amount_total, currency: "CNY", diff --git a/server-rs/crates/shared-contracts/src/lib.rs b/server-rs/crates/shared-contracts/src/lib.rs index 07fbbdf11..ed92100fb 100644 --- a/server-rs/crates/shared-contracts/src/lib.rs +++ b/server-rs/crates/shared-contracts/src/lib.rs @@ -23,4 +23,5 @@ pub mod game_creation_app; pub mod game_distribution; pub mod hyper3d; pub mod llm; +pub mod payment; pub mod runtime; diff --git a/server-rs/crates/shared-contracts/src/payment.rs b/server-rs/crates/shared-contracts/src/payment.rs new file mode 100644 index 000000000..866a4d940 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/payment.rs @@ -0,0 +1,106 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PaymentAppCreateRequest { + pub name: String, + #[serde(default)] + pub callback_url: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PaymentAppResponse { + pub app_id: String, + pub name: String, + pub callback_url: Option, + pub enabled: bool, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PaymentApiKeyResponse { + pub key_id: String, + pub app_id: String, + pub name: String, + pub key_prefix: String, + pub scopes: Vec, + pub created_at: String, + pub last_used_at: Option, + pub revoked_at: Option, + pub updated_at: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PaymentAppCreateResponse { + pub app: PaymentAppResponse, + pub api_key: String, + pub key: PaymentApiKeyResponse, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PaymentAppListResponse { + pub apps: Vec, + pub keys: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PaymentOrderCreateRequest { + pub merchant_order_id: String, + pub title: String, + #[serde(default)] + pub items: Vec, + pub amount_cents: u64, + #[serde(default = "default_payment_currency")] + pub currency: String, + #[serde(default = "default_payment_provider")] + pub provider: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PaymentOrderResponse { + pub order_id: String, + pub app_id: String, + pub merchant_order_id: String, + pub title: String, + pub items: Vec, + pub amount_cents: u64, + pub currency: String, + pub provider: String, + pub provider_trade_no: Option, + pub checkout_token: String, + pub status: String, + pub checkout_url: String, + pub provider_qr_code: Option, + pub created_at: String, + pub expires_at: String, + pub paid_at: Option, + pub updated_at: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PaymentOrderCreateResponse { + pub order: PaymentOrderResponse, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PaymentOrderListResponse { + pub orders: Vec, +} + +pub fn default_payment_currency() -> String { + "CNY".to_string() +} + +pub fn default_payment_provider() -> String { + "wechat_native".to_string() +} diff --git a/server-rs/crates/spacetime-client/src/active.rs b/server-rs/crates/spacetime-client/src/active.rs index b0ee057ca..7a1651ae4 100644 --- a/server-rs/crates/spacetime-client/src/active.rs +++ b/server-rs/crates/spacetime-client/src/active.rs @@ -35,6 +35,8 @@ pub use game_distribution::{ #[path = "active/external_generation.rs"] pub mod external_generation; pub mod llm_router_account; +#[path = "active/payment.rs"] +pub mod payment; pub use external_generation::{ ExternalGenerationJobPhaseUpdateError, ExternalGenerationQueueWakeSubscription, }; diff --git a/server-rs/crates/spacetime-client/src/active/mapper.rs b/server-rs/crates/spacetime-client/src/active/mapper.rs index a12c42531..07b1a9cf8 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper.rs @@ -24,6 +24,8 @@ mod external_generation; mod game_distribution; #[path = "mapper/llm_router_account.rs"] pub(crate) mod llm_router_account; +#[path = "mapper/payment.rs"] +mod payment; #[path = "mapper/runtime.rs"] mod runtime; #[path = "mapper/runtime_profile.rs"] @@ -103,6 +105,15 @@ pub use self::game_distribution::{ GameDistributionVersionRecord, }; pub use self::llm_router_account::{LlmRouterAccountRecord, LlmRouterAccountUpsertRecordInput}; +pub use self::payment::{ + PaymentApiKeyAuthenticateRecordInput, PaymentApiKeyCreateRecordInput, PaymentApiKeyRecord, + PaymentApiKeyRevokeRecordInput, PaymentAppCreateRecordInput, PaymentAppRecord, + PaymentAppUpdateRecordInput, PaymentOrderCreateRecordInput, PaymentOrderGetRecordInput, + PaymentOrderListRecordInput, PaymentOrderMarkPaidRecordInput, + PaymentOrderProviderAttemptRecordInput, PaymentOrderProviderCodeRecordInput, + PaymentOrderRecord, PaymentWebhookClaimRecordInput, PaymentWebhookCompleteRecordInput, + PaymentWebhookDeliveryRecord, PaymentWebhookEnqueueRecordInput, +}; pub use self::runtime::FeatureGateConfigRecord; pub(crate) use self::admin_account::{ @@ -161,6 +172,12 @@ pub(crate) use self::game_distribution::{ map_game_distribution_public_game_result, map_game_distribution_review_list_result, map_game_distribution_version_result, map_review_moderation_operation, map_user_review, }; +pub(crate) use self::payment::{ + map_payment_api_key_list_result, map_payment_api_key_result, map_payment_app_list_result, + map_payment_app_result, map_payment_authenticate_result, map_payment_order_list_result, + map_payment_order_result, map_payment_webhook_claim_result, + map_payment_webhook_complete_result, map_payment_webhook_enqueue_result, +}; pub(crate) use self::runtime::{ map_feature_gate_config_procedure_result, map_runtime_setting_procedure_result, map_runtime_tracking_event_batch_procedure_result, map_runtime_tracking_event_procedure_result, diff --git a/server-rs/crates/spacetime-client/src/active/mapper/payment.rs b/server-rs/crates/spacetime-client/src/active/mapper/payment.rs new file mode 100644 index 000000000..43c482d36 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/active/mapper/payment.rs @@ -0,0 +1,516 @@ +use super::*; + +pub type PaymentAppRecord = module_runtime::PaymentAppSnapshot; +pub type PaymentApiKeyRecord = module_runtime::PaymentApiKeySnapshot; +pub type PaymentOrderRecord = module_runtime::PaymentOrderSnapshot; +pub type PaymentWebhookDeliveryRecord = module_runtime::PaymentWebhookDeliverySnapshot; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PaymentAppCreateRecordInput { + pub app_id: String, + pub owner_user_id: String, + pub name: String, + pub callback_url: Option, + pub now_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PaymentApiKeyCreateRecordInput { + 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: Vec, + pub now_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PaymentAppUpdateRecordInput { + pub app_id: String, + pub owner_user_id: String, + pub enabled: bool, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PaymentApiKeyRevokeRecordInput { + pub key_id: String, + pub owner_user_id: String, + pub revoked_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PaymentApiKeyAuthenticateRecordInput { + pub key_hash: String, + pub used_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PaymentOrderCreateRecordInput { + 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: module_runtime::PaymentProvider, + pub checkout_token: String, + pub now_micros: i64, + pub expires_at_micros: i64, + pub callback_url: Option, + pub callback_signing_key_hash: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PaymentOrderGetRecordInput { + pub order_id: String, + pub owner_user_id: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PaymentOrderListRecordInput { + pub app_id: Option, + pub owner_user_id: String, + pub limit: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PaymentOrderMarkPaidRecordInput { + pub order_id: String, + pub provider_trade_no: String, + pub amount_cents: u64, + pub paid_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PaymentOrderProviderCodeRecordInput { + pub order_id: String, + pub provider_code_url: String, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PaymentOrderProviderAttemptRecordInput { + pub order_id: String, + pub attempted_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PaymentWebhookEnqueueRecordInput { + pub order_id: String, + pub payload_json: String, + pub signature: String, + pub now_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PaymentWebhookClaimRecordInput { + pub worker_id: String, + pub limit: u32, + pub now_micros: i64, + pub lease_expires_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PaymentWebhookCompleteRecordInput { + pub delivery_id: String, + pub worker_id: String, + pub success: bool, + pub error_message: Option, + pub now_micros: i64, + pub next_available_at_micros: i64, + pub attempt_count: u32, +} + +impl From for PaymentAppCreateInput { + fn from(input: PaymentAppCreateRecordInput) -> Self { + Self { + app_id: input.app_id, + owner_user_id: input.owner_user_id, + name: input.name, + callback_url: input.callback_url, + now_micros: input.now_micros, + } + } +} + +impl From for PaymentApiKeyCreateInput { + fn from(input: PaymentApiKeyCreateRecordInput) -> Self { + Self { + key_id: input.key_id, + app_id: input.app_id, + owner_user_id: input.owner_user_id, + name: input.name, + key_prefix: input.key_prefix, + key_hash: input.key_hash, + scopes_json: serde_json::to_string(&input.scopes).unwrap_or_else(|_| "[]".to_string()), + now_micros: input.now_micros, + } + } +} + +impl From for PaymentAppUpdateInput { + fn from(input: PaymentAppUpdateRecordInput) -> Self { + Self { + app_id: input.app_id, + owner_user_id: input.owner_user_id, + enabled: input.enabled, + updated_at_micros: input.updated_at_micros, + } + } +} + +impl From for PaymentApiKeyRevokeInput { + fn from(input: PaymentApiKeyRevokeRecordInput) -> Self { + Self { + key_id: input.key_id, + owner_user_id: input.owner_user_id, + revoked_at_micros: input.revoked_at_micros, + } + } +} + +impl From for PaymentApiKeyAuthenticateInput { + fn from(input: PaymentApiKeyAuthenticateRecordInput) -> Self { + Self { + key_hash: input.key_hash, + used_at_micros: input.used_at_micros, + } + } +} + +impl From for PaymentOrderCreateInput { + fn from(input: PaymentOrderCreateRecordInput) -> Self { + Self { + order_id: input.order_id, + app_id: input.app_id, + owner_user_id: input.owner_user_id, + merchant_order_id: input.merchant_order_id, + idempotency_lookup: input.idempotency_lookup, + title: input.title, + items_json: input.items_json, + amount_cents: input.amount_cents, + currency: input.currency, + provider: map_payment_provider(input.provider), + checkout_token: input.checkout_token, + now_micros: input.now_micros, + expires_at_micros: input.expires_at_micros, + callback_url: input.callback_url, + callback_signing_key_hash: input.callback_signing_key_hash, + } + } +} + +impl From for PaymentOrderGetInput { + fn from(input: PaymentOrderGetRecordInput) -> Self { + Self { + order_id: input.order_id, + owner_user_id: input.owner_user_id, + } + } +} + +impl From for PaymentOrderListInput { + fn from(input: PaymentOrderListRecordInput) -> Self { + Self { + app_id: input.app_id, + owner_user_id: input.owner_user_id, + limit: input.limit, + } + } +} + +impl From for PaymentOrderMarkPaidInput { + fn from(input: PaymentOrderMarkPaidRecordInput) -> Self { + Self { + order_id: input.order_id, + provider_trade_no: input.provider_trade_no, + amount_cents: input.amount_cents, + paid_at_micros: input.paid_at_micros, + } + } +} + +impl From for PaymentOrderProviderCodeInput { + fn from(input: PaymentOrderProviderCodeRecordInput) -> Self { + Self { + order_id: input.order_id, + provider_code_url: input.provider_code_url, + updated_at_micros: input.updated_at_micros, + } + } +} + +impl From for PaymentOrderProviderAttemptInput { + fn from(input: PaymentOrderProviderAttemptRecordInput) -> Self { + Self { + order_id: input.order_id, + attempted_at_micros: input.attempted_at_micros, + } + } +} + +impl From for PaymentWebhookEnqueueInput { + fn from(input: PaymentWebhookEnqueueRecordInput) -> Self { + Self { + order_id: input.order_id, + payload_json: input.payload_json, + signature: input.signature, + now_micros: input.now_micros, + } + } +} + +impl From for PaymentWebhookClaimInput { + fn from(input: PaymentWebhookClaimRecordInput) -> Self { + Self { + worker_id: input.worker_id, + limit: input.limit, + now_micros: input.now_micros, + lease_expires_at_micros: input.lease_expires_at_micros, + } + } +} + +impl From for PaymentWebhookCompleteInput { + fn from(input: PaymentWebhookCompleteRecordInput) -> Self { + Self { + delivery_id: input.delivery_id, + worker_id: input.worker_id, + success: input.success, + error_message: input.error_message, + now_micros: input.now_micros, + next_available_at_micros: input.next_available_at_micros, + attempt_count: input.attempt_count, + } + } +} + +pub(crate) fn map_payment_app_result( + result: PaymentAppProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + result + .app + .map(map_payment_app_snapshot) + .ok_or_else(|| SpacetimeClientError::missing_snapshot("支付应用快照")) +} + +pub(crate) fn map_payment_app_list_result( + result: PaymentAppProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + Ok(result + .apps + .into_iter() + .map(map_payment_app_snapshot) + .collect()) +} + +pub(crate) fn map_payment_api_key_result( + result: PaymentAppProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + result + .key + .map(map_payment_api_key_snapshot) + .ok_or_else(|| SpacetimeClientError::missing_snapshot("支付 API Key 快照")) +} + +pub(crate) fn map_payment_api_key_list_result( + result: PaymentAppProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + Ok(result + .keys + .into_iter() + .map(map_payment_api_key_snapshot) + .collect()) +} + +pub(crate) fn map_payment_authenticate_result( + result: PaymentAppProcedureResult, +) -> Result<(PaymentAppRecord, PaymentApiKeyRecord), SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + let app = result + .app + .map(map_payment_app_snapshot) + .ok_or_else(|| SpacetimeClientError::missing_snapshot("支付应用快照"))?; + let key = result + .key + .map(map_payment_api_key_snapshot) + .ok_or_else(|| SpacetimeClientError::missing_snapshot("支付 API Key 快照"))?; + Ok((app, key)) +} + +pub(crate) fn map_payment_order_result( + result: PaymentOrderProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + result + .order + .map(map_payment_order_snapshot) + .ok_or_else(|| SpacetimeClientError::missing_snapshot("支付订单快照")) +} + +pub(crate) fn map_payment_order_list_result( + result: PaymentOrderProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + Ok(result + .orders + .into_iter() + .map(map_payment_order_snapshot) + .collect()) +} + +fn map_payment_app_snapshot(snapshot: PaymentAppSnapshot) -> PaymentAppRecord { + module_runtime::PaymentAppSnapshot { + app_id: snapshot.app_id, + owner_user_id: snapshot.owner_user_id, + name: snapshot.name, + callback_url: snapshot.callback_url, + enabled: snapshot.enabled, + created_at_micros: snapshot.created_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + +fn map_payment_api_key_snapshot(snapshot: PaymentApiKeySnapshot) -> PaymentApiKeyRecord { + module_runtime::PaymentApiKeySnapshot { + key_id: snapshot.key_id, + app_id: snapshot.app_id, + owner_user_id: snapshot.owner_user_id, + name: snapshot.name, + key_prefix: snapshot.key_prefix, + scopes_json: snapshot.scopes_json, + created_at_micros: snapshot.created_at_micros, + last_used_at_micros: snapshot.last_used_at_micros, + revoked_at_micros: snapshot.revoked_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + +fn map_payment_order_snapshot(snapshot: PaymentOrderSnapshot) -> PaymentOrderRecord { + module_runtime::PaymentOrderSnapshot { + order_id: snapshot.order_id, + app_id: snapshot.app_id, + owner_user_id: snapshot.owner_user_id, + merchant_order_id: snapshot.merchant_order_id, + idempotency_lookup: snapshot.idempotency_lookup, + title: snapshot.title, + items_json: snapshot.items_json, + amount_cents: snapshot.amount_cents, + currency: snapshot.currency, + provider: map_payment_provider_back(snapshot.provider), + provider_trade_no: snapshot.provider_trade_no, + provider_code_url: snapshot.provider_code_url, + callback_url: snapshot.callback_url, + callback_signing_key_hash: snapshot.callback_signing_key_hash, + provider_attempted_at_micros: snapshot.provider_attempted_at_micros, + checkout_token: snapshot.checkout_token, + status: map_payment_order_status_back(snapshot.status), + created_at_micros: snapshot.created_at_micros, + expires_at_micros: snapshot.expires_at_micros, + paid_at_micros: snapshot.paid_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + +fn map_payment_provider(value: module_runtime::PaymentProvider) -> PaymentProvider { + match value { + module_runtime::PaymentProvider::WechatNative => PaymentProvider::WechatNative, + } +} + +fn map_payment_provider_back(value: PaymentProvider) -> module_runtime::PaymentProvider { + match value { + PaymentProvider::WechatNative => module_runtime::PaymentProvider::WechatNative, + } +} + +fn map_payment_order_status_back(value: PaymentOrderStatus) -> module_runtime::PaymentOrderStatus { + match value { + PaymentOrderStatus::Pending => module_runtime::PaymentOrderStatus::Pending, + PaymentOrderStatus::Paying => module_runtime::PaymentOrderStatus::Paying, + PaymentOrderStatus::Paid => module_runtime::PaymentOrderStatus::Paid, + PaymentOrderStatus::Expired => module_runtime::PaymentOrderStatus::Expired, + PaymentOrderStatus::Closed => module_runtime::PaymentOrderStatus::Closed, + PaymentOrderStatus::Refunded => module_runtime::PaymentOrderStatus::Refunded, + } +} + +pub(crate) fn map_payment_webhook_enqueue_result( + result: PaymentWebhookProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + Ok(result.delivery.map(map_payment_webhook_snapshot)) +} + +pub(crate) fn map_payment_webhook_claim_result( + result: PaymentWebhookProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + Ok(result + .deliveries + .into_iter() + .map(map_payment_webhook_snapshot) + .collect()) +} + +pub(crate) fn map_payment_webhook_complete_result( + result: PaymentWebhookProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + result + .delivery + .map(map_payment_webhook_snapshot) + .ok_or_else(|| SpacetimeClientError::missing_snapshot("支付回调投递快照")) +} + +fn map_payment_webhook_snapshot( + snapshot: PaymentWebhookDeliverySnapshot, +) -> PaymentWebhookDeliveryRecord { + module_runtime::PaymentWebhookDeliverySnapshot { + delivery_id: snapshot.delivery_id, + order_id: snapshot.order_id, + app_id: snapshot.app_id, + owner_user_id: snapshot.owner_user_id, + callback_url: snapshot.callback_url, + payload_json: snapshot.payload_json, + signature: snapshot.signature, + status: snapshot.status, + attempt_count: snapshot.attempt_count, + available_at_micros: snapshot.available_at_micros, + lease_worker_id: snapshot.lease_worker_id, + lease_expires_at_micros: snapshot.lease_expires_at_micros, + last_error_message: snapshot.last_error_message, + created_at_micros: snapshot.created_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} diff --git a/server-rs/crates/spacetime-client/src/active/payment.rs b/server-rs/crates/spacetime-client/src/active/payment.rs new file mode 100644 index 000000000..8d1d767e6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/active/payment.rs @@ -0,0 +1,324 @@ +use super::*; + +impl SpacetimeClient { + pub async fn create_payment_app( + &self, + input: PaymentAppCreateRecordInput, + ) -> Result { + let input = input.into(); + self.call_after_connect("create_payment_app", move |connection, sender| { + connection + .procedures() + .create_payment_app_and_return_then(input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_payment_app_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn list_payment_apps( + &self, + owner_user_id: String, + ) -> Result, SpacetimeClientError> { + let input = PaymentAppListInput { owner_user_id }; + self.call_after_connect("list_payment_apps", move |connection, sender| { + connection + .procedures() + .list_payment_apps_and_return_then(input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_payment_app_list_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn update_payment_app( + &self, + input: PaymentAppUpdateRecordInput, + ) -> Result { + let input = input.into(); + self.call_after_connect("update_payment_app", move |connection, sender| { + connection + .procedures() + .update_payment_app_and_return_then(input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_payment_app_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn list_payment_api_keys( + &self, + owner_user_id: String, + ) -> Result, SpacetimeClientError> { + let input = PaymentApiKeyListInput { owner_user_id }; + self.call_after_connect("list_payment_api_keys", move |connection, sender| { + connection + .procedures() + .list_payment_api_keys_and_return_then(input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_payment_api_key_list_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn create_payment_api_key( + &self, + input: PaymentApiKeyCreateRecordInput, + ) -> Result { + let input = input.into(); + self.call_after_connect("create_payment_api_key", move |connection, sender| { + connection + .procedures() + .create_payment_api_key_and_return_then(input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_payment_api_key_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn revoke_payment_api_key( + &self, + input: PaymentApiKeyRevokeRecordInput, + ) -> Result { + let input = input.into(); + self.call_after_connect("revoke_payment_api_key", move |connection, sender| { + connection + .procedures() + .revoke_payment_api_key_and_return_then(input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_payment_api_key_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn authenticate_payment_api_key( + &self, + input: PaymentApiKeyAuthenticateRecordInput, + ) -> Result<(PaymentAppRecord, PaymentApiKeyRecord), SpacetimeClientError> { + let input = input.into(); + self.call_after_connect("authenticate_payment_api_key", move |connection, sender| { + connection + .procedures() + .authenticate_payment_api_key_and_return_then(input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_payment_authenticate_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn create_payment_order( + &self, + input: PaymentOrderCreateRecordInput, + ) -> Result { + let input = input.into(); + self.call_after_connect("create_payment_order", move |connection, sender| { + connection + .procedures() + .create_payment_order_and_return_then(input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_payment_order_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn get_payment_order( + &self, + input: PaymentOrderGetRecordInput, + ) -> Result { + let input = input.into(); + self.call_after_connect("get_payment_order", move |connection, sender| { + connection + .procedures() + .get_payment_order_and_return_then(input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_payment_order_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn get_payment_order_by_checkout_token( + &self, + checkout_token: String, + ) -> Result { + let input = PaymentOrderCheckoutGetInput { checkout_token }; + self.call_after_connect( + "get_payment_order_by_checkout_token", + move |connection, sender| { + connection + .procedures() + .get_payment_order_by_checkout_token_and_return_then( + input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_payment_order_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn list_payment_orders( + &self, + input: PaymentOrderListRecordInput, + ) -> Result, SpacetimeClientError> { + let input = input.into(); + self.call_after_connect("list_payment_orders", move |connection, sender| { + connection + .procedures() + .list_payment_orders_and_return_then(input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_payment_order_list_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn mark_payment_order_paid( + &self, + input: PaymentOrderMarkPaidRecordInput, + ) -> Result { + let input = input.into(); + self.call_after_connect("mark_payment_order_paid", move |connection, sender| { + connection + .procedures() + .mark_payment_order_paid_and_return_then(input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_payment_order_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn set_payment_order_provider_code( + &self, + input: PaymentOrderProviderCodeRecordInput, + ) -> Result { + let input = input.into(); + self.call_after_connect( + "set_payment_order_provider_code", + move |connection, sender| { + connection + .procedures() + .set_payment_order_provider_code_and_return_then(input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_payment_order_result); + send_once(&sender, mapped); + }); + }, + ) + .await + } + + pub async fn mark_payment_order_provider_attempted( + &self, + input: PaymentOrderProviderAttemptRecordInput, + ) -> Result { + let input = input.into(); + self.call_after_connect( + "mark_payment_order_provider_attempted", + move |connection, sender| { + connection + .procedures() + .mark_payment_order_provider_attempted_and_return_then( + input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_payment_order_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn enqueue_payment_webhook( + &self, + input: PaymentWebhookEnqueueRecordInput, + ) -> Result, SpacetimeClientError> { + let input = input.into(); + self.call_after_connect("enqueue_payment_webhook", move |connection, sender| { + connection + .procedures() + .enqueue_payment_webhook_and_return_then(input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_payment_webhook_enqueue_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn claim_payment_webhooks( + &self, + input: PaymentWebhookClaimRecordInput, + ) -> Result, SpacetimeClientError> { + let input = input.into(); + self.call_after_connect("claim_payment_webhooks", move |connection, sender| { + connection + .procedures() + .claim_payment_webhooks_and_return_then(input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_payment_webhook_claim_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn complete_payment_webhook( + &self, + input: PaymentWebhookCompleteRecordInput, + ) -> Result { + let input = input.into(); + self.call_after_connect("complete_payment_webhook", move |connection, sender| { + connection + .procedures() + .complete_payment_webhook_and_return_then(input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_payment_webhook_complete_result); + send_once(&sender, mapped); + }); + }) + .await + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 0946050b7..805d286be 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -134,6 +134,7 @@ pub mod auth_store_projection_user_type; pub mod auth_store_projection_view_type; pub mod auth_store_projection_wechat_state_type; pub mod authenticate_external_api_key_and_return_procedure; +pub mod authenticate_payment_api_key_and_return_procedure; pub mod authorize_database_migration_operator_procedure; pub mod backfill_editor_canvas_layout_and_return_procedure; pub mod backfill_external_generation_job_summaries_and_return_procedure; @@ -142,6 +143,7 @@ pub mod bind_asset_object_to_entity_reducer; pub mod cancel_ai_task_and_return_procedure; pub mod cancel_game_distribution_version_and_return_procedure; pub mod claim_external_generation_jobs_and_return_procedure; +pub mod claim_payment_webhooks_and_return_procedure; pub mod claim_profile_recharge_order_expiration_schedule_and_return_procedure; pub mod claim_profile_task_reward_and_return_procedure; pub mod clean_editor_image_asset_kind_and_return_procedure; @@ -152,6 +154,7 @@ pub mod complete_ai_stage_and_return_procedure; pub mod complete_ai_task_and_return_procedure; pub mod complete_editor_asset_group_cohort_and_return_procedure; pub mod complete_external_generation_job_and_return_procedure; +pub mod complete_payment_webhook_and_return_procedure; pub mod complete_profile_recharge_order_expiration_schedule_and_return_procedure; pub mod confirm_asset_object_and_return_procedure; pub mod confirm_asset_object_reducer; @@ -169,6 +172,9 @@ pub mod create_error_report_and_return_procedure; pub mod create_external_api_key_and_return_procedure; pub mod create_game_distribution_game_and_return_procedure; pub mod create_game_distribution_version_and_return_procedure; +pub mod create_payment_api_key_and_return_procedure; +pub mod create_payment_app_and_return_procedure; +pub mod create_payment_order_and_return_procedure; pub mod create_profile_recharge_order_and_return_procedure; pub mod creation_entry_config_table; pub mod creation_entry_config_type; @@ -337,6 +343,7 @@ pub mod editor_spritesheet_slice_batch_persist_result_type; pub mod editor_spritesheet_slice_persist_item_input_type; pub mod editor_spritesheet_slice_persisted_item_type; pub mod enqueue_external_generation_job_and_return_procedure; +pub mod enqueue_payment_webhook_and_return_procedure; pub mod enqueue_profile_wallet_refund_outbox_and_return_procedure; pub mod ensure_analytics_date_dimension_for_date_reducer; pub mod error_report_create_input_type; @@ -477,6 +484,8 @@ pub mod get_game_distribution_my_review_and_return_procedure; pub mod get_game_distribution_version_and_return_procedure; pub mod get_llm_router_account_and_return_procedure; pub mod get_owner_game_distribution_version_and_return_procedure; +pub mod get_payment_order_and_return_procedure; +pub mod get_payment_order_by_checkout_token_and_return_procedure; pub mod get_profile_dashboard_procedure; pub mod get_profile_recharge_center_procedure; pub mod get_profile_recharge_order_and_return_procedure; @@ -509,6 +518,9 @@ pub mod list_game_distribution_review_games_and_return_procedure; pub mod list_game_distribution_reviews_and_return_procedure; pub mod list_game_distribution_user_reviews_and_return_procedure; pub mod list_owner_game_distribution_games_and_return_procedure; +pub mod list_payment_api_keys_and_return_procedure; +pub mod list_payment_apps_and_return_procedure; +pub mod list_payment_orders_and_return_procedure; pub mod list_profile_recharge_refund_holds_for_reconciliation_procedure; pub mod list_profile_recharge_refunds_for_reconciliation_procedure; pub mod list_profile_wallet_ledger_procedure; @@ -528,10 +540,46 @@ pub mod llm_router_billing_checkpoint_type; pub mod llm_router_quota_settlement_input_type; pub mod llm_router_quota_settlement_result_type; pub mod mark_editor_showcase_asset_refunded_and_return_procedure; +pub mod mark_payment_order_paid_and_return_procedure; +pub mod mark_payment_order_provider_attempted_and_return_procedure; pub mod mark_profile_recharge_order_expiration_checked_procedure; pub mod mark_profile_recharge_order_paid_and_return_procedure; pub mod moderate_game_distribution_user_review_and_return_procedure; pub mod normalize_editor_character_animation_metadata_and_return_procedure; +pub mod payment_api_key_authenticate_input_type; +pub mod payment_api_key_create_input_type; +pub mod payment_api_key_list_input_type; +pub mod payment_api_key_revoke_input_type; +pub mod payment_api_key_snapshot_type; +pub mod payment_api_key_table; +pub mod payment_api_key_type; +pub mod payment_app_create_input_type; +pub mod payment_app_list_input_type; +pub mod payment_app_procedure_result_type; +pub mod payment_app_snapshot_type; +pub mod payment_app_table; +pub mod payment_app_type; +pub mod payment_app_update_input_type; +pub mod payment_order_checkout_get_input_type; +pub mod payment_order_create_input_type; +pub mod payment_order_get_input_type; +pub mod payment_order_list_input_type; +pub mod payment_order_mark_paid_input_type; +pub mod payment_order_procedure_result_type; +pub mod payment_order_provider_attempt_input_type; +pub mod payment_order_provider_code_input_type; +pub mod payment_order_snapshot_type; +pub mod payment_order_status_type; +pub mod payment_order_table; +pub mod payment_order_type; +pub mod payment_provider_type; +pub mod payment_webhook_claim_input_type; +pub mod payment_webhook_complete_input_type; +pub mod payment_webhook_delivery_snapshot_type; +pub mod payment_webhook_delivery_table; +pub mod payment_webhook_delivery_type; +pub mod payment_webhook_enqueue_input_type; +pub mod payment_webhook_procedure_result_type; pub mod persist_editor_generation_result_and_return_procedure; pub mod persist_editor_pixel_art_result_and_return_procedure; pub mod persist_editor_spritesheet_slice_batch_and_return_procedure; @@ -625,6 +673,7 @@ pub mod resolve_profile_recharge_refund_manual_review_and_return_procedure; pub mod restore_game_distribution_game_and_return_procedure; pub mod revoke_database_migration_operator_procedure; pub mod revoke_external_api_key_and_return_procedure; +pub mod revoke_payment_api_key_and_return_procedure; pub mod rollback_editor_canvas_layout_and_return_procedure; pub mod rotate_editor_generation_runtime_service_identity_and_return_procedure; pub mod runtime_browse_history_theme_mode_type; @@ -776,6 +825,7 @@ pub mod save_editor_project_layout_v_2_and_return_procedure; pub mod save_game_distribution_my_review_and_return_procedure; pub mod seed_analytics_date_dimensions_reducer; pub mod set_editor_showcase_asset_like_for_viewer_and_return_procedure; +pub mod set_payment_order_provider_code_and_return_procedure; pub mod settle_llm_router_quota_and_return_procedure; pub mod start_ai_task_reducer; pub mod start_ai_task_stage_reducer; @@ -797,6 +847,7 @@ pub mod update_editor_project_resource_showcase_and_return_procedure; pub mod update_editor_showcase_asset_display_and_return_procedure; pub mod update_error_report_and_return_procedure; pub mod update_external_generation_job_phase_and_return_procedure; +pub mod update_payment_app_and_return_procedure; pub mod upload_agc_analytics_batch_procedure; pub mod upsert_editor_generation_pricing_config_and_return_procedure; pub mod upsert_editor_showcase_campaign_config_and_return_procedure; @@ -937,6 +988,7 @@ pub use auth_store_projection_user_type::AuthStoreProjectionUser; pub use auth_store_projection_view_type::AuthStoreProjectionView; pub use auth_store_projection_wechat_state_type::AuthStoreProjectionWechatState; pub use authenticate_external_api_key_and_return_procedure::authenticate_external_api_key_and_return; +pub use authenticate_payment_api_key_and_return_procedure::authenticate_payment_api_key_and_return; pub use authorize_database_migration_operator_procedure::authorize_database_migration_operator; pub use backfill_editor_canvas_layout_and_return_procedure::backfill_editor_canvas_layout_and_return; pub use backfill_external_generation_job_summaries_and_return_procedure::backfill_external_generation_job_summaries_and_return; @@ -945,6 +997,7 @@ pub use bind_asset_object_to_entity_reducer::bind_asset_object_to_entity; pub use cancel_ai_task_and_return_procedure::cancel_ai_task_and_return; pub use cancel_game_distribution_version_and_return_procedure::cancel_game_distribution_version_and_return; pub use claim_external_generation_jobs_and_return_procedure::claim_external_generation_jobs_and_return; +pub use claim_payment_webhooks_and_return_procedure::claim_payment_webhooks_and_return; pub use claim_profile_recharge_order_expiration_schedule_and_return_procedure::claim_profile_recharge_order_expiration_schedule_and_return; pub use claim_profile_task_reward_and_return_procedure::claim_profile_task_reward_and_return; pub use clean_editor_image_asset_kind_and_return_procedure::clean_editor_image_asset_kind_and_return; @@ -955,6 +1008,7 @@ pub use complete_ai_stage_and_return_procedure::complete_ai_stage_and_return; pub use complete_ai_task_and_return_procedure::complete_ai_task_and_return; pub use complete_editor_asset_group_cohort_and_return_procedure::complete_editor_asset_group_cohort_and_return; pub use complete_external_generation_job_and_return_procedure::complete_external_generation_job_and_return; +pub use complete_payment_webhook_and_return_procedure::complete_payment_webhook_and_return; pub use complete_profile_recharge_order_expiration_schedule_and_return_procedure::complete_profile_recharge_order_expiration_schedule_and_return; pub use confirm_asset_object_and_return_procedure::confirm_asset_object_and_return; pub use confirm_asset_object_reducer::confirm_asset_object; @@ -972,6 +1026,9 @@ pub use create_error_report_and_return_procedure::create_error_report_and_return pub use create_external_api_key_and_return_procedure::create_external_api_key_and_return; pub use create_game_distribution_game_and_return_procedure::create_game_distribution_game_and_return; pub use create_game_distribution_version_and_return_procedure::create_game_distribution_version_and_return; +pub use create_payment_api_key_and_return_procedure::create_payment_api_key_and_return; +pub use create_payment_app_and_return_procedure::create_payment_app_and_return; +pub use create_payment_order_and_return_procedure::create_payment_order_and_return; pub use create_profile_recharge_order_and_return_procedure::create_profile_recharge_order_and_return; pub use creation_entry_config_table::*; pub use creation_entry_config_type::CreationEntryConfig; @@ -1140,6 +1197,7 @@ pub use editor_spritesheet_slice_batch_persist_result_type::EditorSpritesheetSli pub use editor_spritesheet_slice_persist_item_input_type::EditorSpritesheetSlicePersistItemInput; pub use editor_spritesheet_slice_persisted_item_type::EditorSpritesheetSlicePersistedItem; pub use enqueue_external_generation_job_and_return_procedure::enqueue_external_generation_job_and_return; +pub use enqueue_payment_webhook_and_return_procedure::enqueue_payment_webhook_and_return; pub use enqueue_profile_wallet_refund_outbox_and_return_procedure::enqueue_profile_wallet_refund_outbox_and_return; pub use ensure_analytics_date_dimension_for_date_reducer::ensure_analytics_date_dimension_for_date; pub use error_report_create_input_type::ErrorReportCreateInput; @@ -1280,6 +1338,8 @@ pub use get_game_distribution_my_review_and_return_procedure::get_game_distribut pub use get_game_distribution_version_and_return_procedure::get_game_distribution_version_and_return; pub use get_llm_router_account_and_return_procedure::get_llm_router_account_and_return; pub use get_owner_game_distribution_version_and_return_procedure::get_owner_game_distribution_version_and_return; +pub use get_payment_order_and_return_procedure::get_payment_order_and_return; +pub use get_payment_order_by_checkout_token_and_return_procedure::get_payment_order_by_checkout_token_and_return; pub use get_profile_dashboard_procedure::get_profile_dashboard; pub use get_profile_recharge_center_procedure::get_profile_recharge_center; pub use get_profile_recharge_order_and_return_procedure::get_profile_recharge_order_and_return; @@ -1312,6 +1372,9 @@ pub use list_game_distribution_review_games_and_return_procedure::list_game_dist pub use list_game_distribution_reviews_and_return_procedure::list_game_distribution_reviews_and_return; pub use list_game_distribution_user_reviews_and_return_procedure::list_game_distribution_user_reviews_and_return; pub use list_owner_game_distribution_games_and_return_procedure::list_owner_game_distribution_games_and_return; +pub use list_payment_api_keys_and_return_procedure::list_payment_api_keys_and_return; +pub use list_payment_apps_and_return_procedure::list_payment_apps_and_return; +pub use list_payment_orders_and_return_procedure::list_payment_orders_and_return; pub use list_profile_recharge_refund_holds_for_reconciliation_procedure::list_profile_recharge_refund_holds_for_reconciliation; pub use list_profile_recharge_refunds_for_reconciliation_procedure::list_profile_recharge_refunds_for_reconciliation; pub use list_profile_wallet_ledger_procedure::list_profile_wallet_ledger; @@ -1331,10 +1394,46 @@ pub use llm_router_billing_checkpoint_type::LlmRouterBillingCheckpoint; pub use llm_router_quota_settlement_input_type::LlmRouterQuotaSettlementInput; pub use llm_router_quota_settlement_result_type::LlmRouterQuotaSettlementResult; pub use mark_editor_showcase_asset_refunded_and_return_procedure::mark_editor_showcase_asset_refunded_and_return; +pub use mark_payment_order_paid_and_return_procedure::mark_payment_order_paid_and_return; +pub use mark_payment_order_provider_attempted_and_return_procedure::mark_payment_order_provider_attempted_and_return; pub use mark_profile_recharge_order_expiration_checked_procedure::mark_profile_recharge_order_expiration_checked; pub use mark_profile_recharge_order_paid_and_return_procedure::mark_profile_recharge_order_paid_and_return; pub use moderate_game_distribution_user_review_and_return_procedure::moderate_game_distribution_user_review_and_return; pub use normalize_editor_character_animation_metadata_and_return_procedure::normalize_editor_character_animation_metadata_and_return; +pub use payment_api_key_authenticate_input_type::PaymentApiKeyAuthenticateInput; +pub use payment_api_key_create_input_type::PaymentApiKeyCreateInput; +pub use payment_api_key_list_input_type::PaymentApiKeyListInput; +pub use payment_api_key_revoke_input_type::PaymentApiKeyRevokeInput; +pub use payment_api_key_snapshot_type::PaymentApiKeySnapshot; +pub use payment_api_key_table::*; +pub use payment_api_key_type::PaymentApiKey; +pub use payment_app_create_input_type::PaymentAppCreateInput; +pub use payment_app_list_input_type::PaymentAppListInput; +pub use payment_app_procedure_result_type::PaymentAppProcedureResult; +pub use payment_app_snapshot_type::PaymentAppSnapshot; +pub use payment_app_table::*; +pub use payment_app_type::PaymentApp; +pub use payment_app_update_input_type::PaymentAppUpdateInput; +pub use payment_order_checkout_get_input_type::PaymentOrderCheckoutGetInput; +pub use payment_order_create_input_type::PaymentOrderCreateInput; +pub use payment_order_get_input_type::PaymentOrderGetInput; +pub use payment_order_list_input_type::PaymentOrderListInput; +pub use payment_order_mark_paid_input_type::PaymentOrderMarkPaidInput; +pub use payment_order_procedure_result_type::PaymentOrderProcedureResult; +pub use payment_order_provider_attempt_input_type::PaymentOrderProviderAttemptInput; +pub use payment_order_provider_code_input_type::PaymentOrderProviderCodeInput; +pub use payment_order_snapshot_type::PaymentOrderSnapshot; +pub use payment_order_status_type::PaymentOrderStatus; +pub use payment_order_table::*; +pub use payment_order_type::PaymentOrder; +pub use payment_provider_type::PaymentProvider; +pub use payment_webhook_claim_input_type::PaymentWebhookClaimInput; +pub use payment_webhook_complete_input_type::PaymentWebhookCompleteInput; +pub use payment_webhook_delivery_snapshot_type::PaymentWebhookDeliverySnapshot; +pub use payment_webhook_delivery_table::*; +pub use payment_webhook_delivery_type::PaymentWebhookDelivery; +pub use payment_webhook_enqueue_input_type::PaymentWebhookEnqueueInput; +pub use payment_webhook_procedure_result_type::PaymentWebhookProcedureResult; pub use persist_editor_generation_result_and_return_procedure::persist_editor_generation_result_and_return; pub use persist_editor_pixel_art_result_and_return_procedure::persist_editor_pixel_art_result_and_return; pub use persist_editor_spritesheet_slice_batch_and_return_procedure::persist_editor_spritesheet_slice_batch_and_return; @@ -1428,6 +1527,7 @@ pub use resolve_profile_recharge_refund_manual_review_and_return_procedure::reso pub use restore_game_distribution_game_and_return_procedure::restore_game_distribution_game_and_return; pub use revoke_database_migration_operator_procedure::revoke_database_migration_operator; pub use revoke_external_api_key_and_return_procedure::revoke_external_api_key_and_return; +pub use revoke_payment_api_key_and_return_procedure::revoke_payment_api_key_and_return; pub use rollback_editor_canvas_layout_and_return_procedure::rollback_editor_canvas_layout_and_return; pub use rotate_editor_generation_runtime_service_identity_and_return_procedure::rotate_editor_generation_runtime_service_identity_and_return; pub use runtime_browse_history_theme_mode_type::RuntimeBrowseHistoryThemeMode; @@ -1579,6 +1679,7 @@ pub use save_editor_project_layout_v_2_and_return_procedure::save_editor_project pub use save_game_distribution_my_review_and_return_procedure::save_game_distribution_my_review_and_return; pub use seed_analytics_date_dimensions_reducer::seed_analytics_date_dimensions; pub use set_editor_showcase_asset_like_for_viewer_and_return_procedure::set_editor_showcase_asset_like_for_viewer_and_return; +pub use set_payment_order_provider_code_and_return_procedure::set_payment_order_provider_code_and_return; pub use settle_llm_router_quota_and_return_procedure::settle_llm_router_quota_and_return; pub use start_ai_task_reducer::start_ai_task; pub use start_ai_task_stage_reducer::start_ai_task_stage; @@ -1600,6 +1701,7 @@ pub use update_editor_project_resource_showcase_and_return_procedure::update_edi pub use update_editor_showcase_asset_display_and_return_procedure::update_editor_showcase_asset_display_and_return; pub use update_error_report_and_return_procedure::update_error_report_and_return; pub use update_external_generation_job_phase_and_return_procedure::update_external_generation_job_phase_and_return; +pub use update_payment_app_and_return_procedure::update_payment_app_and_return; pub use upload_agc_analytics_batch_procedure::upload_agc_analytics_batch; pub use upsert_editor_generation_pricing_config_and_return_procedure::upsert_editor_generation_pricing_config_and_return; pub use upsert_editor_showcase_campaign_config_and_return_procedure::upsert_editor_showcase_campaign_config_and_return; @@ -1772,6 +1874,10 @@ pub struct DbUpdate { game_distribution_version: __sdk::TableUpdate, llm_router_account: __sdk::TableUpdate, llm_router_billing_checkpoint: __sdk::TableUpdate, + payment_api_key: __sdk::TableUpdate, + payment_app: __sdk::TableUpdate, + payment_order: __sdk::TableUpdate, + payment_webhook_delivery: __sdk::TableUpdate, profile_code_operation: __sdk::TableUpdate, profile_daily_free_points: __sdk::TableUpdate, profile_dashboard_state: __sdk::TableUpdate, @@ -2003,6 +2109,18 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "llm_router_billing_checkpoint" => db_update.llm_router_billing_checkpoint.append( llm_router_billing_checkpoint_table::parse_table_update(table_update)?, ), + "payment_api_key" => db_update + .payment_api_key + .append(payment_api_key_table::parse_table_update(table_update)?), + "payment_app" => db_update + .payment_app + .append(payment_app_table::parse_table_update(table_update)?), + "payment_order" => db_update + .payment_order + .append(payment_order_table::parse_table_update(table_update)?), + "payment_webhook_delivery" => db_update.payment_webhook_delivery.append( + payment_webhook_delivery_table::parse_table_update(table_update)?, + ), "profile_code_operation" => db_update.profile_code_operation.append( profile_code_operation_table::parse_table_update(table_update)?, ), @@ -2414,6 +2532,21 @@ impl __sdk::DbUpdate for DbUpdate { &self.llm_router_billing_checkpoint, ) .with_updates_by_pk(|row| &row.account_key); + diff.payment_api_key = cache + .apply_diff_to_table::("payment_api_key", &self.payment_api_key) + .with_updates_by_pk(|row| &row.key_id); + diff.payment_app = cache + .apply_diff_to_table::("payment_app", &self.payment_app) + .with_updates_by_pk(|row| &row.app_id); + diff.payment_order = cache + .apply_diff_to_table::("payment_order", &self.payment_order) + .with_updates_by_pk(|row| &row.order_id); + diff.payment_webhook_delivery = cache + .apply_diff_to_table::( + "payment_webhook_delivery", + &self.payment_webhook_delivery, + ) + .with_updates_by_pk(|row| &row.delivery_id); diff.profile_code_operation = cache .apply_diff_to_table::( "profile_code_operation", @@ -2775,6 +2908,18 @@ impl __sdk::DbUpdate for DbUpdate { "llm_router_billing_checkpoint" => db_update .llm_router_billing_checkpoint .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "payment_api_key" => db_update + .payment_api_key + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "payment_app" => db_update + .payment_app + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "payment_order" => db_update + .payment_order + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "payment_webhook_delivery" => db_update + .payment_webhook_delivery + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "profile_code_operation" => db_update .profile_code_operation .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -3049,6 +3194,18 @@ impl __sdk::DbUpdate for DbUpdate { "llm_router_billing_checkpoint" => db_update .llm_router_billing_checkpoint .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "payment_api_key" => db_update + .payment_api_key + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "payment_app" => db_update + .payment_app + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "payment_order" => db_update + .payment_order + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "payment_webhook_delivery" => db_update + .payment_webhook_delivery + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "profile_code_operation" => db_update .profile_code_operation .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -3229,6 +3386,10 @@ pub struct AppliedDiff<'r> { game_distribution_version: __sdk::TableAppliedDiff<'r, GameDistributionVersion>, llm_router_account: __sdk::TableAppliedDiff<'r, LlmRouterAccount>, llm_router_billing_checkpoint: __sdk::TableAppliedDiff<'r, LlmRouterBillingCheckpoint>, + payment_api_key: __sdk::TableAppliedDiff<'r, PaymentApiKey>, + payment_app: __sdk::TableAppliedDiff<'r, PaymentApp>, + payment_order: __sdk::TableAppliedDiff<'r, PaymentOrder>, + payment_webhook_delivery: __sdk::TableAppliedDiff<'r, PaymentWebhookDelivery>, profile_code_operation: __sdk::TableAppliedDiff<'r, ProfileCodeOperation>, profile_daily_free_points: __sdk::TableAppliedDiff<'r, ProfileDailyFreePoints>, profile_dashboard_state: __sdk::TableAppliedDiff<'r, ProfileDashboardState>, @@ -3526,6 +3687,22 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.llm_router_billing_checkpoint, event, ); + callbacks.invoke_table_row_callbacks::( + "payment_api_key", + &self.payment_api_key, + event, + ); + callbacks.invoke_table_row_callbacks::("payment_app", &self.payment_app, event); + callbacks.invoke_table_row_callbacks::( + "payment_order", + &self.payment_order, + event, + ); + callbacks.invoke_table_row_callbacks::( + "payment_webhook_delivery", + &self.payment_webhook_delivery, + event, + ); callbacks.invoke_table_row_callbacks::( "profile_code_operation", &self.profile_code_operation, @@ -4421,6 +4598,10 @@ impl __sdk::SpacetimeModule for RemoteModule { game_distribution_version_table::register_table(client_cache); llm_router_account_table::register_table(client_cache); llm_router_billing_checkpoint_table::register_table(client_cache); + payment_api_key_table::register_table(client_cache); + payment_app_table::register_table(client_cache); + payment_order_table::register_table(client_cache); + payment_webhook_delivery_table::register_table(client_cache); profile_code_operation_table::register_table(client_cache); profile_daily_free_points_table::register_table(client_cache); profile_dashboard_state_table::register_table(client_cache); @@ -4510,6 +4691,10 @@ impl __sdk::SpacetimeModule for RemoteModule { "game_distribution_version", "llm_router_account", "llm_router_billing_checkpoint", + "payment_api_key", + "payment_app", + "payment_order", + "payment_webhook_delivery", "profile_code_operation", "profile_daily_free_points", "profile_dashboard_state", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/authenticate_payment_api_key_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/authenticate_payment_api_key_and_return_procedure.rs new file mode 100644 index 000000000..33c26c311 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/authenticate_payment_api_key_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::payment_api_key_authenticate_input_type::PaymentApiKeyAuthenticateInput; +use super::payment_app_procedure_result_type::PaymentAppProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct AuthenticatePaymentApiKeyAndReturnArgs { + pub input: PaymentApiKeyAuthenticateInput, +} + +impl __sdk::InModule for AuthenticatePaymentApiKeyAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `authenticate_payment_api_key_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait authenticate_payment_api_key_and_return { + fn authenticate_payment_api_key_and_return(&self, input: PaymentApiKeyAuthenticateInput) { + self.authenticate_payment_api_key_and_return_then(input, |_, _| {}); + } + + fn authenticate_payment_api_key_and_return_then( + &self, + input: PaymentApiKeyAuthenticateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl authenticate_payment_api_key_and_return for super::RemoteProcedures { + fn authenticate_payment_api_key_and_return_then( + &self, + input: PaymentApiKeyAuthenticateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PaymentAppProcedureResult>( + "authenticate_payment_api_key_and_return", + AuthenticatePaymentApiKeyAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/claim_payment_webhooks_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/claim_payment_webhooks_and_return_procedure.rs new file mode 100644 index 000000000..f28ea001f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/claim_payment_webhooks_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::payment_webhook_claim_input_type::PaymentWebhookClaimInput; +use super::payment_webhook_procedure_result_type::PaymentWebhookProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ClaimPaymentWebhooksAndReturnArgs { + pub input: PaymentWebhookClaimInput, +} + +impl __sdk::InModule for ClaimPaymentWebhooksAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `claim_payment_webhooks_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait claim_payment_webhooks_and_return { + fn claim_payment_webhooks_and_return(&self, input: PaymentWebhookClaimInput) { + self.claim_payment_webhooks_and_return_then(input, |_, _| {}); + } + + fn claim_payment_webhooks_and_return_then( + &self, + input: PaymentWebhookClaimInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl claim_payment_webhooks_and_return for super::RemoteProcedures { + fn claim_payment_webhooks_and_return_then( + &self, + input: PaymentWebhookClaimInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PaymentWebhookProcedureResult>( + "claim_payment_webhooks_and_return", + ClaimPaymentWebhooksAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/complete_payment_webhook_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/complete_payment_webhook_and_return_procedure.rs new file mode 100644 index 000000000..8eb38c881 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/complete_payment_webhook_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::payment_webhook_complete_input_type::PaymentWebhookCompleteInput; +use super::payment_webhook_procedure_result_type::PaymentWebhookProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct CompletePaymentWebhookAndReturnArgs { + pub input: PaymentWebhookCompleteInput, +} + +impl __sdk::InModule for CompletePaymentWebhookAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `complete_payment_webhook_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait complete_payment_webhook_and_return { + fn complete_payment_webhook_and_return(&self, input: PaymentWebhookCompleteInput) { + self.complete_payment_webhook_and_return_then(input, |_, _| {}); + } + + fn complete_payment_webhook_and_return_then( + &self, + input: PaymentWebhookCompleteInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl complete_payment_webhook_and_return for super::RemoteProcedures { + fn complete_payment_webhook_and_return_then( + &self, + input: PaymentWebhookCompleteInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PaymentWebhookProcedureResult>( + "complete_payment_webhook_and_return", + CompletePaymentWebhookAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/create_payment_api_key_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/create_payment_api_key_and_return_procedure.rs new file mode 100644 index 000000000..6cd1a0ca5 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/create_payment_api_key_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::payment_api_key_create_input_type::PaymentApiKeyCreateInput; +use super::payment_app_procedure_result_type::PaymentAppProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct CreatePaymentApiKeyAndReturnArgs { + pub input: PaymentApiKeyCreateInput, +} + +impl __sdk::InModule for CreatePaymentApiKeyAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `create_payment_api_key_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait create_payment_api_key_and_return { + fn create_payment_api_key_and_return(&self, input: PaymentApiKeyCreateInput) { + self.create_payment_api_key_and_return_then(input, |_, _| {}); + } + + fn create_payment_api_key_and_return_then( + &self, + input: PaymentApiKeyCreateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl create_payment_api_key_and_return for super::RemoteProcedures { + fn create_payment_api_key_and_return_then( + &self, + input: PaymentApiKeyCreateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PaymentAppProcedureResult>( + "create_payment_api_key_and_return", + CreatePaymentApiKeyAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/create_payment_app_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/create_payment_app_and_return_procedure.rs new file mode 100644 index 000000000..493fba127 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/create_payment_app_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::payment_app_create_input_type::PaymentAppCreateInput; +use super::payment_app_procedure_result_type::PaymentAppProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct CreatePaymentAppAndReturnArgs { + pub input: PaymentAppCreateInput, +} + +impl __sdk::InModule for CreatePaymentAppAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `create_payment_app_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait create_payment_app_and_return { + fn create_payment_app_and_return(&self, input: PaymentAppCreateInput) { + self.create_payment_app_and_return_then(input, |_, _| {}); + } + + fn create_payment_app_and_return_then( + &self, + input: PaymentAppCreateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl create_payment_app_and_return for super::RemoteProcedures { + fn create_payment_app_and_return_then( + &self, + input: PaymentAppCreateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PaymentAppProcedureResult>( + "create_payment_app_and_return", + CreatePaymentAppAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/create_payment_order_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/create_payment_order_and_return_procedure.rs new file mode 100644 index 000000000..e576d93b7 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/create_payment_order_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::payment_order_create_input_type::PaymentOrderCreateInput; +use super::payment_order_procedure_result_type::PaymentOrderProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct CreatePaymentOrderAndReturnArgs { + pub input: PaymentOrderCreateInput, +} + +impl __sdk::InModule for CreatePaymentOrderAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `create_payment_order_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait create_payment_order_and_return { + fn create_payment_order_and_return(&self, input: PaymentOrderCreateInput) { + self.create_payment_order_and_return_then(input, |_, _| {}); + } + + fn create_payment_order_and_return_then( + &self, + input: PaymentOrderCreateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl create_payment_order_and_return for super::RemoteProcedures { + fn create_payment_order_and_return_then( + &self, + input: PaymentOrderCreateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PaymentOrderProcedureResult>( + "create_payment_order_and_return", + CreatePaymentOrderAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/enqueue_payment_webhook_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/enqueue_payment_webhook_and_return_procedure.rs new file mode 100644 index 000000000..91c952715 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/enqueue_payment_webhook_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::payment_webhook_enqueue_input_type::PaymentWebhookEnqueueInput; +use super::payment_webhook_procedure_result_type::PaymentWebhookProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct EnqueuePaymentWebhookAndReturnArgs { + pub input: PaymentWebhookEnqueueInput, +} + +impl __sdk::InModule for EnqueuePaymentWebhookAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `enqueue_payment_webhook_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait enqueue_payment_webhook_and_return { + fn enqueue_payment_webhook_and_return(&self, input: PaymentWebhookEnqueueInput) { + self.enqueue_payment_webhook_and_return_then(input, |_, _| {}); + } + + fn enqueue_payment_webhook_and_return_then( + &self, + input: PaymentWebhookEnqueueInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl enqueue_payment_webhook_and_return for super::RemoteProcedures { + fn enqueue_payment_webhook_and_return_then( + &self, + input: PaymentWebhookEnqueueInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PaymentWebhookProcedureResult>( + "enqueue_payment_webhook_and_return", + EnqueuePaymentWebhookAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_payment_order_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_payment_order_and_return_procedure.rs new file mode 100644 index 000000000..981124029 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_payment_order_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::payment_order_get_input_type::PaymentOrderGetInput; +use super::payment_order_procedure_result_type::PaymentOrderProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetPaymentOrderAndReturnArgs { + pub input: PaymentOrderGetInput, +} + +impl __sdk::InModule for GetPaymentOrderAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_payment_order_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_payment_order_and_return { + fn get_payment_order_and_return(&self, input: PaymentOrderGetInput) { + self.get_payment_order_and_return_then(input, |_, _| {}); + } + + fn get_payment_order_and_return_then( + &self, + input: PaymentOrderGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_payment_order_and_return for super::RemoteProcedures { + fn get_payment_order_and_return_then( + &self, + input: PaymentOrderGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PaymentOrderProcedureResult>( + "get_payment_order_and_return", + GetPaymentOrderAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_payment_order_by_checkout_token_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_payment_order_by_checkout_token_and_return_procedure.rs new file mode 100644 index 000000000..eb14d8e86 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_payment_order_by_checkout_token_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::payment_order_checkout_get_input_type::PaymentOrderCheckoutGetInput; +use super::payment_order_procedure_result_type::PaymentOrderProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetPaymentOrderByCheckoutTokenAndReturnArgs { + pub input: PaymentOrderCheckoutGetInput, +} + +impl __sdk::InModule for GetPaymentOrderByCheckoutTokenAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_payment_order_by_checkout_token_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_payment_order_by_checkout_token_and_return { + fn get_payment_order_by_checkout_token_and_return(&self, input: PaymentOrderCheckoutGetInput) { + self.get_payment_order_by_checkout_token_and_return_then(input, |_, _| {}); + } + + fn get_payment_order_by_checkout_token_and_return_then( + &self, + input: PaymentOrderCheckoutGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_payment_order_by_checkout_token_and_return for super::RemoteProcedures { + fn get_payment_order_by_checkout_token_and_return_then( + &self, + input: PaymentOrderCheckoutGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PaymentOrderProcedureResult>( + "get_payment_order_by_checkout_token_and_return", + GetPaymentOrderByCheckoutTokenAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_payment_api_keys_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_payment_api_keys_and_return_procedure.rs new file mode 100644 index 000000000..76c904866 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_payment_api_keys_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::payment_api_key_list_input_type::PaymentApiKeyListInput; +use super::payment_app_procedure_result_type::PaymentAppProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ListPaymentApiKeysAndReturnArgs { + pub input: PaymentApiKeyListInput, +} + +impl __sdk::InModule for ListPaymentApiKeysAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `list_payment_api_keys_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait list_payment_api_keys_and_return { + fn list_payment_api_keys_and_return(&self, input: PaymentApiKeyListInput) { + self.list_payment_api_keys_and_return_then(input, |_, _| {}); + } + + fn list_payment_api_keys_and_return_then( + &self, + input: PaymentApiKeyListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl list_payment_api_keys_and_return for super::RemoteProcedures { + fn list_payment_api_keys_and_return_then( + &self, + input: PaymentApiKeyListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PaymentAppProcedureResult>( + "list_payment_api_keys_and_return", + ListPaymentApiKeysAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_payment_apps_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_payment_apps_and_return_procedure.rs new file mode 100644 index 000000000..200701e1e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_payment_apps_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::payment_app_list_input_type::PaymentAppListInput; +use super::payment_app_procedure_result_type::PaymentAppProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ListPaymentAppsAndReturnArgs { + pub input: PaymentAppListInput, +} + +impl __sdk::InModule for ListPaymentAppsAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `list_payment_apps_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait list_payment_apps_and_return { + fn list_payment_apps_and_return(&self, input: PaymentAppListInput) { + self.list_payment_apps_and_return_then(input, |_, _| {}); + } + + fn list_payment_apps_and_return_then( + &self, + input: PaymentAppListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl list_payment_apps_and_return for super::RemoteProcedures { + fn list_payment_apps_and_return_then( + &self, + input: PaymentAppListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PaymentAppProcedureResult>( + "list_payment_apps_and_return", + ListPaymentAppsAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_payment_orders_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_payment_orders_and_return_procedure.rs new file mode 100644 index 000000000..f00f5674d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_payment_orders_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::payment_order_list_input_type::PaymentOrderListInput; +use super::payment_order_procedure_result_type::PaymentOrderProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ListPaymentOrdersAndReturnArgs { + pub input: PaymentOrderListInput, +} + +impl __sdk::InModule for ListPaymentOrdersAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `list_payment_orders_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait list_payment_orders_and_return { + fn list_payment_orders_and_return(&self, input: PaymentOrderListInput) { + self.list_payment_orders_and_return_then(input, |_, _| {}); + } + + fn list_payment_orders_and_return_then( + &self, + input: PaymentOrderListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl list_payment_orders_and_return for super::RemoteProcedures { + fn list_payment_orders_and_return_then( + &self, + input: PaymentOrderListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PaymentOrderProcedureResult>( + "list_payment_orders_and_return", + ListPaymentOrdersAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mark_payment_order_paid_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/mark_payment_order_paid_and_return_procedure.rs new file mode 100644 index 000000000..15600aaff --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/mark_payment_order_paid_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::payment_order_mark_paid_input_type::PaymentOrderMarkPaidInput; +use super::payment_order_procedure_result_type::PaymentOrderProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct MarkPaymentOrderPaidAndReturnArgs { + pub input: PaymentOrderMarkPaidInput, +} + +impl __sdk::InModule for MarkPaymentOrderPaidAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `mark_payment_order_paid_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait mark_payment_order_paid_and_return { + fn mark_payment_order_paid_and_return(&self, input: PaymentOrderMarkPaidInput) { + self.mark_payment_order_paid_and_return_then(input, |_, _| {}); + } + + fn mark_payment_order_paid_and_return_then( + &self, + input: PaymentOrderMarkPaidInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl mark_payment_order_paid_and_return for super::RemoteProcedures { + fn mark_payment_order_paid_and_return_then( + &self, + input: PaymentOrderMarkPaidInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PaymentOrderProcedureResult>( + "mark_payment_order_paid_and_return", + MarkPaymentOrderPaidAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mark_payment_order_provider_attempted_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/mark_payment_order_provider_attempted_and_return_procedure.rs new file mode 100644 index 000000000..b533220a7 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/mark_payment_order_provider_attempted_and_return_procedure.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::payment_order_procedure_result_type::PaymentOrderProcedureResult; +use super::payment_order_provider_attempt_input_type::PaymentOrderProviderAttemptInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct MarkPaymentOrderProviderAttemptedAndReturnArgs { + pub input: PaymentOrderProviderAttemptInput, +} + +impl __sdk::InModule for MarkPaymentOrderProviderAttemptedAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `mark_payment_order_provider_attempted_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait mark_payment_order_provider_attempted_and_return { + fn mark_payment_order_provider_attempted_and_return( + &self, + input: PaymentOrderProviderAttemptInput, + ) { + self.mark_payment_order_provider_attempted_and_return_then(input, |_, _| {}); + } + + fn mark_payment_order_provider_attempted_and_return_then( + &self, + input: PaymentOrderProviderAttemptInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl mark_payment_order_provider_attempted_and_return for super::RemoteProcedures { + fn mark_payment_order_provider_attempted_and_return_then( + &self, + input: PaymentOrderProviderAttemptInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PaymentOrderProcedureResult>( + "mark_payment_order_provider_attempted_and_return", + MarkPaymentOrderProviderAttemptedAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_api_key_authenticate_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_api_key_authenticate_input_type.rs new file mode 100644 index 000000000..e875b26e1 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_api_key_authenticate_input_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentApiKeyAuthenticateInput { + pub key_hash: String, + pub used_at_micros: i64, +} + +impl __sdk::InModule for PaymentApiKeyAuthenticateInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_api_key_create_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_api_key_create_input_type.rs new file mode 100644 index 000000000..f197e8a9b --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_api_key_create_input_type.rs @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +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, +} + +impl __sdk::InModule for PaymentApiKeyCreateInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_api_key_list_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_api_key_list_input_type.rs new file mode 100644 index 000000000..71c0c77aa --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_api_key_list_input_type.rs @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentApiKeyListInput { + pub owner_user_id: String, +} + +impl __sdk::InModule for PaymentApiKeyListInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_api_key_revoke_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_api_key_revoke_input_type.rs new file mode 100644 index 000000000..0078ab788 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_api_key_revoke_input_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentApiKeyRevokeInput { + pub key_id: String, + pub owner_user_id: String, + pub revoked_at_micros: i64, +} + +impl __sdk::InModule for PaymentApiKeyRevokeInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_api_key_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_api_key_snapshot_type.rs new file mode 100644 index 000000000..fc27bf52d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_api_key_snapshot_type.rs @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +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, + pub revoked_at_micros: Option, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for PaymentApiKeySnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_api_key_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_api_key_table.rs new file mode 100644 index 000000000..409f34921 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_api_key_table.rs @@ -0,0 +1,259 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::payment_api_key_type::PaymentApiKey; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `payment_api_key`. +/// +/// Obtain a handle from the [`PaymentApiKeyTableAccess::payment_api_key`] method on [`super::RemoteTables`], +/// like `ctx.db.payment_api_key()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.payment_api_key().on_insert(...)`. +pub struct PaymentApiKeyTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +/// Lifetime-aware accessor marker for the table `payment_api_key`. +pub struct PaymentApiKeyTableAccessor; + +impl __sdk::TableAccessor for PaymentApiKeyTableAccessor { + type Row = PaymentApiKey; + type Handle<'db> = PaymentApiKeyTableHandle<'db>; + + fn get<'db>(db: &'db super::RemoteTables) -> Self::Handle<'db> { + db.payment_api_key() + } +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `payment_api_key`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait PaymentApiKeyTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`PaymentApiKeyTableHandle`], which mediates access to the table `payment_api_key`. + fn payment_api_key(&self) -> PaymentApiKeyTableHandle<'_>; +} + +impl PaymentApiKeyTableAccess for super::RemoteTables { + fn payment_api_key(&self) -> PaymentApiKeyTableHandle<'_> { + PaymentApiKeyTableHandle { + imp: self.imp.get_table::("payment_api_key"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct PaymentApiKeyInsertCallbackId(__sdk::CallbackId); +pub struct PaymentApiKeyDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableLike for PaymentApiKeyTableHandle<'ctx> { + type Row = PaymentApiKey; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } +} + +impl<'ctx> __sdk::Table for PaymentApiKeyTableHandle<'ctx> { + type Row = PaymentApiKey; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = PaymentApiKeyInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PaymentApiKeyInsertCallbackId { + PaymentApiKeyInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: PaymentApiKeyInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = PaymentApiKeyDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PaymentApiKeyDeleteCallbackId { + PaymentApiKeyDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: PaymentApiKeyDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +impl<'ctx> __sdk::WithInsert for PaymentApiKeyTableHandle<'ctx> { + type InsertCallbackId = PaymentApiKeyInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PaymentApiKeyInsertCallbackId { + PaymentApiKeyInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: PaymentApiKeyInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } +} + +impl<'ctx> __sdk::WithDelete for PaymentApiKeyTableHandle<'ctx> { + type DeleteCallbackId = PaymentApiKeyDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PaymentApiKeyDeleteCallbackId { + PaymentApiKeyDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: PaymentApiKeyDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct PaymentApiKeyUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for PaymentApiKeyTableHandle<'ctx> { + type UpdateCallbackId = PaymentApiKeyUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> PaymentApiKeyUpdateCallbackId { + PaymentApiKeyUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: PaymentApiKeyUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +impl<'ctx> __sdk::WithUpdate for PaymentApiKeyTableHandle<'ctx> { + type UpdateCallbackId = PaymentApiKeyUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> PaymentApiKeyUpdateCallbackId { + PaymentApiKeyUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: PaymentApiKeyUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `key_id` unique index on the table `payment_api_key`, +/// which allows point queries on the field of the same name +/// via the [`PaymentApiKeyKeyIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.payment_api_key().key_id().find(...)`. +pub struct PaymentApiKeyKeyIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> PaymentApiKeyTableHandle<'ctx> { + /// Get a handle on the `key_id` unique index on the table `payment_api_key`. + pub fn key_id(&self) -> PaymentApiKeyKeyIdUnique<'ctx> { + PaymentApiKeyKeyIdUnique { + imp: self.imp.get_unique_constraint::("key_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> PaymentApiKeyKeyIdUnique<'ctx> { + /// Find the subscribed row whose `key_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +/// Access to the `key_hash` unique index on the table `payment_api_key`, +/// which allows point queries on the field of the same name +/// via the [`PaymentApiKeyKeyHashUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.payment_api_key().key_hash().find(...)`. +pub struct PaymentApiKeyKeyHashUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> PaymentApiKeyTableHandle<'ctx> { + /// Get a handle on the `key_hash` unique index on the table `payment_api_key`. + pub fn key_hash(&self) -> PaymentApiKeyKeyHashUnique<'ctx> { + PaymentApiKeyKeyHashUnique { + imp: self.imp.get_unique_constraint::("key_hash"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> PaymentApiKeyKeyHashUnique<'ctx> { + /// Find the subscribed row whose `key_hash` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("payment_api_key"); + _table.add_unique_constraint::("key_id", |row| &row.key_id); + _table.add_unique_constraint::("key_hash", |row| &row.key_hash); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `PaymentApiKey`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait payment_api_keyQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `PaymentApiKey`. + fn payment_api_key(&self) -> __sdk::__query_builder::Table; +} + +impl payment_api_keyQueryTableAccess for __sdk::QueryTableAccessor { + fn payment_api_key(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("payment_api_key") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_api_key_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_api_key_type.rs new file mode 100644 index 000000000..4a9bc51e7 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_api_key_type.rs @@ -0,0 +1,85 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentApiKey { + 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 created_at: __sdk::Timestamp, + pub last_used_at: Option<__sdk::Timestamp>, + pub revoked_at: Option<__sdk::Timestamp>, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for PaymentApiKey { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `PaymentApiKey`. +/// +/// Provides typed access to columns for query building. +pub struct PaymentApiKeyCols { + pub key_id: __sdk::__query_builder::Col, + pub app_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub name: __sdk::__query_builder::Col, + pub key_prefix: __sdk::__query_builder::Col, + pub key_hash: __sdk::__query_builder::Col, + pub scopes_json: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, + pub last_used_at: __sdk::__query_builder::Col>, + pub revoked_at: __sdk::__query_builder::Col>, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for PaymentApiKey { + type Cols = PaymentApiKeyCols; + fn cols(table_name: &'static str) -> Self::Cols { + PaymentApiKeyCols { + key_id: __sdk::__query_builder::Col::new(table_name, "key_id"), + app_id: __sdk::__query_builder::Col::new(table_name, "app_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + name: __sdk::__query_builder::Col::new(table_name, "name"), + key_prefix: __sdk::__query_builder::Col::new(table_name, "key_prefix"), + key_hash: __sdk::__query_builder::Col::new(table_name, "key_hash"), + scopes_json: __sdk::__query_builder::Col::new(table_name, "scopes_json"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + last_used_at: __sdk::__query_builder::Col::new(table_name, "last_used_at"), + revoked_at: __sdk::__query_builder::Col::new(table_name, "revoked_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `PaymentApiKey`. +/// +/// Provides typed access to indexed columns for query building. +pub struct PaymentApiKeyIxCols { + pub app_id: __sdk::__query_builder::IxCol, + pub key_hash: __sdk::__query_builder::IxCol, + pub key_id: __sdk::__query_builder::IxCol, + pub owner_user_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for PaymentApiKey { + type IxCols = PaymentApiKeyIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + PaymentApiKeyIxCols { + app_id: __sdk::__query_builder::IxCol::new(table_name, "app_id"), + key_hash: __sdk::__query_builder::IxCol::new(table_name, "key_hash"), + key_id: __sdk::__query_builder::IxCol::new(table_name, "key_id"), + owner_user_id: __sdk::__query_builder::IxCol::new(table_name, "owner_user_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for PaymentApiKey {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_app_create_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_app_create_input_type.rs new file mode 100644 index 000000000..ffbc34df5 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_app_create_input_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentAppCreateInput { + pub app_id: String, + pub owner_user_id: String, + pub name: String, + pub callback_url: Option, + pub now_micros: i64, +} + +impl __sdk::InModule for PaymentAppCreateInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_app_list_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_app_list_input_type.rs new file mode 100644 index 000000000..641a4020d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_app_list_input_type.rs @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentAppListInput { + pub owner_user_id: String, +} + +impl __sdk::InModule for PaymentAppListInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_app_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_app_procedure_result_type.rs new file mode 100644 index 000000000..2bbb8ef04 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_app_procedure_result_type.rs @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::payment_api_key_snapshot_type::PaymentApiKeySnapshot; +use super::payment_app_snapshot_type::PaymentAppSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentAppProcedureResult { + pub ok: bool, + pub app: Option, + pub key: Option, + pub apps: Vec, + pub keys: Vec, + pub error_message: Option, +} + +impl __sdk::InModule for PaymentAppProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_app_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_app_snapshot_type.rs new file mode 100644 index 000000000..a96a682da --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_app_snapshot_type.rs @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentAppSnapshot { + pub app_id: String, + pub owner_user_id: String, + pub name: String, + pub callback_url: Option, + pub enabled: bool, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for PaymentAppSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_app_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_app_table.rs new file mode 100644 index 000000000..74fea42c5 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_app_table.rs @@ -0,0 +1,228 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::payment_app_type::PaymentApp; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `payment_app`. +/// +/// Obtain a handle from the [`PaymentAppTableAccess::payment_app`] method on [`super::RemoteTables`], +/// like `ctx.db.payment_app()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.payment_app().on_insert(...)`. +pub struct PaymentAppTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +/// Lifetime-aware accessor marker for the table `payment_app`. +pub struct PaymentAppTableAccessor; + +impl __sdk::TableAccessor for PaymentAppTableAccessor { + type Row = PaymentApp; + type Handle<'db> = PaymentAppTableHandle<'db>; + + fn get<'db>(db: &'db super::RemoteTables) -> Self::Handle<'db> { + db.payment_app() + } +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `payment_app`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait PaymentAppTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`PaymentAppTableHandle`], which mediates access to the table `payment_app`. + fn payment_app(&self) -> PaymentAppTableHandle<'_>; +} + +impl PaymentAppTableAccess for super::RemoteTables { + fn payment_app(&self) -> PaymentAppTableHandle<'_> { + PaymentAppTableHandle { + imp: self.imp.get_table::("payment_app"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct PaymentAppInsertCallbackId(__sdk::CallbackId); +pub struct PaymentAppDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableLike for PaymentAppTableHandle<'ctx> { + type Row = PaymentApp; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } +} + +impl<'ctx> __sdk::Table for PaymentAppTableHandle<'ctx> { + type Row = PaymentApp; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = PaymentAppInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PaymentAppInsertCallbackId { + PaymentAppInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: PaymentAppInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = PaymentAppDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PaymentAppDeleteCallbackId { + PaymentAppDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: PaymentAppDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +impl<'ctx> __sdk::WithInsert for PaymentAppTableHandle<'ctx> { + type InsertCallbackId = PaymentAppInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PaymentAppInsertCallbackId { + PaymentAppInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: PaymentAppInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } +} + +impl<'ctx> __sdk::WithDelete for PaymentAppTableHandle<'ctx> { + type DeleteCallbackId = PaymentAppDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PaymentAppDeleteCallbackId { + PaymentAppDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: PaymentAppDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct PaymentAppUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for PaymentAppTableHandle<'ctx> { + type UpdateCallbackId = PaymentAppUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> PaymentAppUpdateCallbackId { + PaymentAppUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: PaymentAppUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +impl<'ctx> __sdk::WithUpdate for PaymentAppTableHandle<'ctx> { + type UpdateCallbackId = PaymentAppUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> PaymentAppUpdateCallbackId { + PaymentAppUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: PaymentAppUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `app_id` unique index on the table `payment_app`, +/// which allows point queries on the field of the same name +/// via the [`PaymentAppAppIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.payment_app().app_id().find(...)`. +pub struct PaymentAppAppIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> PaymentAppTableHandle<'ctx> { + /// Get a handle on the `app_id` unique index on the table `payment_app`. + pub fn app_id(&self) -> PaymentAppAppIdUnique<'ctx> { + PaymentAppAppIdUnique { + imp: self.imp.get_unique_constraint::("app_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> PaymentAppAppIdUnique<'ctx> { + /// Find the subscribed row whose `app_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("payment_app"); + _table.add_unique_constraint::("app_id", |row| &row.app_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `PaymentApp`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait payment_appQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `PaymentApp`. + fn payment_app(&self) -> __sdk::__query_builder::Table; +} + +impl payment_appQueryTableAccess for __sdk::QueryTableAccessor { + fn payment_app(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("payment_app") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_app_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_app_type.rs new file mode 100644 index 000000000..f34b81a07 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_app_type.rs @@ -0,0 +1,69 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentApp { + pub app_id: String, + pub owner_user_id: String, + pub name: String, + pub callback_url: Option, + pub enabled: bool, + pub created_at: __sdk::Timestamp, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for PaymentApp { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `PaymentApp`. +/// +/// Provides typed access to columns for query building. +pub struct PaymentAppCols { + pub app_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub name: __sdk::__query_builder::Col, + pub callback_url: __sdk::__query_builder::Col>, + pub enabled: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for PaymentApp { + type Cols = PaymentAppCols; + fn cols(table_name: &'static str) -> Self::Cols { + PaymentAppCols { + app_id: __sdk::__query_builder::Col::new(table_name, "app_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + name: __sdk::__query_builder::Col::new(table_name, "name"), + callback_url: __sdk::__query_builder::Col::new(table_name, "callback_url"), + enabled: __sdk::__query_builder::Col::new(table_name, "enabled"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `PaymentApp`. +/// +/// Provides typed access to indexed columns for query building. +pub struct PaymentAppIxCols { + pub app_id: __sdk::__query_builder::IxCol, + pub owner_user_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for PaymentApp { + type IxCols = PaymentAppIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + PaymentAppIxCols { + app_id: __sdk::__query_builder::IxCol::new(table_name, "app_id"), + owner_user_id: __sdk::__query_builder::IxCol::new(table_name, "owner_user_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for PaymentApp {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_app_update_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_app_update_input_type.rs new file mode 100644 index 000000000..f83e5f760 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_app_update_input_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentAppUpdateInput { + pub app_id: String, + pub owner_user_id: String, + pub enabled: bool, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for PaymentAppUpdateInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_order_checkout_get_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_checkout_get_input_type.rs new file mode 100644 index 000000000..66b87f70e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_checkout_get_input_type.rs @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentOrderCheckoutGetInput { + pub checkout_token: String, +} + +impl __sdk::InModule for PaymentOrderCheckoutGetInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_order_create_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_create_input_type.rs new file mode 100644 index 000000000..a9952746d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_create_input_type.rs @@ -0,0 +1,31 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::payment_provider_type::PaymentProvider; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +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, + pub callback_signing_key_hash: Option, +} + +impl __sdk::InModule for PaymentOrderCreateInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_order_get_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_get_input_type.rs new file mode 100644 index 000000000..203c66ccb --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_get_input_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentOrderGetInput { + pub order_id: String, + pub owner_user_id: Option, +} + +impl __sdk::InModule for PaymentOrderGetInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_order_list_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_list_input_type.rs new file mode 100644 index 000000000..2be50fede --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_list_input_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentOrderListInput { + pub app_id: Option, + pub owner_user_id: String, + pub limit: u32, +} + +impl __sdk::InModule for PaymentOrderListInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_order_mark_paid_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_mark_paid_input_type.rs new file mode 100644 index 000000000..837ac27f6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_mark_paid_input_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentOrderMarkPaidInput { + pub order_id: String, + pub provider_trade_no: String, + pub amount_cents: u64, + pub paid_at_micros: i64, +} + +impl __sdk::InModule for PaymentOrderMarkPaidInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_order_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_procedure_result_type.rs new file mode 100644 index 000000000..22c3f85af --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_procedure_result_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::payment_order_snapshot_type::PaymentOrderSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentOrderProcedureResult { + pub ok: bool, + pub order: Option, + pub orders: Vec, + pub error_message: Option, +} + +impl __sdk::InModule for PaymentOrderProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_order_provider_attempt_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_provider_attempt_input_type.rs new file mode 100644 index 000000000..e2b0002bb --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_provider_attempt_input_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentOrderProviderAttemptInput { + pub order_id: String, + pub attempted_at_micros: i64, +} + +impl __sdk::InModule for PaymentOrderProviderAttemptInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_order_provider_code_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_provider_code_input_type.rs new file mode 100644 index 000000000..b02d63297 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_provider_code_input_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentOrderProviderCodeInput { + pub order_id: String, + pub provider_code_url: String, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for PaymentOrderProviderCodeInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_order_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_snapshot_type.rs new file mode 100644 index 000000000..f2e351627 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_snapshot_type.rs @@ -0,0 +1,38 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::payment_order_status_type::PaymentOrderStatus; +use super::payment_provider_type::PaymentProvider; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +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, + pub provider_code_url: Option, + pub callback_url: Option, + pub callback_signing_key_hash: Option, + pub provider_attempted_at_micros: Option, + pub checkout_token: String, + pub status: PaymentOrderStatus, + pub created_at_micros: i64, + pub expires_at_micros: i64, + pub paid_at_micros: Option, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for PaymentOrderSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_order_status_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_status_type.rs new file mode 100644 index 000000000..046f3370c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_status_type.rs @@ -0,0 +1,26 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum PaymentOrderStatus { + Pending, + + Paying, + + Paid, + + Expired, + + Closed, + + Refunded, +} + +impl __sdk::InModule for PaymentOrderStatus { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_order_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_table.rs new file mode 100644 index 000000000..58f65872a --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_table.rs @@ -0,0 +1,328 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::payment_order_status_type::PaymentOrderStatus; +use super::payment_order_type::PaymentOrder; +use super::payment_provider_type::PaymentProvider; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `payment_order`. +/// +/// Obtain a handle from the [`PaymentOrderTableAccess::payment_order`] method on [`super::RemoteTables`], +/// like `ctx.db.payment_order()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.payment_order().on_insert(...)`. +pub struct PaymentOrderTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +/// Lifetime-aware accessor marker for the table `payment_order`. +pub struct PaymentOrderTableAccessor; + +impl __sdk::TableAccessor for PaymentOrderTableAccessor { + type Row = PaymentOrder; + type Handle<'db> = PaymentOrderTableHandle<'db>; + + fn get<'db>(db: &'db super::RemoteTables) -> Self::Handle<'db> { + db.payment_order() + } +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `payment_order`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait PaymentOrderTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`PaymentOrderTableHandle`], which mediates access to the table `payment_order`. + fn payment_order(&self) -> PaymentOrderTableHandle<'_>; +} + +impl PaymentOrderTableAccess for super::RemoteTables { + fn payment_order(&self) -> PaymentOrderTableHandle<'_> { + PaymentOrderTableHandle { + imp: self.imp.get_table::("payment_order"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct PaymentOrderInsertCallbackId(__sdk::CallbackId); +pub struct PaymentOrderDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableLike for PaymentOrderTableHandle<'ctx> { + type Row = PaymentOrder; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } +} + +impl<'ctx> __sdk::Table for PaymentOrderTableHandle<'ctx> { + type Row = PaymentOrder; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = PaymentOrderInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PaymentOrderInsertCallbackId { + PaymentOrderInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: PaymentOrderInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = PaymentOrderDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PaymentOrderDeleteCallbackId { + PaymentOrderDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: PaymentOrderDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +impl<'ctx> __sdk::WithInsert for PaymentOrderTableHandle<'ctx> { + type InsertCallbackId = PaymentOrderInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PaymentOrderInsertCallbackId { + PaymentOrderInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: PaymentOrderInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } +} + +impl<'ctx> __sdk::WithDelete for PaymentOrderTableHandle<'ctx> { + type DeleteCallbackId = PaymentOrderDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PaymentOrderDeleteCallbackId { + PaymentOrderDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: PaymentOrderDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct PaymentOrderUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for PaymentOrderTableHandle<'ctx> { + type UpdateCallbackId = PaymentOrderUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> PaymentOrderUpdateCallbackId { + PaymentOrderUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: PaymentOrderUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +impl<'ctx> __sdk::WithUpdate for PaymentOrderTableHandle<'ctx> { + type UpdateCallbackId = PaymentOrderUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> PaymentOrderUpdateCallbackId { + PaymentOrderUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: PaymentOrderUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `order_id` unique index on the table `payment_order`, +/// which allows point queries on the field of the same name +/// via the [`PaymentOrderOrderIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.payment_order().order_id().find(...)`. +pub struct PaymentOrderOrderIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> PaymentOrderTableHandle<'ctx> { + /// Get a handle on the `order_id` unique index on the table `payment_order`. + pub fn order_id(&self) -> PaymentOrderOrderIdUnique<'ctx> { + PaymentOrderOrderIdUnique { + imp: self.imp.get_unique_constraint::("order_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> PaymentOrderOrderIdUnique<'ctx> { + /// Find the subscribed row whose `order_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +/// Access to the `idempotency_lookup` unique index on the table `payment_order`, +/// which allows point queries on the field of the same name +/// via the [`PaymentOrderIdempotencyLookupUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.payment_order().idempotency_lookup().find(...)`. +pub struct PaymentOrderIdempotencyLookupUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> PaymentOrderTableHandle<'ctx> { + /// Get a handle on the `idempotency_lookup` unique index on the table `payment_order`. + pub fn idempotency_lookup(&self) -> PaymentOrderIdempotencyLookupUnique<'ctx> { + PaymentOrderIdempotencyLookupUnique { + imp: self + .imp + .get_unique_constraint::("idempotency_lookup"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> PaymentOrderIdempotencyLookupUnique<'ctx> { + /// Find the subscribed row whose `idempotency_lookup` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +/// Access to the `merchant_order_lookup` unique index on the table `payment_order`, +/// which allows point queries on the field of the same name +/// via the [`PaymentOrderMerchantOrderLookupUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.payment_order().merchant_order_lookup().find(...)`. +pub struct PaymentOrderMerchantOrderLookupUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> PaymentOrderTableHandle<'ctx> { + /// Get a handle on the `merchant_order_lookup` unique index on the table `payment_order`. + pub fn merchant_order_lookup(&self) -> PaymentOrderMerchantOrderLookupUnique<'ctx> { + PaymentOrderMerchantOrderLookupUnique { + imp: self + .imp + .get_unique_constraint::("merchant_order_lookup"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> PaymentOrderMerchantOrderLookupUnique<'ctx> { + /// Find the subscribed row whose `merchant_order_lookup` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +/// Access to the `checkout_token` unique index on the table `payment_order`, +/// which allows point queries on the field of the same name +/// via the [`PaymentOrderCheckoutTokenUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.payment_order().checkout_token().find(...)`. +pub struct PaymentOrderCheckoutTokenUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> PaymentOrderTableHandle<'ctx> { + /// Get a handle on the `checkout_token` unique index on the table `payment_order`. + pub fn checkout_token(&self) -> PaymentOrderCheckoutTokenUnique<'ctx> { + PaymentOrderCheckoutTokenUnique { + imp: self.imp.get_unique_constraint::("checkout_token"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> PaymentOrderCheckoutTokenUnique<'ctx> { + /// Find the subscribed row whose `checkout_token` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("payment_order"); + _table.add_unique_constraint::("order_id", |row| &row.order_id); + _table.add_unique_constraint::("idempotency_lookup", |row| &row.idempotency_lookup); + _table + .add_unique_constraint::("merchant_order_lookup", |row| &row.merchant_order_lookup); + _table.add_unique_constraint::("checkout_token", |row| &row.checkout_token); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `PaymentOrder`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait payment_orderQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `PaymentOrder`. + fn payment_order(&self) -> __sdk::__query_builder::Table; +} + +impl payment_orderQueryTableAccess for __sdk::QueryTableAccessor { + fn payment_order(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("payment_order") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_order_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_type.rs new file mode 100644 index 000000000..abb46c6d5 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_type.rs @@ -0,0 +1,142 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::payment_order_status_type::PaymentOrderStatus; +use super::payment_provider_type::PaymentProvider; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentOrder { + pub order_id: String, + pub app_id: String, + pub owner_user_id: String, + pub merchant_order_id: String, + pub idempotency_lookup: String, + pub merchant_order_lookup: String, + pub title: String, + pub items_json: String, + pub amount_cents: u64, + pub currency: String, + pub provider: PaymentProvider, + pub provider_trade_no: Option, + pub checkout_token: String, + pub status: PaymentOrderStatus, + pub created_at: __sdk::Timestamp, + pub expires_at: __sdk::Timestamp, + pub paid_at: Option<__sdk::Timestamp>, + pub updated_at: __sdk::Timestamp, + pub provider_code_url: Option, + pub callback_url: Option, + pub callback_signing_key_hash: Option, + pub provider_attempted_at: Option<__sdk::Timestamp>, +} + +impl __sdk::InModule for PaymentOrder { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `PaymentOrder`. +/// +/// Provides typed access to columns for query building. +pub struct PaymentOrderCols { + pub order_id: __sdk::__query_builder::Col, + pub app_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub merchant_order_id: __sdk::__query_builder::Col, + pub idempotency_lookup: __sdk::__query_builder::Col, + pub merchant_order_lookup: __sdk::__query_builder::Col, + pub title: __sdk::__query_builder::Col, + pub items_json: __sdk::__query_builder::Col, + pub amount_cents: __sdk::__query_builder::Col, + pub currency: __sdk::__query_builder::Col, + pub provider: __sdk::__query_builder::Col, + pub provider_trade_no: __sdk::__query_builder::Col>, + pub checkout_token: __sdk::__query_builder::Col, + pub status: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, + pub expires_at: __sdk::__query_builder::Col, + pub paid_at: __sdk::__query_builder::Col>, + pub updated_at: __sdk::__query_builder::Col, + pub provider_code_url: __sdk::__query_builder::Col>, + pub callback_url: __sdk::__query_builder::Col>, + pub callback_signing_key_hash: __sdk::__query_builder::Col>, + pub provider_attempted_at: __sdk::__query_builder::Col>, +} + +impl __sdk::__query_builder::HasCols for PaymentOrder { + type Cols = PaymentOrderCols; + fn cols(table_name: &'static str) -> Self::Cols { + PaymentOrderCols { + order_id: __sdk::__query_builder::Col::new(table_name, "order_id"), + app_id: __sdk::__query_builder::Col::new(table_name, "app_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + merchant_order_id: __sdk::__query_builder::Col::new(table_name, "merchant_order_id"), + idempotency_lookup: __sdk::__query_builder::Col::new(table_name, "idempotency_lookup"), + merchant_order_lookup: __sdk::__query_builder::Col::new( + table_name, + "merchant_order_lookup", + ), + title: __sdk::__query_builder::Col::new(table_name, "title"), + items_json: __sdk::__query_builder::Col::new(table_name, "items_json"), + amount_cents: __sdk::__query_builder::Col::new(table_name, "amount_cents"), + currency: __sdk::__query_builder::Col::new(table_name, "currency"), + provider: __sdk::__query_builder::Col::new(table_name, "provider"), + provider_trade_no: __sdk::__query_builder::Col::new(table_name, "provider_trade_no"), + checkout_token: __sdk::__query_builder::Col::new(table_name, "checkout_token"), + status: __sdk::__query_builder::Col::new(table_name, "status"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + expires_at: __sdk::__query_builder::Col::new(table_name, "expires_at"), + paid_at: __sdk::__query_builder::Col::new(table_name, "paid_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + provider_code_url: __sdk::__query_builder::Col::new(table_name, "provider_code_url"), + callback_url: __sdk::__query_builder::Col::new(table_name, "callback_url"), + callback_signing_key_hash: __sdk::__query_builder::Col::new( + table_name, + "callback_signing_key_hash", + ), + provider_attempted_at: __sdk::__query_builder::Col::new( + table_name, + "provider_attempted_at", + ), + } + } +} + +/// Indexed column accessor struct for the table `PaymentOrder`. +/// +/// Provides typed access to indexed columns for query building. +pub struct PaymentOrderIxCols { + pub app_id: __sdk::__query_builder::IxCol, + pub checkout_token: __sdk::__query_builder::IxCol, + pub idempotency_lookup: __sdk::__query_builder::IxCol, + pub merchant_order_lookup: __sdk::__query_builder::IxCol, + pub order_id: __sdk::__query_builder::IxCol, + pub owner_user_id: __sdk::__query_builder::IxCol, + pub status: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for PaymentOrder { + type IxCols = PaymentOrderIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + PaymentOrderIxCols { + app_id: __sdk::__query_builder::IxCol::new(table_name, "app_id"), + checkout_token: __sdk::__query_builder::IxCol::new(table_name, "checkout_token"), + idempotency_lookup: __sdk::__query_builder::IxCol::new( + table_name, + "idempotency_lookup", + ), + merchant_order_lookup: __sdk::__query_builder::IxCol::new( + table_name, + "merchant_order_lookup", + ), + order_id: __sdk::__query_builder::IxCol::new(table_name, "order_id"), + owner_user_id: __sdk::__query_builder::IxCol::new(table_name, "owner_user_id"), + status: __sdk::__query_builder::IxCol::new(table_name, "status"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for PaymentOrder {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_provider_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_provider_type.rs new file mode 100644 index 000000000..c006a51ca --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_provider_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum PaymentProvider { + WechatNative, +} + +impl __sdk::InModule for PaymentProvider { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_webhook_claim_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_webhook_claim_input_type.rs new file mode 100644 index 000000000..3fdb121b4 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_webhook_claim_input_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentWebhookClaimInput { + pub worker_id: String, + pub limit: u32, + pub now_micros: i64, + pub lease_expires_at_micros: i64, +} + +impl __sdk::InModule for PaymentWebhookClaimInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_webhook_complete_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_webhook_complete_input_type.rs new file mode 100644 index 000000000..ecbdd90dd --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_webhook_complete_input_type.rs @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentWebhookCompleteInput { + pub delivery_id: String, + pub worker_id: String, + pub success: bool, + pub error_message: Option, + pub now_micros: i64, + pub next_available_at_micros: i64, + pub attempt_count: u32, +} + +impl __sdk::InModule for PaymentWebhookCompleteInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_webhook_delivery_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_webhook_delivery_snapshot_type.rs new file mode 100644 index 000000000..22eca80db --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_webhook_delivery_snapshot_type.rs @@ -0,0 +1,29 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +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, + pub lease_expires_at_micros: Option, + pub last_error_message: Option, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for PaymentWebhookDeliverySnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_webhook_delivery_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_webhook_delivery_table.rs new file mode 100644 index 000000000..8cc76e053 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_webhook_delivery_table.rs @@ -0,0 +1,231 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::payment_webhook_delivery_type::PaymentWebhookDelivery; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `payment_webhook_delivery`. +/// +/// Obtain a handle from the [`PaymentWebhookDeliveryTableAccess::payment_webhook_delivery`] method on [`super::RemoteTables`], +/// like `ctx.db.payment_webhook_delivery()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.payment_webhook_delivery().on_insert(...)`. +pub struct PaymentWebhookDeliveryTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +/// Lifetime-aware accessor marker for the table `payment_webhook_delivery`. +pub struct PaymentWebhookDeliveryTableAccessor; + +impl __sdk::TableAccessor for PaymentWebhookDeliveryTableAccessor { + type Row = PaymentWebhookDelivery; + type Handle<'db> = PaymentWebhookDeliveryTableHandle<'db>; + + fn get<'db>(db: &'db super::RemoteTables) -> Self::Handle<'db> { + db.payment_webhook_delivery() + } +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `payment_webhook_delivery`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait PaymentWebhookDeliveryTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`PaymentWebhookDeliveryTableHandle`], which mediates access to the table `payment_webhook_delivery`. + fn payment_webhook_delivery(&self) -> PaymentWebhookDeliveryTableHandle<'_>; +} + +impl PaymentWebhookDeliveryTableAccess for super::RemoteTables { + fn payment_webhook_delivery(&self) -> PaymentWebhookDeliveryTableHandle<'_> { + PaymentWebhookDeliveryTableHandle { + imp: self + .imp + .get_table::("payment_webhook_delivery"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct PaymentWebhookDeliveryInsertCallbackId(__sdk::CallbackId); +pub struct PaymentWebhookDeliveryDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableLike for PaymentWebhookDeliveryTableHandle<'ctx> { + type Row = PaymentWebhookDelivery; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } +} + +impl<'ctx> __sdk::Table for PaymentWebhookDeliveryTableHandle<'ctx> { + type Row = PaymentWebhookDelivery; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = PaymentWebhookDeliveryInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PaymentWebhookDeliveryInsertCallbackId { + PaymentWebhookDeliveryInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: PaymentWebhookDeliveryInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = PaymentWebhookDeliveryDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PaymentWebhookDeliveryDeleteCallbackId { + PaymentWebhookDeliveryDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: PaymentWebhookDeliveryDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +impl<'ctx> __sdk::WithInsert for PaymentWebhookDeliveryTableHandle<'ctx> { + type InsertCallbackId = PaymentWebhookDeliveryInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PaymentWebhookDeliveryInsertCallbackId { + PaymentWebhookDeliveryInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: PaymentWebhookDeliveryInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } +} + +impl<'ctx> __sdk::WithDelete for PaymentWebhookDeliveryTableHandle<'ctx> { + type DeleteCallbackId = PaymentWebhookDeliveryDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PaymentWebhookDeliveryDeleteCallbackId { + PaymentWebhookDeliveryDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: PaymentWebhookDeliveryDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct PaymentWebhookDeliveryUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for PaymentWebhookDeliveryTableHandle<'ctx> { + type UpdateCallbackId = PaymentWebhookDeliveryUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> PaymentWebhookDeliveryUpdateCallbackId { + PaymentWebhookDeliveryUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: PaymentWebhookDeliveryUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +impl<'ctx> __sdk::WithUpdate for PaymentWebhookDeliveryTableHandle<'ctx> { + type UpdateCallbackId = PaymentWebhookDeliveryUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> PaymentWebhookDeliveryUpdateCallbackId { + PaymentWebhookDeliveryUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: PaymentWebhookDeliveryUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `delivery_id` unique index on the table `payment_webhook_delivery`, +/// which allows point queries on the field of the same name +/// via the [`PaymentWebhookDeliveryDeliveryIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.payment_webhook_delivery().delivery_id().find(...)`. +pub struct PaymentWebhookDeliveryDeliveryIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> PaymentWebhookDeliveryTableHandle<'ctx> { + /// Get a handle on the `delivery_id` unique index on the table `payment_webhook_delivery`. + pub fn delivery_id(&self) -> PaymentWebhookDeliveryDeliveryIdUnique<'ctx> { + PaymentWebhookDeliveryDeliveryIdUnique { + imp: self.imp.get_unique_constraint::("delivery_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> PaymentWebhookDeliveryDeliveryIdUnique<'ctx> { + /// Find the subscribed row whose `delivery_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = + client_cache.get_or_make_table::("payment_webhook_delivery"); + _table.add_unique_constraint::("delivery_id", |row| &row.delivery_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `PaymentWebhookDelivery`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait payment_webhook_deliveryQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `PaymentWebhookDelivery`. + fn payment_webhook_delivery(&self) -> __sdk::__query_builder::Table; +} + +impl payment_webhook_deliveryQueryTableAccess for __sdk::QueryTableAccessor { + fn payment_webhook_delivery(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("payment_webhook_delivery") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_webhook_delivery_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_webhook_delivery_type.rs new file mode 100644 index 000000000..be33d532c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_webhook_delivery_type.rs @@ -0,0 +1,96 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentWebhookDelivery { + 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: __sdk::Timestamp, + pub lease_worker_id: Option, + pub lease_expires_at: Option<__sdk::Timestamp>, + pub last_error_message: Option, + pub created_at: __sdk::Timestamp, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for PaymentWebhookDelivery { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `PaymentWebhookDelivery`. +/// +/// Provides typed access to columns for query building. +pub struct PaymentWebhookDeliveryCols { + pub delivery_id: __sdk::__query_builder::Col, + pub order_id: __sdk::__query_builder::Col, + pub app_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub callback_url: __sdk::__query_builder::Col, + pub payload_json: __sdk::__query_builder::Col, + pub signature: __sdk::__query_builder::Col, + pub status: __sdk::__query_builder::Col, + pub attempt_count: __sdk::__query_builder::Col, + pub available_at: __sdk::__query_builder::Col, + pub lease_worker_id: __sdk::__query_builder::Col>, + pub lease_expires_at: + __sdk::__query_builder::Col>, + pub last_error_message: __sdk::__query_builder::Col>, + pub created_at: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for PaymentWebhookDelivery { + type Cols = PaymentWebhookDeliveryCols; + fn cols(table_name: &'static str) -> Self::Cols { + PaymentWebhookDeliveryCols { + delivery_id: __sdk::__query_builder::Col::new(table_name, "delivery_id"), + order_id: __sdk::__query_builder::Col::new(table_name, "order_id"), + app_id: __sdk::__query_builder::Col::new(table_name, "app_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + callback_url: __sdk::__query_builder::Col::new(table_name, "callback_url"), + payload_json: __sdk::__query_builder::Col::new(table_name, "payload_json"), + signature: __sdk::__query_builder::Col::new(table_name, "signature"), + status: __sdk::__query_builder::Col::new(table_name, "status"), + attempt_count: __sdk::__query_builder::Col::new(table_name, "attempt_count"), + available_at: __sdk::__query_builder::Col::new(table_name, "available_at"), + lease_worker_id: __sdk::__query_builder::Col::new(table_name, "lease_worker_id"), + lease_expires_at: __sdk::__query_builder::Col::new(table_name, "lease_expires_at"), + last_error_message: __sdk::__query_builder::Col::new(table_name, "last_error_message"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `PaymentWebhookDelivery`. +/// +/// Provides typed access to indexed columns for query building. +pub struct PaymentWebhookDeliveryIxCols { + pub delivery_id: __sdk::__query_builder::IxCol, + pub order_id: __sdk::__query_builder::IxCol, + pub owner_user_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for PaymentWebhookDelivery { + type IxCols = PaymentWebhookDeliveryIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + PaymentWebhookDeliveryIxCols { + delivery_id: __sdk::__query_builder::IxCol::new(table_name, "delivery_id"), + order_id: __sdk::__query_builder::IxCol::new(table_name, "order_id"), + owner_user_id: __sdk::__query_builder::IxCol::new(table_name, "owner_user_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for PaymentWebhookDelivery {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_webhook_enqueue_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_webhook_enqueue_input_type.rs new file mode 100644 index 000000000..d44d70835 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_webhook_enqueue_input_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentWebhookEnqueueInput { + pub order_id: String, + pub payload_json: String, + pub signature: String, + pub now_micros: i64, +} + +impl __sdk::InModule for PaymentWebhookEnqueueInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_webhook_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_webhook_procedure_result_type.rs new file mode 100644 index 000000000..0ffbd510e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_webhook_procedure_result_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::payment_webhook_delivery_snapshot_type::PaymentWebhookDeliverySnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentWebhookProcedureResult { + pub ok: bool, + pub delivery: Option, + pub deliveries: Vec, + pub error_message: Option, +} + +impl __sdk::InModule for PaymentWebhookProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/revoke_payment_api_key_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/revoke_payment_api_key_and_return_procedure.rs new file mode 100644 index 000000000..659adfe4c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/revoke_payment_api_key_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::payment_api_key_revoke_input_type::PaymentApiKeyRevokeInput; +use super::payment_app_procedure_result_type::PaymentAppProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct RevokePaymentApiKeyAndReturnArgs { + pub input: PaymentApiKeyRevokeInput, +} + +impl __sdk::InModule for RevokePaymentApiKeyAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `revoke_payment_api_key_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait revoke_payment_api_key_and_return { + fn revoke_payment_api_key_and_return(&self, input: PaymentApiKeyRevokeInput) { + self.revoke_payment_api_key_and_return_then(input, |_, _| {}); + } + + fn revoke_payment_api_key_and_return_then( + &self, + input: PaymentApiKeyRevokeInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl revoke_payment_api_key_and_return for super::RemoteProcedures { + fn revoke_payment_api_key_and_return_then( + &self, + input: PaymentApiKeyRevokeInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PaymentAppProcedureResult>( + "revoke_payment_api_key_and_return", + RevokePaymentApiKeyAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/set_payment_order_provider_code_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/set_payment_order_provider_code_and_return_procedure.rs new file mode 100644 index 000000000..c2ee27aa6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/set_payment_order_provider_code_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::payment_order_procedure_result_type::PaymentOrderProcedureResult; +use super::payment_order_provider_code_input_type::PaymentOrderProviderCodeInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct SetPaymentOrderProviderCodeAndReturnArgs { + pub input: PaymentOrderProviderCodeInput, +} + +impl __sdk::InModule for SetPaymentOrderProviderCodeAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `set_payment_order_provider_code_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait set_payment_order_provider_code_and_return { + fn set_payment_order_provider_code_and_return(&self, input: PaymentOrderProviderCodeInput) { + self.set_payment_order_provider_code_and_return_then(input, |_, _| {}); + } + + fn set_payment_order_provider_code_and_return_then( + &self, + input: PaymentOrderProviderCodeInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl set_payment_order_provider_code_and_return for super::RemoteProcedures { + fn set_payment_order_provider_code_and_return_then( + &self, + input: PaymentOrderProviderCodeInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PaymentOrderProcedureResult>( + "set_payment_order_provider_code_and_return", + SetPaymentOrderProviderCodeAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/update_payment_app_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/update_payment_app_and_return_procedure.rs new file mode 100644 index 000000000..c87253b43 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/update_payment_app_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::payment_app_procedure_result_type::PaymentAppProcedureResult; +use super::payment_app_update_input_type::PaymentAppUpdateInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct UpdatePaymentAppAndReturnArgs { + pub input: PaymentAppUpdateInput, +} + +impl __sdk::InModule for UpdatePaymentAppAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `update_payment_app_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait update_payment_app_and_return { + fn update_payment_app_and_return(&self, input: PaymentAppUpdateInput) { + self.update_payment_app_and_return_then(input, |_, _| {}); + } + + fn update_payment_app_and_return_then( + &self, + input: PaymentAppUpdateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl update_payment_app_and_return for super::RemoteProcedures { + fn update_payment_app_and_return_then( + &self, + input: PaymentAppUpdateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PaymentAppProcedureResult>( + "update_payment_app_and_return", + UpdatePaymentAppAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-module/src/active.rs b/server-rs/crates/spacetime-module/src/active.rs index de0a60919..12d2789e2 100644 --- a/server-rs/crates/spacetime-module/src/active.rs +++ b/server-rs/crates/spacetime-module/src/active.rs @@ -29,6 +29,7 @@ mod external_generation; mod game_distribution; #[path = "llm_router_account.rs"] mod llm_router_account_storage; +mod payment_storage; pub use agc_models::*; mod migration; mod runtime; @@ -46,6 +47,7 @@ pub use external_generation::*; pub use game_distribution::*; pub use llm_router_account_storage::*; pub use migration::*; +pub use payment_storage::*; pub use runtime::*; // Host-side unit tests need to link the module crate as a normal test binary. diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index ba1738869..f942967bb 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -204,6 +204,10 @@ macro_rules! migration_tables { asset_entity_binding, asset_event, external_api_key, + payment_app, + payment_api_key, + payment_order, + payment_webhook_delivery, // 游戏分发:游戏身份、不可变发行版本和幂等收据都属于业务事实, // 随迁移导出/导入;私有 ZIP 与展开内容保存在对象存储,不进入这两张表。 // 资料随版本冻结:版本表末尾追加 metadata_json,游戏表末尾追加 diff --git a/server-rs/crates/spacetime-module/src/payment_storage.rs b/server-rs/crates/spacetime-module/src/payment_storage.rs new file mode 100644 index 000000000..375c1c641 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/payment_storage.rs @@ -0,0 +1,1199 @@ +use crate::*; + +#[spacetimedb::table( + accessor = payment_app, + index(accessor = by_payment_app_owner_user_id, btree(columns = [owner_user_id])) +)] +#[derive(Clone)] +pub struct PaymentApp { + #[primary_key] + pub app_id: String, + pub owner_user_id: String, + pub name: String, + pub callback_url: Option, + pub enabled: bool, + pub created_at: Timestamp, + pub updated_at: Timestamp, +} + +#[spacetimedb::table( + accessor = payment_api_key, + index(accessor = by_payment_api_key_app_id, btree(columns = [app_id])), + index(accessor = by_payment_api_key_owner_user_id, btree(columns = [owner_user_id])) +)] +pub struct PaymentApiKey { + #[primary_key] + pub key_id: String, + pub app_id: String, + pub owner_user_id: String, + pub name: String, + pub key_prefix: String, + #[unique] + pub key_hash: String, + pub scopes_json: String, + pub created_at: Timestamp, + pub last_used_at: Option, + pub revoked_at: Option, + pub updated_at: Timestamp, +} + +#[spacetimedb::table( + accessor = payment_order, + index(accessor = by_payment_order_app_id, btree(columns = [app_id])), + index(accessor = by_payment_order_owner_user_id, btree(columns = [owner_user_id])), + index(accessor = by_payment_order_status, btree(columns = [status])) +)] +#[derive(Clone)] +pub struct PaymentOrder { + #[primary_key] + pub order_id: String, + pub app_id: String, + pub owner_user_id: String, + pub merchant_order_id: String, + #[unique] + pub idempotency_lookup: String, + #[unique] + pub merchant_order_lookup: String, + pub title: String, + pub items_json: String, + pub amount_cents: u64, + pub currency: String, + pub provider: PaymentProvider, + pub provider_trade_no: Option, + #[unique] + pub checkout_token: String, + pub status: PaymentOrderStatus, + pub created_at: Timestamp, + pub expires_at: Timestamp, + pub paid_at: Option, + pub updated_at: Timestamp, + pub provider_code_url: Option, + pub callback_url: Option, + pub callback_signing_key_hash: Option, + #[default(None::)] + pub provider_attempted_at: Option, +} + +#[spacetimedb::table( + accessor = payment_webhook_delivery, + index(accessor = by_payment_webhook_delivery_status_available, btree(columns = [status, available_at])), + index(accessor = by_payment_webhook_delivery_owner_user_id, btree(columns = [owner_user_id])), + index(accessor = by_payment_webhook_delivery_order_id, btree(columns = [order_id])) +)] +#[derive(Clone)] +pub struct PaymentWebhookDelivery { + #[primary_key] + 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: Timestamp, + pub lease_worker_id: Option, + pub lease_expires_at: Option, + pub last_error_message: Option, + pub created_at: Timestamp, + pub updated_at: Timestamp, +} + +const PAYMENT_WEBHOOK_STATUS_PENDING: &str = "pending"; +const PAYMENT_WEBHOOK_STATUS_PROCESSING: &str = "processing"; +const PAYMENT_WEBHOOK_STATUS_DELIVERED: &str = "delivered"; +const PAYMENT_WEBHOOK_STATUS_FAILED: &str = "failed"; + +#[spacetimedb::procedure] +pub fn create_payment_app_and_return( + ctx: &mut ProcedureContext, + input: PaymentAppCreateInput, +) -> PaymentAppProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::require_editor_generation_runtime_service_identity(tx, caller)?; + create_payment_app(tx, input.clone()) + }) { + Ok(app) => PaymentAppProcedureResult { + ok: true, + app: Some(app), + key: None, + apps: Vec::new(), + keys: Vec::new(), + error_message: None, + }, + Err(error_message) => payment_app_error(error_message), + } +} + +#[spacetimedb::procedure] +pub fn list_payment_apps_and_return( + ctx: &mut ProcedureContext, + input: PaymentAppListInput, +) -> PaymentAppProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::require_editor_generation_runtime_service_identity(tx, caller)?; + list_payment_apps(tx, input.clone()) + }) { + Ok(apps) => PaymentAppProcedureResult { + ok: true, + app: None, + key: None, + apps, + keys: Vec::new(), + error_message: None, + }, + Err(error_message) => payment_app_error(error_message), + } +} + +#[spacetimedb::procedure] +pub fn update_payment_app_and_return( + ctx: &mut ProcedureContext, + input: PaymentAppUpdateInput, +) -> PaymentAppProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::require_editor_generation_runtime_service_identity(tx, caller)?; + update_payment_app(tx, input.clone()) + }) { + Ok(app) => PaymentAppProcedureResult { + ok: true, + app: Some(app), + key: None, + apps: Vec::new(), + keys: Vec::new(), + error_message: None, + }, + Err(error_message) => payment_app_error(error_message), + } +} + +#[spacetimedb::procedure] +pub fn list_payment_api_keys_and_return( + ctx: &mut ProcedureContext, + input: PaymentApiKeyListInput, +) -> PaymentAppProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::require_editor_generation_runtime_service_identity(tx, caller)?; + list_payment_api_keys(tx, input.clone()) + }) { + Ok(keys) => PaymentAppProcedureResult { + ok: true, + app: None, + key: None, + apps: Vec::new(), + keys, + error_message: None, + }, + Err(error_message) => payment_app_error(error_message), + } +} + +#[spacetimedb::procedure] +pub fn create_payment_api_key_and_return( + ctx: &mut ProcedureContext, + input: PaymentApiKeyCreateInput, +) -> PaymentAppProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::require_editor_generation_runtime_service_identity(tx, caller)?; + create_payment_api_key(tx, input.clone()) + }) { + Ok(key) => PaymentAppProcedureResult { + ok: true, + app: None, + key: Some(key), + apps: Vec::new(), + keys: Vec::new(), + error_message: None, + }, + Err(error_message) => payment_app_error(error_message), + } +} + +#[spacetimedb::procedure] +pub fn revoke_payment_api_key_and_return( + ctx: &mut ProcedureContext, + input: PaymentApiKeyRevokeInput, +) -> PaymentAppProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::require_editor_generation_runtime_service_identity(tx, caller)?; + revoke_payment_api_key(tx, input.clone()) + }) { + Ok(key) => PaymentAppProcedureResult { + ok: true, + app: None, + key: Some(key), + apps: Vec::new(), + keys: Vec::new(), + error_message: None, + }, + Err(error_message) => payment_app_error(error_message), + } +} + +#[spacetimedb::procedure] +pub fn authenticate_payment_api_key_and_return( + ctx: &mut ProcedureContext, + input: PaymentApiKeyAuthenticateInput, +) -> PaymentAppProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::require_editor_generation_runtime_service_identity(tx, caller)?; + authenticate_payment_api_key(tx, input.clone()) + }) { + Ok((app, key)) => PaymentAppProcedureResult { + ok: true, + app: Some(app), + key: Some(key), + apps: Vec::new(), + keys: Vec::new(), + error_message: None, + }, + Err(error_message) => payment_app_error(error_message), + } +} + +#[spacetimedb::procedure] +pub fn create_payment_order_and_return( + ctx: &mut ProcedureContext, + input: PaymentOrderCreateInput, +) -> PaymentOrderProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::require_editor_generation_runtime_service_identity(tx, caller)?; + create_payment_order(tx, input.clone()) + }) { + Ok(order) => payment_order_single_ok(order), + Err(error_message) => payment_order_error(error_message), + } +} + +#[spacetimedb::procedure] +pub fn get_payment_order_and_return( + ctx: &mut ProcedureContext, + input: PaymentOrderGetInput, +) -> PaymentOrderProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::require_editor_generation_runtime_service_identity(tx, caller)?; + get_payment_order(tx, input.clone()) + }) { + Ok(order) => payment_order_single_ok(order), + Err(error_message) => payment_order_error(error_message), + } +} + +#[spacetimedb::procedure] +pub fn get_payment_order_by_checkout_token_and_return( + ctx: &mut ProcedureContext, + input: PaymentOrderCheckoutGetInput, +) -> PaymentOrderProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::require_editor_generation_runtime_service_identity(tx, caller)?; + get_payment_order_by_checkout_token(tx, input.clone()) + }) { + Ok(order) => payment_order_single_ok(order), + Err(error_message) => payment_order_error(error_message), + } +} + +#[spacetimedb::procedure] +pub fn list_payment_orders_and_return( + ctx: &mut ProcedureContext, + input: PaymentOrderListInput, +) -> PaymentOrderProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::require_editor_generation_runtime_service_identity(tx, caller)?; + list_payment_orders(tx, input.clone()) + }) { + Ok(orders) => PaymentOrderProcedureResult { + ok: true, + order: None, + orders, + error_message: None, + }, + Err(error_message) => payment_order_error(error_message), + } +} + +#[spacetimedb::procedure] +pub fn mark_payment_order_paid_and_return( + ctx: &mut ProcedureContext, + input: PaymentOrderMarkPaidInput, +) -> PaymentOrderProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::require_editor_generation_runtime_service_identity(tx, caller)?; + mark_payment_order_paid(tx, input.clone()) + }) { + Ok(order) => payment_order_single_ok(order), + Err(error_message) => payment_order_error(error_message), + } +} + +#[spacetimedb::procedure] +pub fn set_payment_order_provider_code_and_return( + ctx: &mut ProcedureContext, + input: PaymentOrderProviderCodeInput, +) -> PaymentOrderProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::require_editor_generation_runtime_service_identity(tx, caller)?; + set_payment_order_provider_code(tx, input.clone()) + }) { + Ok(order) => payment_order_single_ok(order), + Err(error_message) => payment_order_error(error_message), + } +} + +#[spacetimedb::procedure] +pub fn mark_payment_order_provider_attempted_and_return( + ctx: &mut ProcedureContext, + input: PaymentOrderProviderAttemptInput, +) -> PaymentOrderProcedureResult { + match ctx.try_with_tx(|tx| mark_payment_order_provider_attempted(tx, input.clone())) { + Ok(order) => payment_order_single_ok(order), + Err(error_message) => payment_order_error(error_message), + } +} + +#[spacetimedb::procedure] +pub fn enqueue_payment_webhook_and_return( + ctx: &mut ProcedureContext, + input: PaymentWebhookEnqueueInput, +) -> PaymentWebhookProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::require_editor_generation_runtime_service_identity(tx, caller)?; + enqueue_payment_webhook(tx, input.clone()) + }) { + Ok(delivery) => PaymentWebhookProcedureResult { + ok: true, + delivery, + deliveries: Vec::new(), + error_message: None, + }, + Err(error_message) => payment_webhook_error(error_message), + } +} + +#[spacetimedb::procedure] +pub fn claim_payment_webhooks_and_return( + ctx: &mut ProcedureContext, + input: PaymentWebhookClaimInput, +) -> PaymentWebhookProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::require_editor_generation_runtime_service_identity(tx, caller)?; + claim_payment_webhooks(tx, input.clone()) + }) { + Ok(deliveries) => PaymentWebhookProcedureResult { + ok: true, + delivery: None, + deliveries, + error_message: None, + }, + Err(error_message) => payment_webhook_error(error_message), + } +} + +#[spacetimedb::procedure] +pub fn complete_payment_webhook_and_return( + ctx: &mut ProcedureContext, + input: PaymentWebhookCompleteInput, +) -> PaymentWebhookProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::require_editor_generation_runtime_service_identity(tx, caller)?; + complete_payment_webhook(tx, input.clone()) + }) { + Ok(delivery) => PaymentWebhookProcedureResult { + ok: true, + delivery: Some(delivery), + deliveries: Vec::new(), + error_message: None, + }, + Err(error_message) => payment_webhook_error(error_message), + } +} + +fn create_payment_app( + ctx: &ReducerContext, + input: PaymentAppCreateInput, +) -> Result { + let input = build_payment_app_create_input( + input.app_id, + input.owner_user_id, + input.name, + input.callback_url, + input.now_micros, + )?; + if ctx.db.payment_app().app_id().find(&input.app_id).is_some() { + return Err("支付应用 ID 已存在".to_string()); + } + let now = Timestamp::from_micros_since_unix_epoch(input.now_micros); + ctx.db.payment_app().insert(PaymentApp { + app_id: input.app_id.clone(), + owner_user_id: input.owner_user_id, + name: input.name, + callback_url: input.callback_url, + enabled: true, + created_at: now, + updated_at: now, + }); + ctx.db + .payment_app() + .app_id() + .find(&input.app_id) + .map(|row| payment_app_snapshot(&row)) + .ok_or_else(|| "支付应用创建后读取失败".to_string()) +} + +fn list_payment_apps( + ctx: &ReducerContext, + input: PaymentAppListInput, +) -> Result, String> { + let owner_user_id = required(input.owner_user_id, "payment_app.owner_user_id")?; + let mut apps = ctx + .db + .payment_app() + .by_payment_app_owner_user_id() + .filter(&owner_user_id) + .map(|row| payment_app_snapshot(&row)) + .collect::>(); + apps.sort_by(|left, right| right.created_at_micros.cmp(&left.created_at_micros)); + Ok(apps) +} + +fn update_payment_app( + ctx: &ReducerContext, + input: PaymentAppUpdateInput, +) -> Result { + let app_id = required(input.app_id, "payment_app.app_id")?; + let owner_user_id = required(input.owner_user_id, "payment_app.owner_user_id")?; + let row = ctx + .db + .payment_app() + .app_id() + .find(&app_id) + .ok_or_else(|| "支付应用不存在".to_string())?; + if row.owner_user_id != owner_user_id { + return Err("无权访问支付应用".to_string()); + } + let now = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); + ctx.db.payment_app().app_id().delete(&app_id); + ctx.db.payment_app().insert(PaymentApp { + enabled: input.enabled, + updated_at: now, + ..row + }); + ctx.db + .payment_app() + .app_id() + .find(&app_id) + .map(|row| payment_app_snapshot(&row)) + .ok_or_else(|| "支付应用更新后读取失败".to_string()) +} + +fn list_payment_api_keys( + ctx: &ReducerContext, + input: PaymentApiKeyListInput, +) -> Result, String> { + let owner_user_id = required(input.owner_user_id, "payment_api_key.owner_user_id")?; + let mut keys = ctx + .db + .payment_api_key() + .by_payment_api_key_owner_user_id() + .filter(&owner_user_id) + .map(|row| payment_api_key_snapshot(&row)) + .collect::>(); + keys.sort_by(|left, right| right.created_at_micros.cmp(&left.created_at_micros)); + Ok(keys) +} + +fn create_payment_api_key( + ctx: &ReducerContext, + input: PaymentApiKeyCreateInput, +) -> Result { + let input = build_payment_api_key_create_input( + input.key_id, + input.app_id, + input.owner_user_id, + input.name, + input.key_prefix, + input.key_hash, + input.scopes_json, + input.now_micros, + )?; + let app = ctx + .db + .payment_app() + .app_id() + .find(&input.app_id) + .ok_or_else(|| "支付应用不存在".to_string())?; + if app.owner_user_id != input.owner_user_id || !app.enabled { + return Err("支付应用不可用".to_string()); + } + if ctx + .db + .payment_api_key() + .key_hash() + .find(&input.key_hash) + .is_some() + { + return Err("支付 API Key 摘要已存在".to_string()); + } + let now = Timestamp::from_micros_since_unix_epoch(input.now_micros); + ctx.db.payment_api_key().insert(PaymentApiKey { + key_id: input.key_id.clone(), + app_id: input.app_id, + owner_user_id: input.owner_user_id, + name: input.name, + key_prefix: input.key_prefix, + key_hash: input.key_hash, + scopes_json: input.scopes_json, + created_at: now, + last_used_at: None, + revoked_at: None, + updated_at: now, + }); + ctx.db + .payment_api_key() + .key_id() + .find(&input.key_id) + .map(|row| payment_api_key_snapshot(&row)) + .ok_or_else(|| "支付 API Key 创建后读取失败".to_string()) +} + +fn revoke_payment_api_key( + ctx: &ReducerContext, + input: PaymentApiKeyRevokeInput, +) -> Result { + let key_id = required(input.key_id, "payment_api_key.key_id")?; + let owner_user_id = required(input.owner_user_id, "payment_api_key.owner_user_id")?; + let row = ctx + .db + .payment_api_key() + .key_id() + .find(&key_id) + .ok_or_else(|| "支付 API Key 不存在".to_string())?; + if row.owner_user_id != owner_user_id { + return Err("无权访问支付 API Key".to_string()); + } + let now = Timestamp::from_micros_since_unix_epoch(input.revoked_at_micros); + ctx.db.payment_api_key().key_id().delete(&key_id); + ctx.db.payment_api_key().insert(PaymentApiKey { + revoked_at: row.revoked_at.or(Some(now)), + updated_at: now, + ..row + }); + ctx.db + .payment_api_key() + .key_id() + .find(&key_id) + .map(|row| payment_api_key_snapshot(&row)) + .ok_or_else(|| "支付 API Key 撤销后读取失败".to_string()) +} + +fn authenticate_payment_api_key( + ctx: &ReducerContext, + input: PaymentApiKeyAuthenticateInput, +) -> Result<(PaymentAppSnapshot, PaymentApiKeySnapshot), String> { + let key_hash = required(input.key_hash, "payment_api_key.key_hash")?; + let key = ctx + .db + .payment_api_key() + .key_hash() + .find(&key_hash) + .ok_or_else(|| "支付 API Key 不存在或已失效".to_string())?; + if key.revoked_at.is_some() { + return Err("支付 API Key 不存在或已失效".to_string()); + } + let app = ctx + .db + .payment_app() + .app_id() + .find(&key.app_id) + .ok_or_else(|| "支付应用不存在".to_string())?; + if !app.enabled || app.owner_user_id != key.owner_user_id { + return Err("支付应用不可用".to_string()); + } + let now = Timestamp::from_micros_since_unix_epoch(input.used_at_micros); + let key_id = key.key_id.clone(); + ctx.db.payment_api_key().key_id().delete(&key_id); + ctx.db.payment_api_key().insert(PaymentApiKey { + last_used_at: Some(now), + updated_at: now, + ..key + }); + let key = ctx + .db + .payment_api_key() + .key_id() + .find(&key_id) + .ok_or_else(|| "支付 API Key 更新失败".to_string())?; + Ok((payment_app_snapshot(&app), payment_api_key_snapshot(&key))) +} + +fn create_payment_order( + ctx: &ReducerContext, + input: PaymentOrderCreateInput, +) -> Result { + let input = build_payment_order_create_input( + input.order_id, + input.app_id, + input.owner_user_id, + input.merchant_order_id, + input.idempotency_lookup, + input.title, + input.items_json, + input.amount_cents, + input.currency, + input.provider, + input.checkout_token, + input.now_micros, + input.expires_at_micros, + input.callback_url.clone(), + input.callback_signing_key_hash.clone(), + )?; + let app = ctx + .db + .payment_app() + .app_id() + .find(&input.app_id) + .ok_or_else(|| "支付应用不存在".to_string())?; + if !app.enabled || app.owner_user_id != input.owner_user_id { + return Err("支付应用不可用".to_string()); + } + if let Some(existing) = ctx + .db + .payment_order() + .idempotency_lookup() + .find(&input.idempotency_lookup) + { + if existing.app_id != input.app_id + || existing.amount_cents != input.amount_cents + || existing.title != input.title + || existing.items_json != input.items_json + { + return Err("相同幂等键对应的订单字段不一致".to_string()); + } + return Ok(payment_order_snapshot(&existing)); + } + let merchant_order_lookup = format!("{}:{}", input.app_id, input.merchant_order_id); + if ctx + .db + .payment_order() + .merchant_order_lookup() + .find(&merchant_order_lookup) + .is_some() + { + return Err("接入方订单号已存在".to_string()); + } + if ctx + .db + .payment_order() + .checkout_token() + .find(&input.checkout_token) + .is_some() + { + return Err("收银台 token 已存在".to_string()); + } + let now = Timestamp::from_micros_since_unix_epoch(input.now_micros); + let expires_at = Timestamp::from_micros_since_unix_epoch(input.expires_at_micros); + ctx.db.payment_order().insert(PaymentOrder { + order_id: input.order_id.clone(), + app_id: input.app_id, + owner_user_id: input.owner_user_id, + merchant_order_id: input.merchant_order_id, + idempotency_lookup: input.idempotency_lookup, + merchant_order_lookup, + title: input.title, + items_json: input.items_json, + amount_cents: input.amount_cents, + currency: input.currency, + provider: input.provider, + provider_trade_no: None, + checkout_token: input.checkout_token, + status: PaymentOrderStatus::Pending, + created_at: now, + expires_at, + paid_at: None, + updated_at: now, + provider_code_url: None, + callback_url: input.callback_url, + callback_signing_key_hash: input.callback_signing_key_hash, + provider_attempted_at: None, + }); + ctx.db + .payment_order() + .order_id() + .find(&input.order_id) + .map(|row| payment_order_snapshot(&row)) + .ok_or_else(|| "支付订单创建后读取失败".to_string()) +} + +fn get_payment_order( + ctx: &ReducerContext, + input: PaymentOrderGetInput, +) -> Result { + let order_id = required(input.order_id, "payment_order.order_id")?; + let order = ctx + .db + .payment_order() + .order_id() + .find(&order_id) + .ok_or_else(|| "支付订单不存在".to_string())?; + if let Some(owner_user_id) = input.owner_user_id { + if order.owner_user_id != required(owner_user_id, "payment_order.owner_user_id")? { + return Err("无权访问支付订单".to_string()); + } + } + Ok(payment_order_snapshot(&order)) +} + +fn get_payment_order_by_checkout_token( + ctx: &ReducerContext, + input: PaymentOrderCheckoutGetInput, +) -> Result { + let token = required(input.checkout_token, "payment_order.checkout_token")?; + ctx.db + .payment_order() + .checkout_token() + .find(&token) + .map(|order| payment_order_snapshot(&order)) + .ok_or_else(|| "支付订单不存在".to_string()) +} + +fn list_payment_orders( + ctx: &ReducerContext, + input: PaymentOrderListInput, +) -> Result, String> { + let owner_user_id = required(input.owner_user_id, "payment_order.owner_user_id")?; + let limit = input.limit.clamp(1, 100) as usize; + let mut orders = match input.app_id { + Some(app_id) => ctx + .db + .payment_order() + .by_payment_order_app_id() + .filter(&required(app_id, "payment_order.app_id")?) + .filter(|order| order.owner_user_id == owner_user_id) + .map(|row| payment_order_snapshot(&row)) + .collect::>(), + None => ctx + .db + .payment_order() + .by_payment_order_owner_user_id() + .filter(&owner_user_id) + .map(|row| payment_order_snapshot(&row)) + .collect::>(), + }; + orders.sort_by(|left, right| right.created_at_micros.cmp(&left.created_at_micros)); + orders.truncate(limit); + Ok(orders) +} + +fn mark_payment_order_paid( + ctx: &ReducerContext, + input: PaymentOrderMarkPaidInput, +) -> Result { + let input = build_payment_order_mark_paid_input( + input.order_id, + input.provider_trade_no, + input.amount_cents, + input.paid_at_micros, + )?; + let order = ctx + .db + .payment_order() + .order_id() + .find(&input.order_id) + .ok_or_else(|| "支付订单不存在".to_string())?; + if order.amount_cents != input.amount_cents { + return Err("支付金额与本地订单不一致".to_string()); + } + if order.status == PaymentOrderStatus::Paid { + if order.provider_trade_no.as_deref() != Some(input.provider_trade_no.as_str()) { + return Err("已支付订单的平台交易号不一致".to_string()); + } + return Ok(payment_order_snapshot(&order)); + } + if matches!( + order.status, + PaymentOrderStatus::Closed | PaymentOrderStatus::Refunded + ) { + return Err("当前支付订单状态不能确认支付".to_string()); + } + let now = Timestamp::from_micros_since_unix_epoch(input.paid_at_micros); + ctx.db.payment_order().order_id().delete(&order.order_id); + ctx.db.payment_order().insert(PaymentOrder { + status: PaymentOrderStatus::Paid, + provider_trade_no: Some(input.provider_trade_no), + paid_at: Some(now), + updated_at: now, + ..order + }); + ctx.db + .payment_order() + .order_id() + .find(&input.order_id) + .map(|row| payment_order_snapshot(&row)) + .ok_or_else(|| "支付订单确认后读取失败".to_string()) +} + +fn set_payment_order_provider_code( + ctx: &ReducerContext, + input: PaymentOrderProviderCodeInput, +) -> Result { + let order_id = required(input.order_id, "payment_order.order_id")?; + let provider_code_url = required(input.provider_code_url, "payment_order.provider_code_url")?; + let order = ctx + .db + .payment_order() + .order_id() + .find(&order_id) + .ok_or_else(|| "支付订单不存在".to_string())?; + if matches!( + order.status, + PaymentOrderStatus::Paid | PaymentOrderStatus::Closed + ) { + return Ok(payment_order_snapshot(&order)); + } + let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); + ctx.db.payment_order().order_id().delete(&order_id); + ctx.db.payment_order().insert(PaymentOrder { + provider_code_url: Some(provider_code_url), + updated_at, + ..order + }); + ctx.db + .payment_order() + .order_id() + .find(&order_id) + .map(|row| payment_order_snapshot(&row)) + .ok_or_else(|| "支付订单 provider 结果写入后读取失败".to_string()) +} + +fn mark_payment_order_provider_attempted( + ctx: &ReducerContext, + input: PaymentOrderProviderAttemptInput, +) -> Result { + let order_id = required(input.order_id, "payment_order.order_id")?; + let order = ctx + .db + .payment_order() + .order_id() + .find(&order_id) + .ok_or_else(|| "支付订单不存在".to_string())?; + if order.provider_code_url.is_some() || order.status == PaymentOrderStatus::Paid { + return Ok(payment_order_snapshot(&order)); + } + if order.provider_attempted_at.is_some() { + return Err("PAYMENT_PROVIDER_PENDING".to_string()); + } + let attempted_at = Timestamp::from_micros_since_unix_epoch(input.attempted_at_micros); + ctx.db.payment_order().order_id().delete(&order_id); + ctx.db.payment_order().insert(PaymentOrder { + status: PaymentOrderStatus::Paying, + provider_attempted_at: Some(attempted_at), + updated_at: attempted_at, + ..order + }); + ctx.db + .payment_order() + .order_id() + .find(&order_id) + .map(|row| payment_order_snapshot(&row)) + .ok_or_else(|| "支付订单 provider claim 后读取失败".to_string()) +} + +fn enqueue_payment_webhook( + ctx: &ReducerContext, + input: PaymentWebhookEnqueueInput, +) -> Result, String> { + let order_id = required(input.order_id, "payment_webhook.order_id")?; + let payload_json = required(input.payload_json, "payment_webhook.payload_json")?; + let signature = required(input.signature, "payment_webhook.signature")?; + if payload_json.len() > 64 * 1024 { + return Err("payment_webhook.payload_json 超出大小限制".to_string()); + } + let order = ctx + .db + .payment_order() + .order_id() + .find(&order_id) + .ok_or_else(|| "支付订单不存在".to_string())?; + if order.status != PaymentOrderStatus::Paid { + return Err("只有已支付订单可以创建外部回调".to_string()); + } + let Some(callback_url) = order.callback_url.clone() else { + return Ok(None); + }; + let delivery_id = format!("payment-webhook:{order_id}"); + if let Some(existing) = ctx + .db + .payment_webhook_delivery() + .delivery_id() + .find(&delivery_id) + { + if existing.payload_json != payload_json || existing.signature != signature { + return Err("相同支付订单的回调内容不一致".to_string()); + } + return Ok(Some(payment_webhook_snapshot(&existing))); + } + let now = Timestamp::from_micros_since_unix_epoch(input.now_micros); + ctx.db + .payment_webhook_delivery() + .insert(PaymentWebhookDelivery { + delivery_id: delivery_id.clone(), + order_id: order.order_id, + app_id: order.app_id, + owner_user_id: order.owner_user_id, + callback_url, + payload_json, + signature, + status: PAYMENT_WEBHOOK_STATUS_PENDING.to_string(), + attempt_count: 0, + available_at: now, + lease_worker_id: None, + lease_expires_at: None, + last_error_message: None, + created_at: now, + updated_at: now, + }); + ctx.db + .payment_webhook_delivery() + .delivery_id() + .find(&delivery_id) + .map(|row| payment_webhook_snapshot(&row)) + .map(Some) + .ok_or_else(|| "支付回调写入后读取失败".to_string()) +} + +fn claim_payment_webhooks( + ctx: &ReducerContext, + input: PaymentWebhookClaimInput, +) -> Result, String> { + let worker_id = required(input.worker_id, "payment_webhook.worker_id")?; + let now = Timestamp::from_micros_since_unix_epoch(input.now_micros); + let lease_expires_at = Timestamp::from_micros_since_unix_epoch(input.lease_expires_at_micros); + let limit = input.limit.clamp(1, 50) as usize; + let mut rows = ctx + .db + .payment_webhook_delivery() + .iter() + .filter(|row| { + (row.status == PAYMENT_WEBHOOK_STATUS_PENDING && row.available_at <= now) + || (row.status == PAYMENT_WEBHOOK_STATUS_PROCESSING + && row.lease_expires_at.is_some_and(|value| value <= now)) + }) + .collect::>(); + rows.sort_by(|left, right| left.available_at.cmp(&right.available_at)); + rows.truncate(limit); + let mut claimed = Vec::with_capacity(rows.len()); + for row in rows { + let delivery_id = row.delivery_id.clone(); + ctx.db + .payment_webhook_delivery() + .delivery_id() + .delete(&delivery_id); + ctx.db + .payment_webhook_delivery() + .insert(PaymentWebhookDelivery { + status: PAYMENT_WEBHOOK_STATUS_PROCESSING.to_string(), + lease_worker_id: Some(worker_id.clone()), + lease_expires_at: Some(lease_expires_at), + updated_at: now, + ..row + }); + if let Some(updated) = ctx + .db + .payment_webhook_delivery() + .delivery_id() + .find(&delivery_id) + { + claimed.push(payment_webhook_snapshot(&updated)); + } + } + Ok(claimed) +} + +fn complete_payment_webhook( + ctx: &ReducerContext, + input: PaymentWebhookCompleteInput, +) -> Result { + let delivery_id = required(input.delivery_id, "payment_webhook.delivery_id")?; + let worker_id = required(input.worker_id, "payment_webhook.worker_id")?; + let mut row = ctx + .db + .payment_webhook_delivery() + .delivery_id() + .find(&delivery_id) + .ok_or_else(|| "支付回调投递不存在".to_string())?; + if row.status != PAYMENT_WEBHOOK_STATUS_PROCESSING + || row.lease_worker_id.as_deref() != Some(worker_id.as_str()) + { + return Err("支付回调投递租约不属于当前 worker".to_string()); + } + let now = Timestamp::from_micros_since_unix_epoch(input.now_micros); + row.lease_worker_id = None; + row.lease_expires_at = None; + row.updated_at = now; + if input.success { + row.status = PAYMENT_WEBHOOK_STATUS_DELIVERED.to_string(); + row.last_error_message = None; + } else { + row.attempt_count = row.attempt_count.saturating_add(1); + row.last_error_message = input + .error_message + .and_then(|value| normalize_optional_text(Some(value))); + if row.attempt_count >= PAYMENT_WEBHOOK_MAX_ATTEMPTS { + row.status = PAYMENT_WEBHOOK_STATUS_FAILED.to_string(); + } else { + row.status = PAYMENT_WEBHOOK_STATUS_PENDING.to_string(); + row.available_at = + Timestamp::from_micros_since_unix_epoch(input.next_available_at_micros); + } + } + ctx.db + .payment_webhook_delivery() + .delivery_id() + .delete(&delivery_id); + ctx.db.payment_webhook_delivery().insert(row.clone()); + Ok(payment_webhook_snapshot(&row)) +} + +fn payment_app_snapshot(row: &PaymentApp) -> PaymentAppSnapshot { + PaymentAppSnapshot { + app_id: row.app_id.clone(), + owner_user_id: row.owner_user_id.clone(), + name: row.name.clone(), + callback_url: row.callback_url.clone(), + enabled: row.enabled, + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + } +} + +fn payment_api_key_snapshot(row: &PaymentApiKey) -> PaymentApiKeySnapshot { + PaymentApiKeySnapshot { + key_id: row.key_id.clone(), + app_id: row.app_id.clone(), + owner_user_id: row.owner_user_id.clone(), + name: row.name.clone(), + key_prefix: row.key_prefix.clone(), + scopes_json: row.scopes_json.clone(), + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + last_used_at_micros: row + .last_used_at + .map(|value| value.to_micros_since_unix_epoch()), + revoked_at_micros: row + .revoked_at + .map(|value| value.to_micros_since_unix_epoch()), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + } +} + +fn payment_order_snapshot(row: &PaymentOrder) -> PaymentOrderSnapshot { + PaymentOrderSnapshot { + order_id: row.order_id.clone(), + app_id: row.app_id.clone(), + owner_user_id: row.owner_user_id.clone(), + merchant_order_id: row.merchant_order_id.clone(), + idempotency_lookup: row.idempotency_lookup.clone(), + title: row.title.clone(), + items_json: row.items_json.clone(), + amount_cents: row.amount_cents, + currency: row.currency.clone(), + provider: row.provider, + provider_trade_no: row.provider_trade_no.clone(), + provider_code_url: row.provider_code_url.clone(), + callback_url: row.callback_url.clone(), + callback_signing_key_hash: row.callback_signing_key_hash.clone(), + provider_attempted_at_micros: row + .provider_attempted_at + .map(|value| value.to_micros_since_unix_epoch()), + checkout_token: row.checkout_token.clone(), + status: row.status, + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + expires_at_micros: row.expires_at.to_micros_since_unix_epoch(), + paid_at_micros: row.paid_at.map(|value| value.to_micros_since_unix_epoch()), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + } +} + +fn payment_webhook_snapshot(row: &PaymentWebhookDelivery) -> PaymentWebhookDeliverySnapshot { + PaymentWebhookDeliverySnapshot { + delivery_id: row.delivery_id.clone(), + order_id: row.order_id.clone(), + app_id: row.app_id.clone(), + owner_user_id: row.owner_user_id.clone(), + callback_url: row.callback_url.clone(), + payload_json: row.payload_json.clone(), + signature: row.signature.clone(), + status: row.status.clone(), + attempt_count: row.attempt_count, + available_at_micros: row.available_at.to_micros_since_unix_epoch(), + lease_worker_id: row.lease_worker_id.clone(), + lease_expires_at_micros: row + .lease_expires_at + .map(|value| value.to_micros_since_unix_epoch()), + last_error_message: row.last_error_message.clone(), + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + } +} + +fn payment_app_error(error_message: String) -> PaymentAppProcedureResult { + PaymentAppProcedureResult { + ok: false, + app: None, + key: None, + apps: Vec::new(), + keys: Vec::new(), + error_message: Some(error_message), + } +} + +fn payment_order_single_ok(order: PaymentOrderSnapshot) -> PaymentOrderProcedureResult { + PaymentOrderProcedureResult { + ok: true, + order: Some(order), + orders: Vec::new(), + error_message: None, + } +} + +fn payment_order_error(error_message: String) -> PaymentOrderProcedureResult { + PaymentOrderProcedureResult { + ok: false, + order: None, + orders: Vec::new(), + error_message: Some(error_message), + } +} + +fn payment_webhook_error(error_message: String) -> PaymentWebhookProcedureResult { + PaymentWebhookProcedureResult { + ok: false, + delivery: None, + deliveries: Vec::new(), + error_message: Some(error_message), + } +} + +fn required(value: String, field: &str) -> Result { + let value = value.trim().to_string(); + if value.is_empty() { + return Err(format!("{field} 不能为空")); + } + Ok(value) +} diff --git a/src/components/platform-entry/PaymentCheckoutView.tsx b/src/components/platform-entry/PaymentCheckoutView.tsx new file mode 100644 index 000000000..f1bf8a149 --- /dev/null +++ b/src/components/platform-entry/PaymentCheckoutView.tsx @@ -0,0 +1,199 @@ +import { + CheckCircle2, + Clock3, + Copy, + LoaderCircle, + ShieldCheck, +} from 'lucide-react'; +import QRCode from 'qrcode'; +import { useEffect, useState } from 'react'; + +import type { PaymentOrder } from '../../../packages/shared/src/contracts/payment'; +import { getPublicPaymentCheckout } from '../../services/payment/paymentCheckoutClient'; + +type PaymentCheckoutViewProps = { + checkoutToken: string; +}; + +export function PaymentCheckoutView({ + checkoutToken, +}: PaymentCheckoutViewProps) { + const [order, setOrder] = useState(null); + const [qrDataUrl, setQrDataUrl] = useState(''); + const [error, setError] = useState(''); + + useEffect(() => { + let cancelled = false; + const load = async () => { + try { + const response = await getPublicPaymentCheckout(checkoutToken); + if (!cancelled) { + setOrder(response.order); + setError(''); + } + } catch (nextError: unknown) { + if (!cancelled) { + setError( + nextError instanceof Error ? nextError.message : '读取支付订单失败', + ); + } + } + }; + void load(); + const timer = window.setInterval(() => { + if ( + order?.status === 'paid' || + order?.status === 'closed' || + order?.status === 'expired' + ) { + return; + } + void load(); + }, 5000); + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, [checkoutToken, order?.status]); + + useEffect(() => { + let cancelled = false; + if (!order?.providerQrCode) { + setQrDataUrl(''); + return; + } + QRCode.toDataURL(order.providerQrCode, { + width: 320, + margin: 2, + color: { dark: '#2c2723', light: '#ffffff' }, + }).then((dataUrl) => { + if (!cancelled) { + setQrDataUrl(dataUrl); + } + }); + return () => { + cancelled = true; + }; + }, [order?.providerQrCode]); + + const copyAmount = () => { + if (order) { + void navigator.clipboard?.writeText((order.amountCents / 100).toFixed(2)); + } + }; + + if (error) { + return ( +
+
{error}
+
+ ); + } + + if (!order) { + return ( +
+
+ + 正在读取支付订单... +
+
+ ); + } + + const amount = (order.amountCents / 100).toFixed(2); + const isPaid = order.status === 'paid'; + const isClosed = order.status === 'closed' || order.status === 'expired'; + + return ( +
+
+
+
+ ¥ +
+ 扫码付款 + 支付成功后自动更新订单状态 +
+
+
+ 订单号 {order.merchantOrderId} +
+

{order.title}

+

+ 请核对商品与订单信息,使用右侧二维码完成支付。 +

+
+ 付款金额说明 + + 请完全按照页面显示的金额付款,少付或多付都可能导致支付无法自动确认。 + +
+
+ {isPaid ? ( + + ) : ( + + )} +
+ + {isPaid ? '支付成功' : isClosed ? '订单已关闭' : '等待支付'} + + + {isPaid + ? '平台已确认到账' + : isClosed + ? '请重新创建订单' + : '完成付款后页面会自动更新状态'} + +
+
+ +
+
+
+ 扫码付款 + {isPaid ? '已完成' : isClosed ? '已关闭' : '支付中'} +
+
+ 应付金额 + ¥{amount} +
+ {isPaid ? ( +
+ + 支付成功 +
+ ) : isClosed ? ( +
+ + 订单已关闭 +
+ ) : qrDataUrl ? ( + 支付二维码 + ) : ( +
+ +
+ )} +
+ + 微信支付 +
+

请使用微信扫一扫完成支付

+
+
+
+ ); +} diff --git a/src/components/platform-entry/PlatformActiveProfileView.tsx b/src/components/platform-entry/PlatformActiveProfileView.tsx index 8b431a2aa..c271c9365 100644 --- a/src/components/platform-entry/PlatformActiveProfileView.tsx +++ b/src/components/platform-entry/PlatformActiveProfileView.tsx @@ -1,6 +1,7 @@ import { Camera, Coins, + CreditCard, History, KeyRound, MessageCircle, @@ -69,6 +70,7 @@ type PlatformActiveProfileViewProps = { mudPointBalance: ProfileMudPointBalance | null; onLogin: () => void; onOpenApiKeys: () => void; + onOpenPaymentService?: () => void; onOpenCommunity: () => void; onOpenFeedback: () => void; onOpenInvite: () => void; @@ -279,6 +281,7 @@ export function PlatformActiveProfileView({ mudPointBalance, onLogin, onOpenApiKeys, + onOpenPaymentService, onOpenCommunity, onOpenFeedback, onOpenInvite, @@ -663,6 +666,11 @@ export function PlatformActiveProfileView({ icon={KeyRound} onClick={onOpenApiKeys} /> + onOpenPaymentService?.()} + /> diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx index 26007e677..a6a621256 100644 --- a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx +++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx @@ -35,6 +35,7 @@ import { FLOATING_FEEDBACK_FORM_URL } from '../common/floatingFeedbackEntryModel import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformSubpanel } from '../common/PlatformSubpanel'; import { ClientDownloadEntry } from '../creation-home/ClientDownloadEntry'; +import { PaymentCheckoutView } from './PaymentCheckoutView'; import { PlatformActiveMobileWelcomeDialog, shouldOpenActiveMobileWelcomeDialog, @@ -46,6 +47,7 @@ import { import { PlatformActiveProfileView } from './PlatformActiveProfileView'; import type { PlatformEntryFlowShellProps } from './platformEntryActiveTypes'; import { usePlatformDesktopLayout } from './platformEntryResponsive'; +import { PlatformPaymentServiceView } from './PlatformPaymentServiceView'; import { PlatformProfileApiKeysModal } from './PlatformProfileApiKeysModal'; import { canPasteFromNativeHostClipboard, @@ -365,6 +367,7 @@ export function PlatformEntryFlowShellImpl({ const isProfileStage = selectionStage === 'profile' || + selectionStage === 'profile-payment' || (!isDesktopLayout && selectionStage === 'platform'); const profileCenter = usePlatformProfileCenterController({ @@ -403,6 +406,10 @@ export function PlatformEntryFlowShellImpl({ setSelectionStage('profile', { path: '/profile' }); }, [setSelectionStage]); + const openPaymentService = useCallback(() => { + setSelectionStage('profile-payment', { path: '/profile/payment' }); + }, [setSelectionStage]); + const openGames = useCallback(() => { setSelectionStage('games', { path: '/games' }); }, [setSelectionStage]); @@ -575,6 +582,14 @@ export function PlatformEntryFlowShellImpl({ ); } + if (selectionStage === 'payment-checkout') { + const checkoutToken = + typeof window === 'undefined' + ? '' + : (window.location.pathname.split('/').filter(Boolean).at(-1) ?? ''); + return ; + } + const isAuthenticated = Boolean(authUi?.user); const balance = mudPointBalance?.totalPoints ?? legacyWalletBalance; const isCreationStage = @@ -770,7 +785,13 @@ export function PlatformEntryFlowShellImpl({ id={isCreationStage ? 'platform-tab-panel-create' : undefined} className={`platform-tab-panel min-h-0 min-w-0 flex-1 ${isGamePlayStage ? 'platform-tab-panel--game-play' : 'overflow-auto'} ${isCreationStage || isProfileStage || isGamePlayStage ? '' : 'px-3 py-4 sm:px-6 sm:py-6'}`} > - {isProfileStage ? ( + {selectionStage === 'profile-payment' ? ( + authUi?.openLoginModal()} + /> + ) : isProfileStage ? ( authUi?.openLoginModal()} onOpenApiKeys={() => setIsApiKeysOpen(true)} + onOpenPaymentService={openPaymentService} onOpenCommunity={() => profileCenter.openProfilePopupPanel('community') } diff --git a/src/components/platform-entry/PlatformPaymentServiceView.tsx b/src/components/platform-entry/PlatformPaymentServiceView.tsx new file mode 100644 index 000000000..9e91b88ac --- /dev/null +++ b/src/components/platform-entry/PlatformPaymentServiceView.tsx @@ -0,0 +1,347 @@ +import { + ArrowLeft, + Check, + Copy, + CreditCard, + ExternalLink, + KeyRound, +} from 'lucide-react'; +import { useEffect, useState } from 'react'; + +import type { AuthUser } from '@/packages/shared/src'; + +import type { + PaymentApiKey, + PaymentApp, + PaymentAppCreateResponse, +} from '../../../packages/shared/src/contracts/payment'; +import { + createPlatformProfilePaymentApp, + listPlatformProfilePaymentApps, + revokePlatformProfilePaymentApiKey, + updatePlatformProfilePaymentApp, +} from '../../services/platform-entry/platformProfileClient'; +import { PlatformActionButton } from '../common/PlatformActionButton'; +import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; +import { PlatformSubpanel } from '../common/PlatformSubpanel'; + +type PlatformPaymentServiceViewProps = { + user: AuthUser | null | undefined; + onBack: () => void; + onLogin: () => void; +}; + +export function PlatformPaymentServiceView({ + user, + onBack, + onLogin, +}: PlatformPaymentServiceViewProps) { + const [apps, setApps] = useState([]); + const [keys, setKeys] = useState([]); + const [name, setName] = useState('我的游戏支付'); + const [callbackUrl, setCallbackUrl] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [isCreating, setIsCreating] = useState(false); + const [error, setError] = useState(''); + const [created, setCreated] = useState(null); + + useEffect(() => { + if (!user) { + return; + } + let cancelled = false; + setIsLoading(true); + listPlatformProfilePaymentApps() + .then((response) => { + if (!cancelled) { + setApps(response.apps ?? []); + setKeys(response.keys ?? []); + } + }) + .catch((nextError: unknown) => { + if (!cancelled) { + setError( + nextError instanceof Error ? nextError.message : '读取支付服务失败', + ); + } + }) + .finally(() => { + if (!cancelled) { + setIsLoading(false); + } + }); + return () => { + cancelled = true; + }; + }, [user]); + + const submit = async () => { + const normalizedName = name.trim(); + if (!normalizedName) { + setError('请填写应用名称'); + return; + } + setIsCreating(true); + setError(''); + try { + const response = await createPlatformProfilePaymentApp( + normalizedName, + callbackUrl.trim() || null, + ); + setCreated(response); + setApps((current) => [response.app, ...current]); + } catch (nextError: unknown) { + setError( + nextError instanceof Error ? nextError.message : '创建支付应用失败', + ); + } finally { + setIsCreating(false); + } + }; + + const toggleApp = async (app: PaymentApp) => { + try { + const updated = await updatePlatformProfilePaymentApp( + app.appId, + !app.enabled, + ); + setApps((current) => + current.map((item) => (item.appId === updated.appId ? updated : item)), + ); + } catch (nextError: unknown) { + setError( + nextError instanceof Error ? nextError.message : '更新支付应用失败', + ); + } + }; + + const revokeKey = async (key: PaymentApiKey) => { + try { + const updated = await revokePlatformProfilePaymentApiKey(key.keyId); + setKeys((current) => + current.map((item) => (item.keyId === updated.keyId ? updated : item)), + ); + } catch (nextError: unknown) { + setError( + nextError instanceof Error + ? nextError.message + : '撤销支付 API Key 失败', + ); + } + }; + + const copyKey = async () => { + if (!created?.apiKey) { + return; + } + await navigator.clipboard?.writeText(created.apiKey); + }; + + if (!user) { + return ( +
+ + +

支付服务

+

+ 登录后可以创建游戏或外部产品的支付应用。 +

+ + 登录 + +
+
+ ); + } + + return ( +
+
+ +
+

+ 支付服务 +

+

+ 统一收款、动态二维码和支付结果通知。 +

+
+
+ + {error ? ( + + {error} + + ) : null} + + +
+ + +
+

创建接入应用

+

+ 为一款游戏或外部产品创建独立的应用身份。 +

+
+
+
+ + +
+ void submit()} + disabled={isCreating} + > + +
+ + {created ? ( + +
+ + 应用已创建 +
+

+ 完整 API Key 只显示这一次,请立即复制并保存到你的服务端。 +

+
+ + {created.apiKey} + + +
+
+ + {created.key.scopes.join('、')} +
+
+ ) : null} + + +
+

我的支付应用

+ + {isLoading ? '读取中...' : `${apps.length} 个应用`} + +
+
+ {apps.length === 0 && !isLoading ? ( +

+ 还没有支付应用。 +

+ ) : null} + {apps.map((app) => ( +
+
+ {app.name} + + {app.appId} + +
+
+ + {app.enabled ? '可用' : '已停用'} + +
+
+ ))} +
+
+ + +
+

支付 API Key

+ + {keys.length} 个 + +
+
+ {keys.length === 0 ? ( +

+ 创建应用后会生成默认 Key。 +

+ ) : null} + {keys.map((key) => ( +
+
+ {key.name} + + {key.keyPrefix} + +
+ +
+ ))} +
+
+ +

+ + 支付金额由服务端订单快照决定,到账以微信支付通知或查单为准。 +

+
+ ); +} diff --git a/src/components/platform-entry/platformEntryActiveTypes.ts b/src/components/platform-entry/platformEntryActiveTypes.ts index 862fbe957..46af48ad2 100644 --- a/src/components/platform-entry/platformEntryActiveTypes.ts +++ b/src/components/platform-entry/platformEntryActiveTypes.ts @@ -3,6 +3,8 @@ export type SelectionStage = | 'creation-home' | 'project' | 'profile' + | 'profile-payment' + | 'payment-checkout' | 'image-editor' | 'games' | 'game-detail' diff --git a/src/index.css b/src/index.css index 561c0d1bb..71eaa9a09 100644 --- a/src/index.css +++ b/src/index.css @@ -15,6 +15,71 @@ font-display: swap; } +.payment-checkout-page { + min-height: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; + background: radial-gradient(circle at 18% 12%, rgba(199, 101, 50, 0.11), transparent 32%), #f5f1ea; + color: #2c2723; +} + +.payment-checkout-shell { + width: min(68rem, 100%); + display: grid; + grid-template-columns: minmax(0, 0.95fr) minmax(20rem, 1.15fr); + gap: 1.25rem; +} + +.payment-checkout-summary, +.payment-checkout-qr-panel { + border: 1px solid rgba(119, 92, 68, 0.13); + border-radius: 1.8rem; + background: rgba(255, 253, 249, 0.92); + box-shadow: 0 1.5rem 3.5rem rgba(92, 70, 49, 0.1); +} + +.payment-checkout-summary { padding: 2rem; } +.payment-checkout-qr-panel { padding: 2rem; text-align: center; } +.payment-checkout-brand { display: flex; align-items: center; gap: 0.75rem; } +.payment-checkout-brand > span { display: grid; place-items: center; width: 2.5rem; height: 2.5rem; border-radius: 0.9rem; background: #f0e4d8; font-weight: 900; } +.payment-checkout-brand strong, .payment-checkout-brand small { display: block; } +.payment-checkout-brand small { margin-top: 0.2rem; color: #88796d; font-size: 0.76rem; } +.payment-checkout-code { margin-top: 2rem; color: #8c7d70; font-size: 0.78rem; } +.payment-checkout-code strong { display: block; margin-top: 0.35rem; color: #2c2723; font-size: 0.9rem; } +.payment-checkout-summary h1 { margin-top: 1.8rem; font-size: clamp(1.8rem, 4vw, 2.6rem); font-weight: 900; } +.payment-checkout-description { margin-top: 0.65rem; color: #75675c; font-size: 0.9rem; line-height: 1.7; } +.payment-checkout-alert { display: grid; gap: 0.45rem; margin-top: 1.7rem; padding: 1rem; border: 1px solid #f1d9d1; border-radius: 1rem; background: #fff7f4; color: #8f3e32; font-size: 0.82rem; line-height: 1.6; } +.payment-checkout-state { display: flex; align-items: center; gap: 0.75rem; margin-top: 1.1rem; padding: 1rem; border: 1px solid rgba(119, 92, 68, 0.1); border-radius: 1rem; } +.payment-checkout-state strong, .payment-checkout-state span { display: block; } +.payment-checkout-state span { margin-top: 0.2rem; color: #88796d; font-size: 0.78rem; } +.payment-checkout-copy { display: inline-flex; align-items: center; gap: 0.4rem; margin-top: 1rem; color: #7a5b42; font-size: 0.8rem; font-weight: 700; } +.payment-checkout-qr-title { display: flex; justify-content: space-between; font-size: 0.95rem; } +.payment-checkout-qr-title span { padding: 0.35rem 0.7rem; border: 1px solid rgba(119, 92, 68, 0.12); border-radius: 999px; color: #88796d; font-size: 0.75rem; } +.payment-checkout-amount { display: flex; align-items: center; justify-content: space-between; margin-top: 1.2rem; padding: 1.15rem; border: 1px solid rgba(119, 92, 68, 0.11); border-radius: 1rem; background: #f8f1e9; text-align: left; } +.payment-checkout-amount small { color: #88796d; } +.payment-checkout-amount strong { font-size: 2.1rem; } +.payment-checkout-qr, .payment-checkout-qr-placeholder { width: min(19rem, 100%); aspect-ratio: 1; margin: 1.8rem auto 1rem; border-radius: 1.2rem; background: #fff; } +.payment-checkout-qr { display: block; padding: 0.65rem; border: 1px solid rgba(119, 92, 68, 0.12); } +.payment-checkout-qr-placeholder { display: grid; place-items: center; color: #a17c5b; } +.payment-checkout-success { display: grid; place-items: center; gap: 0.7rem; min-height: 19rem; margin-top: 1.8rem; color: #27845d; } +.payment-checkout-success--muted { color: #998b7f; } +.payment-checkout-provider { display: inline-flex; align-items: center; gap: 0.35rem; font-weight: 800; } +.payment-checkout-hint { margin-top: 0.45rem; color: #88796d; font-size: 0.8rem; } +.payment-checkout-loading, .payment-checkout-error { display: flex; align-items: center; gap: 0.6rem; padding: 1rem 1.25rem; border-radius: 1rem; background: rgba(255, 253, 249, 0.95); box-shadow: 0 1rem 2rem rgba(92, 70, 49, 0.1); } +.payment-checkout-error { color: #8f3e32; } + +@media (max-width: 760px) { + .payment-checkout-page { align-items: flex-start; padding: 1rem; } + .payment-checkout-shell { grid-template-columns: 1fr; gap: 0.8rem; } + .payment-checkout-summary, .payment-checkout-qr-panel { border-radius: 1.25rem; padding: 1.25rem; } + .payment-checkout-summary h1 { margin-top: 1.25rem; font-size: 1.7rem; } + .payment-checkout-code { margin-top: 1.25rem; } + .payment-checkout-qr, .payment-checkout-qr-placeholder { margin-top: 1.25rem; } + .payment-checkout-success { min-height: 14rem; } +} + @theme { --font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif; --font-serif: 'Noto Serif SC', 'Georgia', serif; diff --git a/src/routing/activeAppPageRoutes.test.ts b/src/routing/activeAppPageRoutes.test.ts index 11e141e2a..94b300a3e 100644 --- a/src/routing/activeAppPageRoutes.test.ts +++ b/src/routing/activeAppPageRoutes.test.ts @@ -30,6 +30,9 @@ describe('appPageRoutes', () => { expect(resolveSelectionStageFromPath('/games/publish')).toBe( 'game-publish', ); + expect(resolveSelectionStageFromPath('/pay/checkout-token')).toBe( + 'payment-checkout', + ); expect(resolveSelectionStageFromPath('/creation/rpg')).toBe('platform'); expect(resolveSelectionStageFromPath('/runtime/puzzle')).toBe('platform'); expect(isKnownMainAppPagePath('/creation')).toBe(true); @@ -42,6 +45,10 @@ describe('appPageRoutes', () => { expect(resolvePathForSelectionStage('games')).toBe('/games'); expect(resolvePathForSelectionStage('game-mine')).toBe('/games/mine'); expect(resolvePathForSelectionStage('game-publish')).toBe('/games/publish'); + expect(resolvePathForSelectionStage('profile-payment')).toBe( + '/profile/payment', + ); + expect(resolvePathForSelectionStage('payment-checkout')).toBe('/pay'); }); it('requires a project id for direct editor navigation', () => { diff --git a/src/routing/activeAppPageRoutes.ts b/src/routing/activeAppPageRoutes.ts index 5da7357e4..8c91e2e6e 100644 --- a/src/routing/activeAppPageRoutes.ts +++ b/src/routing/activeAppPageRoutes.ts @@ -8,6 +8,8 @@ const STAGE_ROUTE_ENTRIES = [ ['creation-home', '/creation'], ['project', '/project'], ['profile', '/profile'], + ['profile-payment', '/profile/payment'], + ['payment-checkout', '/pay'], ['image-editor', '/editor/canvas'], ['games', '/games'], ['game-detail', '/games/detail'], @@ -39,6 +41,9 @@ export function normalizeAppPath(pathname: string) { export function resolveSelectionStageFromPath( pathname: string, ): SelectionStage { + if (normalizeAppPath(pathname).startsWith('/pay/')) { + return 'payment-checkout'; + } return ROUTE_STAGE_BY_PATH.get(normalizeAppPath(pathname)) ?? 'platform'; } @@ -69,7 +74,8 @@ export function shouldRedirectEditorCanvasWithoutProject( } export function isKnownMainAppPagePath(pathname: string) { - return ROUTE_STAGE_BY_PATH.has(normalizeAppPath(pathname)); + const normalized = normalizeAppPath(pathname); + return ROUTE_STAGE_BY_PATH.has(normalized) || normalized.startsWith('/pay/'); } export function pushAppHistoryPath(path: string) { diff --git a/src/services/activeAppTitle.ts b/src/services/activeAppTitle.ts index 618713eab..d3a82f17d 100644 --- a/src/services/activeAppTitle.ts +++ b/src/services/activeAppTitle.ts @@ -9,6 +9,8 @@ const APP_TITLE_BY_SELECTION_STAGE: Record = { 'creation-home': APP_HOME_TITLE, project: '项目 - 陶泥儿', profile: '我的 - 陶泥儿', + 'profile-payment': '支付服务 - 陶泥儿', + 'payment-checkout': '扫码支付 - 陶泥儿', 'image-editor': '美术编辑器 - 陶泥儿', games: '游戏 - 陶泥儿', 'game-detail': '游戏详情 - 陶泥儿', diff --git a/src/services/payment/paymentCheckoutClient.ts b/src/services/payment/paymentCheckoutClient.ts new file mode 100644 index 000000000..0438338e0 --- /dev/null +++ b/src/services/payment/paymentCheckoutClient.ts @@ -0,0 +1,11 @@ +import type { PaymentOrderCreateResponse } from '../../../packages/shared/src/contracts/payment'; +import { requestJson } from '../apiClient'; + +export function getPublicPaymentCheckout(checkoutToken: string) { + return requestJson( + `/api/payment/checkout/${encodeURIComponent(checkoutToken)}`, + { method: 'GET' }, + '读取支付订单失败', + { skipAuth: true, skipRefresh: true }, + ); +} diff --git a/src/services/platform-entry/platformProfileClient.ts b/src/services/platform-entry/platformProfileClient.ts index 7164a8118..561674520 100644 --- a/src/services/platform-entry/platformProfileClient.ts +++ b/src/services/platform-entry/platformProfileClient.ts @@ -1,3 +1,9 @@ +import type { + PaymentApiKey, + PaymentApp, + PaymentAppCreateResponse, + PaymentAppListResponse, +} from '../../../packages/shared/src/contracts/payment'; import type { ConfirmWechatProfileRechargeOrderResponse, CreateProfileRechargeOrderResponse, @@ -140,6 +146,63 @@ export function revokePlatformProfileExternalApiKey( ); } +export function listPlatformProfilePaymentApps( + options: PlatformProfileRequestOptions = {}, +) { + return requestPlatformProfileJson( + '/payment/apps', + { method: 'GET' }, + '读取支付服务失败', + options, + ); +} + +export function createPlatformProfilePaymentApp( + name: string, + callbackUrl: string | null, + options: PlatformProfileRequestOptions = {}, +) { + return requestPlatformProfileJson( + '/payment/apps', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, callbackUrl }), + }, + '创建支付应用失败', + options, + ); +} + +export function updatePlatformProfilePaymentApp( + appId: string, + enabled: boolean, + options: PlatformProfileRequestOptions = {}, +) { + return requestPlatformProfileJson( + `/payment/apps/${encodeURIComponent(appId)}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled }), + }, + '更新支付应用失败', + options, + ); +} + +export function revokePlatformProfilePaymentApiKey( + keyId: string, + options: PlatformProfileRequestOptions = {}, +) { + return requestPlatformProfileJson( + `/payment/keys/${encodeURIComponent(keyId)}`, + { method: 'DELETE' }, + '撤销支付 API Key 失败', + options, + ); +} + export function getPlatformProfileRechargeCenter( options: PlatformProfileRequestOptions = {}, ) {