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/package.json b/apps/ai-game-creator-shell/package.json index 79d145ae3..0d3ec33c6 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -19,7 +19,8 @@ "bundled-resources:test": "node --test scripts/prepare-bundled-resources.test.mjs", "llm-status": "node scripts/run-cli-with-config.mjs --llm-status", "config": "node scripts/game-creator-config-wizard.mjs", - "typecheck": "tsc -p tsconfig.json --noEmit && npm run skill-pack:check && npm run bundled-resources:check && node scripts/check-config.mjs" + "check:tests:types": "tsc -p tsconfig.tests.json --noEmit", + "typecheck": "npm run check:tests:types && tsc -p tsconfig.json --noEmit && npm run skill-pack:check && npm run bundled-resources:check && node scripts/check-config.mjs" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "0.3.285", 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/prompts/runtime/texts/direct.json b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct.json index 8ba3c986f..a544d209e 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct.json +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct.json @@ -17,8 +17,14 @@ "browser.status": "整体 passed={}", "browser.notStarted": "本次未启动 Chromium", "browser.evidence": "[AGC 浏览器事实证据]\nattempt={attempt}; completionError={completion_status}; browser={browser_status}; codeFingerprintChanged={output_changed}\n{}\n诊断与平台素材运行时观察:{}\n交互探针:{}\n客户端已保存结构化证据;它是事实输入,不代表 Codex 已阅读截图或已经完成修复。当前 cwd 是项目根目录;按需读取实际文件,并自行决定是否修改、再次试玩或直接回复。AGC 只负责启动浏览器、采集证据和执行项目边界,Codex 负责解释结果。", - "browser.noFailureDetails": "无额外硬失败详情", - "browser.noVisibleControls": "未找到可执行的可见控件", + "browser.noFailureDetails": "无额外视觉检查失败详情", + "browser.noVisibleControls": "无建议性交互探针记录", + "browser.gameplayNotRun": "玩法检查:未执行(visual 仅提供视觉证据)。", + "browser.gameplayMissing": "固定场景玩法结果缺失,不能确认通过。", + "browser.gameplayNoDiagnostic": "无诊断记录", + "browser.gameplayNoUnmetAssertion": "无", + "browser.gameplayViewport": "{viewport}: gameplayPassed={passed};首条玩法诊断:{diagnostic};首个未通过断言:{assertion}", + "browser.gameplayEvidence": "[固定场景玩法证据]\n{}\n断言 passed=false 表示未通过,也可能因前序阻断而未执行;修复以具体诊断为准,不把所有未通过项视为独立故障。固定场景通过不代表完整关卡已验证;reportPath 仅为宿主证据引用,无需读取私有报告。", "system.role": "你是陶泥儿,是 Genarrative 面向用户的游戏创作助手,负责当前任务的执行。先理解用户意图:普通对话直接回答,项目请求按需要检查、修改、运行和验证,并用简洁中文报告真实结果。", "system.workspaceBoundary": "工作区边界:只在当前项目目录内工作;不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径。遇到阻断必须说明具体原因、文件和下一步,不要声称未验证的成功。", "system.toolAuthorization": "AGC 工具授权:agc_tools 使用当前登录会话。工具返回 401/403 时,报告登录或权限状态异常并停止,交由用户处理登录和权限。", diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-browser-playtest/references/browser-evidence-contract.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-browser-playtest/references/browser-evidence-contract.md index 6db5127e8..33c8ac092 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-browser-playtest/references/browser-evidence-contract.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-browser-playtest/references/browser-evidence-contract.md @@ -10,6 +10,8 @@ Canvas/WebGL probes can show whether registered image sources entered a core ren `mode=visual` captures desktop/mobile screenshots, loading, layout and diagnostics without clicking gameplay controls. It does not prove that the game loop works. `mode=gameplay` uses the fixed approved scenario (`generic-v1` by default, or the existing `tetris-v1` / `lane-defense-v1` contract), not an agent-written autopilot. It exercises the scenario independently in desktop and mobile viewports and requires both results. The receipt declares `gameplayViewports` separately from `visualViewports`. Older reports with only a desktop `playtest` result do not prove mobile gameplay. These bounded scenarios do not establish completion of an arbitrary long level. +Use each viewport's `gameplayResults` to diagnose failures: start with its first `diagnostics` entry, then inspect `assertions` and the recorded initial/final phase, sequence and level. An assertion with `passed=false` may not have run because an earlier step blocked; do not treat every unmet assertion as an independent defect. Missing viewport evidence is not a pass. In visual mode, `gameplayResults` is empty because gameplay was not run. `reportPath` is a private host evidence reference; use the returned diagnostics instead of trying to read that report. + For `generic-v1`, expose exactly one `script#playable-web-game-state[type="application/json"]` whose JSON is a projection of actual game state: ```json diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json index 4625c1ca2..12678da03 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": "agc-skill-pack.v1", - "version": "2026-08-26.35", + "version": "2026-08-26.37", "skills": [ { "name": "agc-unity-editor", @@ -140,7 +140,7 @@ "references/browser-evidence-contract.md", "references/runner-physics.mjs" ], - "sha256": "e0e7bdca529e068ce2974a5e312a267108ab5acb20e8f9d7c0c22d049d90b796" + "sha256": "6800059c4e7bae70b0b42ec47175fc85e38d789e1759dca5df2067f715dc0657" }, { "name": "agc-client-projection", 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 110b0b918..db9db4abf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -30,6 +30,8 @@ mod direct_runtime; mod direct_tool_bridge; mod direct_tools_mcp; mod direct_validation; +#[cfg(test)] +pub(crate) use direct_validation::browser_tool_result; mod generation; pub(crate) mod json_sidecar; pub(crate) mod redaction; @@ -43,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..f0f01ad0e 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 @@ -94,11 +94,14 @@ impl Drop for ActiveClaudeCodeTurnGuard { } } -struct ExternalMcpStopGuard; +struct ExternalMcpStopGuard { + root: PathBuf, + token: String, +} impl Drop for ExternalMcpStopGuard { fn drop(&mut self) { - stop_external_mcp_loopback(); + stop_external_mcp_loopback_for_root(&self.root, &self.token); } } @@ -747,6 +750,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 +801,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}" ))); } @@ -920,9 +963,13 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at( observer: Option<&mut (dyn FnMut(TurnObservation) + Send)>, ) -> Result { direct_turn_trace("claude-executor-enter"); - let (mcp_url, mcp_token) = start_external_mcp_loopback(root, llm.web_search_enabled).await?; + let (mcp_url, mcp_token) = + start_external_mcp_loopback_for_direct_turn(root, llm.web_search_enabled).await?; direct_turn_trace("claude-mcp-loopback-ready"); - let _mcp_guard = ExternalMcpStopGuard; + let _mcp_guard = ExternalMcpStopGuard { + root: root.to_path_buf(), + token: mcp_token.clone(), + }; let home = crate::project::resolve_local_project_path(root, ".agent/runtime/claude-code/home")?; std::fs::create_dir_all(&home) .map_err(|error| format!("创建 cc Direct 隔离目录失败:{error}"))?; @@ -1156,6 +1203,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/agent/direct_events.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_events.rs index bafdb8b7f..429c97240 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_events.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_events.rs @@ -28,7 +28,22 @@ pub(crate) const DIRECT_ACTIVE_TURNS_CHANGED_EVENT: &str = "game-creator-direct-active-turns-changed"; static DIRECT_ACTIVE_TURNS_EVENT_REVISION: AtomicU64 = AtomicU64::new(0); #[cfg(test)] -static DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT: AtomicU64 = AtomicU64::new(0); +thread_local! { + /// 只统计**当前线程**发出的通知。`--test-threads=1` 只串行测试线程,宿主 + /// `tauri::async_runtime` 的后台回合仍在自己的工作线程上跑(放行任务随 + /// `TurnReservation::drop` 起整轮),并会在任意时刻广播「运行中的项目」变了。断言要观测的 + /// 是本测试自己触发的通知,不该被别的后台广播串台。 + /// + /// 这里必须留在测试线程作用域内:退役 `runtime_driver` 时这条口径曾被降级回进程级 + /// `AtomicU64`,于是 `agent::thread_manager::tests::active_turn_changes_publish_one_notification_per_real_change` + /// 又回到偶发(同一片内前一个用例留下的后台回合广播进采样窗口)。 + /// + /// 代价:计数改成线程作用域后,**别的线程**上的重复 / 丢失通知不再被这条用例覆盖(那是 + /// 宿主后台回合的行为,本身就不该由单线程断言口径表达);要覆盖跨线程序,应另加用例 + /// 观察回合身份/序列号,而不是把计数器退回进程级。 + static DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT: std::cell::Cell = + const { std::cell::Cell::new(0) }; +} const GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES: u64 = 64 * 1024; const GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_MAX: usize = 16; @@ -54,7 +69,7 @@ pub(crate) fn set_game_creator_agent_runtime_update_app_handle(app: tauri::AppHa pub(crate) fn emit_direct_active_turns_changed() { let revision = DIRECT_ACTIVE_TURNS_EVENT_REVISION.fetch_add(1, Ordering::AcqRel) + 1; #[cfg(test)] - DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT.fetch_add(1, Ordering::AcqRel); + let _ = DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT.try_with(|count| count.set(count.get() + 1)); let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else { return; }; @@ -66,7 +81,7 @@ pub(crate) fn emit_direct_active_turns_changed() { #[cfg(test)] pub(crate) fn direct_active_turns_event_test_count() -> u64 { - DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT.load(Ordering::Acquire) + DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT.with(std::cell::Cell::get) } pub(crate) fn emit_direct_game_creator_progress(root: &Path, stage: &str, message: &str) { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 31cd98b59..a3a32f57f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -4236,8 +4236,9 @@ pub(crate) async fn start_direct_tool_bridge( pub(crate) async fn start_external_client_tool_bridge( root: &Path, controlled_web_search: bool, + direct_turn_execution: bool, ) -> Result { - start_tool_bridge_for_source(root, controlled_web_search, false).await + start_tool_bridge_for_source(root, controlled_web_search, direct_turn_execution).await } async fn start_tool_bridge_for_source( @@ -4421,6 +4422,115 @@ pub(super) fn drain_analytics_test_writer( #[cfg(test)] mod tests { + #[test] + fn browser_gameplay_results_reach_model_with_matching_viewports_and_screenshots() { + for passed in [false, true] { + let temp = tempfile::tempdir().unwrap(); + let evidence = if passed { + super::super::direct_validation::tests::browser_evidence_fixture(temp.path()) + } else { + super::super::direct_validation::tests::failed_browser_evidence_fixture(temp.path()) + }; + for viewport in &evidence.viewport_results { + image::RgbaImage::new(2, 2) + .save(&viewport.screenshot_path) + .unwrap(); + } + let receipt = super::super::direct_validation::browser_tool_result( + temp.path(), + &evidence, + Some(crate::browser::BrowserPlaytestScenario::GenericV1), + 1, + ); + assert!(evidence.viewport_results.iter().all(|result| result.passed)); + assert_eq!(receipt["passed"], passed); + let outcome = bridge_validation_result( + temp.path(), + Ok(receipt.clone()), + |report, screenshots| BrowserPlaytestError::PlaytestNotPassed { + report, + screenshots, + }, + ) + .map_err(|failure| { + let error = ToolCallError::from(&failure); + assert_eq!(error.error["PlaytestNotPassed"]["report"], receipt); + assert!(error.error["PlaytestNotPassed"] + .get("screenshots") + .is_none()); + error + }); + let state = direct_tool_bridge_state(temp.path().to_path_buf()); + let response = + compose_direct_tool_outcome(&state, "agc_browser_playtest", "{}", false, outcome); + assert_eq!(response["isError"], !passed); + let text = response["content"][0]["text"].as_str().unwrap(); + let model_receipt: Value = + serde_json::from_str(&text[text.find('{').unwrap()..]).unwrap(); + assert_eq!(model_receipt, receipt); + assert_eq!( + response["content"] + .as_array() + .unwrap() + .iter() + .filter(|block| block["type"] == "image") + .count(), + 2 + ); + let summary = model_receipt["summary"].as_str().unwrap(); + for (index, entry) in evidence.viewport_playtests.iter().enumerate() { + let report = &model_receipt["gameplayResults"][index]; + assert_eq!( + report["viewport"], + serde_json::to_value(entry.viewport).unwrap() + ); + assert_eq!(report["passed"], entry.result.passed); + assert_eq!( + report["assertions"], + serde_json::to_value(&entry.result.assertions).unwrap() + ); + assert_eq!(report["finalPhase"], "ready"); + assert_eq!( + report["finalSequence"], + entry.result.final_sequence.unwrap() + ); + let viewport = report["viewport"].as_str().unwrap(); + let line = summary + .lines() + .find(|line| line.starts_with(&format!("{viewport}: gameplayPassed="))) + .unwrap(); + assert!(line.starts_with(&format!( + "{viewport}: gameplayPassed={}", + entry.result.passed + ))); + if let Some(reason) = entry.result.diagnostics.first() { + assert_eq!(report["diagnostics"][0], reason.as_str()); + assert!(line.contains(reason)); + } + if let Some(assertion) = entry + .result + .assertions + .iter() + .find(|assertion| !assertion.passed) + { + assert!(line.contains(&assertion.name)); + } + } + } + } + + #[tokio::test] + async fn external_client_bridge_can_be_enabled_for_a_direct_turn() { + let temporary = tempfile::tempdir().unwrap(); + init_local_game_project_at(temporary.path(), "external-direct-turn", "外部直连回合") + .unwrap(); + let bridge = start_external_client_tool_bridge(temporary.path(), false, true) + .await + .expect("direct Claude bridge"); + assert!(bridge.state.direct_turn_execution); + drop(bridge); + } + #[tokio::test] async fn host_cancellation_releases_queued_image_without_a_new_post() { use std::sync::atomic::{AtomicBool, Ordering}; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 50ff3abf5..28aded7d3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -35,12 +35,16 @@ const EXTERNAL_MCP_RESPONSE_SUMMARY_MAX_CHARS: usize = 240; const EXTERNAL_MCP_JOURNAL_MAX_BYTES: u64 = 8 * 1024 * 1024; const EXTERNAL_MCP_JOURNAL_RELATIVE_PATH: &str = ".agent/conversations/codex-responses.jsonl"; static EXTERNAL_MCP_JOURNAL_LOCK: OnceLock> = OnceLock::new(); -static EXTERNAL_MCP_SERVER: OnceLock>> = OnceLock::new(); +static EXTERNAL_MCP_SERVER: OnceLock< + Mutex>, +> = OnceLock::new(); tokio::task_local! { static EXTERNAL_MCP_BRIDGE_URL: String; } pub(crate) struct ExternalMcpServer { + token: String, + _direct_turn_guard: Option, _bridge: super::direct_tool_bridge::DirectToolBridge, task: tokio::task::JoinHandle<()>, } @@ -1759,6 +1763,21 @@ async fn handle_external_mcp_http_request( pub(crate) async fn start_external_mcp_loopback( root: &Path, controlled_web_search: bool, +) -> Result<(String, String), String> { + start_external_mcp_loopback_with_mode(root, controlled_web_search, false).await +} + +pub(crate) async fn start_external_mcp_loopback_for_direct_turn( + root: &Path, + controlled_web_search: bool, +) -> Result<(String, String), String> { + start_external_mcp_loopback_with_mode(root, controlled_web_search, true).await +} + +async fn start_external_mcp_loopback_with_mode( + root: &Path, + controlled_web_search: bool, + direct_turn_execution: bool, ) -> Result<(String, String), String> { let root = validate_direct_tools_project_root(root)?; let session = current_platform_session() @@ -1771,12 +1790,20 @@ pub(crate) async fn start_external_mcp_loopback( let address = listener .local_addr() .map_err(|error| format!("读取客户端 MCP 地址失败:{error}"))?; - let bridge = - super::direct_tool_bridge::start_external_client_tool_bridge(&root, controlled_web_search) - .await?; + let bridge = super::direct_tool_bridge::start_external_client_tool_bridge( + &root, + controlled_web_search, + direct_turn_execution, + ) + .await?; + let direct_turn_guard = if direct_turn_execution { + Some(bridge.begin_user_turn()?) + } else { + None + }; let state = ExternalMcpHttpState { bridge_url: bridge.url().to_string(), - root, + root: root.clone(), token: token.clone(), session_user_id: session.user_id, session_identity_generation: session.identity_generation, @@ -1790,24 +1817,40 @@ pub(crate) async fn start_external_mcp_loopback( let _ = axum::serve(listener, app).await; }); let url = format!("http://127.0.0.1:{}{route}", address.port()); - let registry = EXTERNAL_MCP_SERVER.get_or_init(|| Mutex::new(None)); + let registry = + EXTERNAL_MCP_SERVER.get_or_init(|| Mutex::new(std::collections::BTreeMap::new())); let mut guard = registry .lock() .map_err(|_| "客户端 MCP 服务注册表不可用".to_string())?; - if let Some(previous) = guard.take() { - drop(previous); - } - *guard = Some(ExternalMcpServer { - _bridge: bridge, - task, - }); + guard.insert( + root, + ExternalMcpServer { + token: token.clone(), + _direct_turn_guard: direct_turn_guard, + _bridge: bridge, + task, + }, + ); Ok((url, token)) } pub(crate) fn stop_external_mcp_loopback() { if let Some(registry) = EXTERNAL_MCP_SERVER.get() { if let Ok(mut guard) = registry.lock() { - guard.take(); + guard.clear(); + } + } +} + +pub(crate) fn stop_external_mcp_loopback_for_root(root: &Path, token: &str) { + let Ok(root) = root.canonicalize() else { + return; + }; + if let Some(registry) = EXTERNAL_MCP_SERVER.get() { + if let Ok(mut guard) = registry.lock() { + if guard.get(&root).is_some_and(|server| server.token == token) { + guard.remove(&root); + } } } } @@ -2180,10 +2223,13 @@ mod tests { .unwrap(); std::fs::create_dir(project.path().join(".agent")).unwrap(); std::fs::write(project.path().join(".agent/manifest.json"), "{}").unwrap(); - let bridge = - super::super::direct_tool_bridge::start_direct_tool_bridge(project.path(), false) - .await - .unwrap(); + let bridge = super::super::direct_tool_bridge::start_external_client_tool_bridge( + project.path(), + false, + false, + ) + .await + .unwrap(); for enabled in [false, true, false, true] { crate::builtin_plugins::set_enabled( crate::builtin_plugins::AGC_GODOT_EDITOR_PLUGIN_ID, @@ -2281,10 +2327,13 @@ mod tests { let project = crate::tests::canonical_test_tempdir("builtin-editor-mcp-"); std::fs::create_dir_all(project.path().join(".agent")).unwrap(); std::fs::write(project.path().join(".agent/manifest.json"), "{}").unwrap(); - let bridge = - super::super::direct_tool_bridge::start_direct_tool_bridge(project.path(), false) - .await - .unwrap(); + let bridge = super::super::direct_tool_bridge::start_external_client_tool_bridge( + project.path(), + false, + false, + ) + .await + .unwrap(); for (cocos_enabled, unity_enabled) in [(false, false), (true, false), (false, true), (true, true)] { @@ -2419,10 +2468,13 @@ mod tests { let project = crate::tests::canonical_test_tempdir("builtin-mcp-project-"); std::fs::create_dir_all(project.path().join(".agent")).unwrap(); std::fs::write(project.path().join(".agent/manifest.json"), "{}").unwrap(); - let bridge = - super::super::direct_tool_bridge::start_direct_tool_bridge(project.path(), false) - .await - .unwrap(); + let bridge = super::super::direct_tool_bridge::start_external_client_tool_bridge( + project.path(), + false, + false, + ) + .await + .unwrap(); for enabled in [false, true, false, true] { crate::builtin_plugins::set_enabled( crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs index d2b6f847b..a42aed1b0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs @@ -395,6 +395,137 @@ async fn finish_async( .map_err(|_| "验证回执任务退出".to_string())? } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct GameplayViewportEvidence { + viewport: crate::browser::BrowserValidationViewport, + passed: bool, + diagnostics: Vec, + assertions: Vec, + initial_phase: Option, + initial_sequence: Option, + initial_level: Option, + final_phase: Option, + final_sequence: Option, + final_level: Option, +} + +/// 只投影模型修复所需的玩法事实;完整报告继续留在宿主私有目录。 +pub(crate) fn browser_tool_result( + root: &Path, + evidence: &crate::browser::BrowserValidationResult, + scenario: Option, + sequence: usize, +) -> Value { + use crate::browser::BrowserValidationViewport::{Desktop, Mobile}; + + let gameplay_results = if scenario.is_some() { + [Desktop, Mobile] + .into_iter() + .map(|viewport| { + let result = evidence + .viewport_playtests + .iter() + .find(|entry| entry.viewport == viewport) + .map(|entry| &entry.result); + GameplayViewportEvidence { + viewport, + passed: result.is_some_and(|result| result.passed), + diagnostics: result + .map(|result| { + result + .diagnostics + .iter() + .take(4) + .map(|message| truncate_agent_runtime_text(message, 511)) + .collect() + }) + .unwrap_or_else(|| { + vec![prompt_text!("direct.browser.gameplayMissing").into()] + }), + assertions: result + .map(|result| result.assertions.clone()) + .unwrap_or_default(), + initial_phase: result.and_then(|result| result.initial_phase), + initial_sequence: result.and_then(|result| result.initial_sequence), + initial_level: result.and_then(|result| result.initial_level), + final_phase: result.and_then(|result| result.final_phase), + final_sequence: result.and_then(|result| result.final_sequence), + final_level: result.and_then(|result| result.final_level), + } + }) + .collect::>() + } else { + Vec::new() + }; + let gameplay_summary = if scenario.is_none() { + prompt_text!("direct.browser.gameplayNotRun").to_string() + } else { + let lines = gameplay_results + .iter() + .map(|result| { + format!( + prompt_text!("direct.browser.gameplayViewport"), + viewport = match result.viewport { + Desktop => "desktop", + Mobile => "mobile", + }, + passed = result.passed, + diagnostic = result + .diagnostics + .first() + .map(String::as_str) + .unwrap_or(prompt_text!("direct.browser.gameplayNoDiagnostic")), + assertion = result + .assertions + .iter() + .find(|assertion| !assertion.passed) + .map(|assertion| assertion.name.as_str()) + .unwrap_or(prompt_text!("direct.browser.gameplayNoUnmetAssertion")), + ) + }) + .collect::>(); + format!( + prompt_text!("direct.browser.gameplayEvidence"), + lines.join("\n") + ) + }; + let summary = format!( + "{}\n{}", + render_direct_browser_tool_evidence(root, evidence, sequence), + gameplay_summary + ); + let screenshots: Vec<_> = evidence + .viewport_results + .iter() + .filter_map(|viewport| { + viewport + .screenshot_path + .strip_prefix(root) + .ok() + .map(|path| path.to_string_lossy().replace('\\', "/")) + }) + .collect(); + let passed = evidence.passed + && scenario.is_none_or(|scenario| { + crate::browser::required_viewport_playtests_passed( + scenario, + &evidence.viewport_playtests, + ) + }); + json!({ + "kind":"browser", "mode":if scenario.is_some() {"gameplay"} else {"visual"}, + "scenario":scenario, "passed":passed, + "scenarioFingerprint":scenario.map(crate::browser::browser_playtest_scenario_fingerprint), + "summary":summary, "screenshots":screenshots, + "reportPath":evidence.evidence.report_path.strip_prefix(root).ok().map(|path|path.to_string_lossy().replace('\\', "/")), + "visualViewports":["desktop","mobile"], + "gameplayViewports":evidence.viewport_playtests.iter().map(|entry|entry.viewport).collect::>(), + "gameplayResults":gameplay_results, + "coverage":if scenario.is_some() {"dual-viewport-fixed-scenario"} else {"visual-only"} + }) +} + pub(super) async fn run_browser(root: &Path, arguments: &Value) -> Result { run_browser_with_budget(root, arguments, true).await } @@ -466,33 +597,9 @@ async fn run_browser_with_budget( .await; let (result, passed) = match evidence { Ok(evidence) => { - let screenshots: Vec<_> = evidence - .viewport_results - .iter() - .filter_map(|v| { - v.screenshot_path - .strip_prefix(root) - .ok() - .map(|p| p.to_string_lossy().replace('\\', "/")) - }) - .collect(); - let gameplay_passed = scenario.is_none_or(|scenario| { - crate::browser::required_viewport_playtests_passed( - scenario, - &evidence.viewport_playtests, - ) - }); - let passed = evidence.passed && gameplay_passed; - let summary = render_direct_browser_tool_evidence(root, &evidence, sequence); - ( - json!({"kind":"browser", "mode":input.mode, "scenario":scenario, - "scenarioFingerprint":scenario.map(crate::browser::browser_playtest_scenario_fingerprint), "summary":summary, "screenshots":screenshots, - "reportPath":evidence.evidence.report_path.strip_prefix(root).ok().map(|p|p.to_string_lossy().replace('\\', "/")), - "visualViewports":["desktop","mobile"], - "gameplayViewports":evidence.viewport_playtests.iter().map(|v|v.viewport).collect::>(), - "coverage":if scenario.is_some() {"dual-viewport-fixed-scenario"} else {"visual-only"}}), - passed, - ) + let result = browser_tool_result(root, &evidence, scenario, sequence); + let passed = result["passed"] == true; + (result, passed) } Err(error) => ( json!({"mode":input.mode,"needsReconciliation":error.starts_with("browser-cleanup-unconfirmed:"),"error":truncate_agent_runtime_text(&error, 1800)}), @@ -599,9 +706,147 @@ pub(super) async fn run_command(root: &Path, arguments: &Value) -> Result crate::browser::BrowserValidationResult { + let mut viewports = Vec::new(); + let mut playtests = Vec::new(); + for viewport in ["desktop", "mobile"] { + viewports.push(json!({ + "viewport":viewport, "width":1280, "height":720, + "finalUrl":"http://127.0.0.1/", "title":"fixture", "readyState":"complete", + "visibleTextSummary":"", "visibleTextCharacterCount":0, "domCharacterCount":0, + "expectedText":[], "consoleErrors":[], "consoleWarnings":[], "exceptions":[], + "failedRequests":[], "canvases":[], "diagnostics":[], "passed":true, + "blockedPopupCount":0, "blockedDialogCount":0, "blockedDownloadCount":0, + "blockedPermissionCount":0, "blockedServiceWorkerCount":0, + "screenshotPath":root.join(format!("{viewport}.png")) + })); + playtests.push(json!({"viewport":viewport, "result":{ + "scenario":"generic-v1", "scenarioFingerprint":"fixture", "passed":true, + "initialSequence":0, "initialPhase":"ready", "initialLevel":1, + "finalSequence":3, "finalPhase":"ready", "finalLevel":1, + "assertions":[ + {"name":"start-control-clicked", "passed":true}, + {"name":"start-phase-playing", "passed":true}, + {"name":"restart-control-clicked", "passed":true} + ], "diagnostics":[] + }})); + } + serde_json::from_value(json!({ + "schemaVersion":"browser-validation.v1", "url":"http://127.0.0.1/", + "browser":{"kind":"chrome", "product":"fixture", "protocolVersion":"1.3"}, + "passed":true, "viewportResults":viewports, "viewportPlaytests":playtests, + "diagnostics":[], "completedAtUnixMs":1, + "evidence":{"root":root, "reportPath":root.join("validation.json")} + })) + .unwrap() + } + + pub(crate) fn failed_browser_evidence_fixture( + root: &Path, + ) -> crate::browser::BrowserValidationResult { + let mut evidence = browser_evidence_fixture(root); + evidence.passed = false; + for (index, entry) in evidence.viewport_playtests.iter_mut().enumerate() { + entry.result.passed = false; + entry.result.final_phase = Some(crate::browser::BrowserPlaytestPhase::Ready); + entry.result.final_sequence = Some(index as u64); + entry.result.diagnostics = vec![if index == 0 { + "固定试玩控件 start 处于 disabled 状态".into() + } else { + "generic-v1 start 后必须进入 playing".into() + }]; + for (assertion_index, assertion) in entry.result.assertions.iter_mut().enumerate() { + assertion.passed = index == 1 && assertion_index == 0; + } + } + evidence + } + + #[test] + fn browser_tool_projection_distinguishes_gameplay_success_visual_only_and_missing_viewport() { + use crate::browser::BrowserPlaytestScenario::GenericV1; + let root = tempfile::tempdir().unwrap(); + for mode in ["gameplay", "visual", "missing-mobile"] { + let mut evidence = browser_evidence_fixture(root.path()); + let scenario = if mode == "visual" { + evidence.viewport_playtests.clear(); + None + } else { + Some(GenericV1) + }; + if mode == "missing-mobile" { + evidence.viewport_playtests.pop(); + evidence.passed = false; + } + let receipt = browser_tool_result(root.path(), &evidence, scenario, 1); + let summary = receipt["summary"].as_str().unwrap(); + assert_eq!(receipt["passed"], mode != "missing-mobile"); + if mode == "visual" { + assert_eq!(receipt["gameplayResults"], json!([])); + assert!(summary + .lines() + .any(|line| line.starts_with("玩法检查:未执行"))); + assert_eq!(receipt["coverage"], "visual-only"); + continue; + } + for (index, viewport) in ["desktop", "mobile"].iter().enumerate() { + let result = &receipt["gameplayResults"][index]; + assert_eq!(result["viewport"], *viewport); + if mode == "missing-mobile" && index == 1 { + assert_eq!(result["passed"], false); + assert!(result["diagnostics"][0].as_str().unwrap().contains("缺失")); + assert!(summary.contains("mobile: gameplayPassed=false")); + assert_eq!(result["assertions"], json!([])); + assert_eq!(result["finalPhase"], Value::Null); + } else { + assert_eq!(result["passed"], true); + assert_eq!(result["diagnostics"], json!([])); + assert!(result["assertions"] + .as_array() + .unwrap() + .iter() + .all(|a| a["passed"] == true)); + assert!(summary.contains(&format!("{viewport}: gameplayPassed=true"))); + } + } + } + } + + #[test] + fn browser_tool_projection_bounds_diagnostics_without_losing_either_blocker() { + let root = tempfile::tempdir().unwrap(); + let mut evidence = failed_browser_evidence_fixture(root.path()); + for viewport in &mut evidence.viewport_playtests { + viewport + .result + .diagnostics + .extend((0..8).map(|_| "额外诊断".repeat(200))); + } + let receipt = browser_tool_result( + root.path(), + &evidence, + Some(crate::browser::BrowserPlaytestScenario::GenericV1), + 1, + ); + for (index, entry) in evidence.viewport_playtests.iter().enumerate() { + let diagnostics = receipt["gameplayResults"][index]["diagnostics"] + .as_array() + .unwrap(); + assert_eq!(diagnostics.len(), 4); + assert_eq!(diagnostics[0], entry.result.diagnostics[0]); + assert!(diagnostics + .iter() + .all(|d| d.as_str().unwrap().chars().count() <= 512)); + assert!(receipt["summary"] + .as_str() + .unwrap() + .contains(&entry.result.diagnostics[0])); + } + } + fn project() -> (tempfile::TempDir, std::path::PathBuf) { let temp = tempfile::tempdir().unwrap(); let root = temp.path().join("project"); 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/browser/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs index 9d8733862..cb5d1cf8b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs @@ -1514,6 +1514,41 @@ async fn real_chrome_generic_playtest_pointer_events_keeps_unavailable_controls_ } } +#[tokio::test] +#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"] +async fn real_chrome_generic_playtest_reports_start_click_without_playing_phase() { + let result = run_stable_generic_viewport_fixture(false, Some( + r#""#, + )).await; + assert!(!result.passed); + assert!(result + .viewport_results + .iter() + .all(|viewport| viewport.passed)); + for entry in &result.viewport_playtests { + let playtest = &entry.result; + assert!(!playtest.passed); + assert_eq!(playtest.final_phase, Some(BrowserPlaytestPhase::Ready)); + assert_eq!(playtest.final_sequence, Some(1)); + for (name, expected) in [ + ("start-control-clicked", true), + ("start-phase-playing", false), + ] { + assert_eq!( + playtest + .assertions + .iter() + .find(|assertion| assertion.name == name) + .unwrap() + .passed, + expected + ); + } + let reason = &playtest.diagnostics[0]; + assert!(reason.contains("必须进入 playing"), "{reason}"); + } +} + async fn run_stable_generic_viewport_fixture( mobile_action_broken: bool, start_markup: Option<&str>, @@ -1556,7 +1591,7 @@ async fn run_stable_generic_viewport_fixture( }; document.querySelector('[data-playtest-id="start"]').addEventListener('click', (event) => { if (event.isTrusted && state.phase === 'ready') { - advance(() => { state.phase = 'playing'; }); + advance(() => { state.phase = event.currentTarget.dataset.startPhase || 'playing'; }); } }); const primary = document.querySelector('[data-playtest-id="primary-action"]'); @@ -1641,6 +1676,57 @@ async fn run_stable_generic_viewport_fixture( let persisted: serde_json::Value = serde_json::from_slice(&fs::read(&result.evidence.report_path).unwrap()).unwrap(); assert_eq!(persisted["viewportPlaytests"].as_array().unwrap().len(), 2); + let receipt = crate::agent::browser_tool_result( + evidence.path(), + &result, + Some(BrowserPlaytestScenario::GenericV1), + 1, + ); + assert_eq!(receipt["passed"], persisted["passed"]); + for (index, entry) in persisted["viewportPlaytests"] + .as_array() + .unwrap() + .iter() + .enumerate() + { + let projected = &receipt["gameplayResults"][index]; + assert_eq!(projected["viewport"], entry["viewport"]); + let viewport = entry["viewport"].as_str().unwrap(); + let line = receipt["summary"] + .as_str() + .unwrap() + .lines() + .find(|line| line.starts_with(&format!("{viewport}: gameplayPassed="))) + .unwrap(); + assert!(line.starts_with(&format!( + "{viewport}: gameplayPassed={}", + entry["result"]["passed"] + ))); + for field in [ + "passed", + "assertions", + "initialPhase", + "initialSequence", + "initialLevel", + "finalPhase", + "finalSequence", + "finalLevel", + ] { + assert_eq!(projected[field], entry["result"][field], "{field}"); + } + if let Some(reason) = entry["result"]["diagnostics"][0].as_str() { + assert_eq!(projected["diagnostics"][0], reason); + assert!(line.contains(reason)); + } + if let Some(assertion) = entry["result"]["assertions"] + .as_array() + .unwrap() + .iter() + .find(|assertion| assertion["passed"] == false) + { + assert!(line.contains(assertion["name"].as_str().unwrap())); + } + } result } 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-tauri/src/process_session/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs index c96ef974c..9c47ae81c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs @@ -630,12 +630,20 @@ fn process_session_graceful_terminate_keeps_wrapper_alive_for_target_cleanup() { let root = directory.path(); init_local_game_project_at(root, "graceful-process-project", "Graceful Process Project") .expect("initialize project"); + // leader 输出 READY 后 **等** 同组后台子进程,不自己先退出。terminate 必须先送到 trampoline + // 的 target group、再让组内后代把延迟清理跑完,这条断言才有确定性:leader 一旦先自然退出, + // trampoline 的 800ms 宽限就开始计时,客户端只要在那之后才发出 terminate(CI 高负载下调度 + // 完全可能 >800ms),会话就会先以 `exited` 收口,客户端再 terminate 只能读到既成事实。 + // 后台子进程仍留在同一进程组里、仍靠 TERM 触发延迟 400ms 的 marker 清理,语义不变。 + // 注意:这条用例的确定性靠的是「trap 里 400ms 清理 < 800ms 宽限」的时间余量(实测 0.41s), + // 不是真正的同步原语;若以后清理耗时逼近或超过宽限,应把 marker 拆成「收到 TERM 即时落盘 + + // 延迟内容」两步,或让宽限可注入,而不是放宽断言。 let spec = resolve_project_command_spec_at( root, "bash", &[ "-lc".to_string(), - "(trap 'sleep 0.4; printf done > graceful-marker.txt; exit 0' TERM; while :; do sleep 1; done) & printf 'READY\\n'; exit 0".to_string(), + "(trap 'sleep 0.4; printf done > graceful-marker.txt; exit 0' TERM; while :; do sleep 1; done) & printf 'READY\\n'; wait".to_string(), ], ".", 30, diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 1bd48562a..4aee35d18 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -71,6 +71,7 @@ import { isAbsoluteProjectPath, projectPathHasControlCharacter, } from './features/project-summary/projectSummary'; +import { insertChatReferences } from './features/project-workspace/activeChatComposer'; import { importDesignFiles } from './features/project-workspace/importDesignFiles'; import { needsInitializedChatProject, @@ -78,10 +79,13 @@ import { } from './features/project-workspace/projectCommandPolicy'; import type { ResourceReferenceInputHandle } from './features/project-workspace/ResourceReferenceInput'; import { + type ChatReference, directCodexContentToLegacyContentDto, hasMeaningfulDirectCodexContent, RESOURCE_REFERENCE_INSERT_EVENT, + RESOURCE_REFERENCE_INSERT_MANY_EVENT, type ResourceReferenceInsertEventDetail, + type ResourceReferenceInsertManyEventDetail, } from './features/project-workspace/resourceReferences'; import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog'; import { readGamePublishAvailability } from './services/gameDistributionPublish'; @@ -1273,22 +1277,54 @@ export function App({ } useEffect(() => { + /* + 画布引用只有这一处消费者:单条「引用」按钮与拖拽批量引用都从这里落进草稿。 + 落到哪份输入区由「活跃聊天输入区」注册表回答——普通项目挂 DirectProject、 + 策划链路挂策划面,事件本身不携带这个判断。 + + 插入失败(此刻没有可用的输入区——没挂载或已卸载)不再静默:dev 下留一行线索, + 否则用户看到的又是一次「点了没反应」。空批次不是失败,它没有要插的东西, + 也就不能把原因指到输入区上。 + */ + const insertReferences = (references: readonly ChatReference[]) => { + if (references.length === 0) return; + if (insertChatReferences(references)) return; + if (import.meta.env.DEV) { + console.warn( + '[resource-reference] 引用没有落进草稿:没有可用的聊天输入区(未挂载或已卸载)', + ); + } + }; const handleResourceReferenceInsert = (event: Event) => { const detail = (event as CustomEvent) .detail; if (!detail?.reference) return; - chatComposerRef.current?.insertReferences([detail.reference]); - chatComposerRef.current?.focus(); + insertReferences([detail.reference]); + }; + const handleResourceReferenceInsertMany = (event: Event) => { + const detail = ( + event as CustomEvent + ).detail; + insertReferences(detail?.references ?? []); }; window.addEventListener( RESOURCE_REFERENCE_INSERT_EVENT, handleResourceReferenceInsert, ); - return () => + window.addEventListener( + RESOURCE_REFERENCE_INSERT_MANY_EVENT, + handleResourceReferenceInsertMany, + ); + return () => { window.removeEventListener( RESOURCE_REFERENCE_INSERT_EVENT, handleResourceReferenceInsert, ); + window.removeEventListener( + RESOURCE_REFERENCE_INSERT_MANY_EVENT, + handleResourceReferenceInsertMany, + ); + }; }, []); /** diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/activeChatComposer.ts b/apps/ai-game-creator-shell/src/features/project-workspace/activeChatComposer.ts new file mode 100644 index 000000000..cc67b7a7b --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/activeChatComposer.ts @@ -0,0 +1,66 @@ +import type { ChatReference } from './resourceReferences'; + +/** + * 当前挂载的聊天输入区句柄:只保留「把引用插进草稿」这一件事。 + * + * `insertReferences` 回答**这一批有没有真的落进草稿**(输入区自己那份句柄可能还没挂上、 + * 或者已经被卸载),注册表按它决定成功还是失败,不让「注册表里有句柄」冒充「用户看得见 + * 的结果」。输入区自己还持有 `getDraft` / `clear` 之类的提交期能力,但那些只在所属面板 + * 内部用,不进这里——注册表只负责跨面板的引用插入。 + */ +export type ActiveChatComposerHandle = { + insertReferences: (references: ChatReference[]) => boolean; + focus: () => void; +}; + +/** + * 同一时刻只可能有一个聊天输入区挂载:普通项目走 DirectProject,立项策划走策划面, + * 两条链路互斥渲染(见 `App.tsx` 的 `directProjectMode`)。 + */ +let activeChatComposer: ActiveChatComposerHandle | null = null; + +/** + * 注册当前挂载的聊天输入区,返回注销函数。 + * + * 注销时按身份校验:新输入区已经接管、旧输入区才卸载时(切换项目、两条链路互换), + * 旧注销不能把新句柄一起清掉。 + * + * 两个输入区同时挂载属于调用方接线错误(本应互斥,见 `App.tsx` 的 `directProjectMode`): + * 后注册者会顶掉前者,引用会落进用户看不见的那份草稿。这里只留一条线索,不改运行时语义 + * ——注册表仍然按最后注册的那个工作,注销的身份校验也照旧兜住乱序卸载。 + */ +export function registerActiveChatComposer( + handle: ActiveChatComposerHandle, +): () => void { + if (activeChatComposer && activeChatComposer !== handle) { + if (import.meta.env.DEV) { + console.warn( + '[resource-reference] 检测到第二个聊天输入区注册:引用会插进最后注册的那一个', + ); + } + } + activeChatComposer = handle; + return () => { + if (activeChatComposer === handle) { + activeChatComposer = null; + } + }; +} + +/** + * 把一批引用插进当前挂载的聊天输入区,回答**有没有落进草稿**。 + * + * 三种情况都返回 `false`:空批次、此刻没有任何输入区挂载、注册表里的句柄已经插不进去 + * (它转发的那份输入区没挂上或已卸载)。这些都不能静默,由调用方(`App.tsx` 的事件监听) + * 决定怎么留痕或提示,注册表本身不吞;只有真的插进去了才把焦点交给输入区。 + */ +export function insertChatReferences( + references: readonly ChatReference[], +): boolean { + if (references.length === 0) return false; + const handle = activeChatComposer; + if (!handle) return false; + if (!handle.insertReferences([...references])) return false; + handle.focus(); + return true; +} 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/chat/DirectProjectChatView.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx index f2d345d75..a429ada6a 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx @@ -4,17 +4,22 @@ import { useEffect, useImperativeHandle, useMemo, + useRef, useState, } from 'react'; import { claimInitialTurnForPage } from '../../../app/initialTurnClaims'; import type { PendingUiConfirmation } from '../../../app/types'; import { projectNameFromPath } from '../../../features/agent-runtime'; +import { registerActiveChatComposer } from '../../../features/project-workspace/activeChatComposer'; import { userItemFromContent } from '../../../features/project-workspace/resourceReferences'; import { type ApprovalMode, approvalModeLabel } from '../approvalMode'; import { ApprovalModeDialog } from '../ApprovalModeDialog'; import { DirectProjectChatHeader } from './components/DirectProjectChatHeader/DirectProjectChatHeader'; -import { DirectProjectComposer } from './components/DirectProjectComposer/DirectProjectComposer'; +import { + DirectProjectComposer, + type DirectProjectComposerHandle, +} from './components/DirectProjectComposer/DirectProjectComposer'; import { DirectProjectConversation } from './components/DirectProjectConversation/DirectProjectConversation'; import { DirectProjectSettingsDialog } from './components/DirectProjectSettingsDialog/DirectProjectSettingsDialog'; import { @@ -103,6 +108,7 @@ export function DirectProjectChatView({ }: DirectProjectChatViewProps) { const { assets, projectId, refresh, versions } = useDirectProjectManifest(projectPath); + const composerHandleRef = useRef(null); const [runtimeNotice, setRuntimeNotice] = useState(''); const [settingsOpen, setSettingsOpen] = useState(false); const [approvalOpen, setApprovalOpen] = useState(false); @@ -212,6 +218,28 @@ export function DirectProjectChatView({ }, })); + /* + 画布的「引用」与拖拽批量引用是 window 事件,唯一消费者在 `App.tsx`;它只认注册表里 + **当前挂载**的输入区。普通项目固定渲染这里、策划链路渲染 `PlanningChatView`,两条链路 + 互斥,所以同一时刻注册表里只有一个句柄。 + + 注册的是一个**按 ref 转发**的句柄、且不依赖「输入区此刻已挂上」:注册只表达「这个聊天面 + 在用」,插入成功与否由转发那一刻的实际情况回答(`DirectProjectComposerHandle.insertReferences` + 返回 boolean)。这样挂载顺序、子组件重挂载都不会让注册表漏挂或指向死句柄。 + */ + useEffect( + () => + registerActiveChatComposer({ + insertReferences: (references) => { + const handle = composerHandleRef.current; + if (!handle) return false; + return handle.insertReferences(references); + }, + focus: () => composerHandleRef.current?.focus(), + }), + [], + ); + return (
) : null} Promise; + /** + * 输入区句柄出口:`DirectProjectChatView` 拿它注册「活跃聊天输入区」, + * 画布的「引用」/拖拽批量引用事件才能落进这份草稿。 + */ + ref?: Ref; }) { const composerRef = useRef(null); const composerRootRef = useRef(null); @@ -166,6 +189,24 @@ export function DirectProjectComposer({ }, [onUploadFiles], ); + /* + 对外句柄只做一层转发:内部输入区句柄会随编辑器重挂载换对象,这里按 ref 读最新值, + 句柄本身(注册表持有的那个)保持稳定。插入与聚焦都不自己实现,避免出现第二套草稿真相; + `insertReferences` 回答有没有真的递到输入区——注册表拿它区分「插进去了」和「这一步落空」。 + */ + useImperativeHandle( + ref, + () => ({ + insertReferences: (references: ChatReference[]) => { + const handle = composerRef.current; + if (!handle) return false; + handle.insertReferences([...references]); + return true; + }, + focus: () => composerRef.current?.focus(), + }), + [], + ); // 这个按钮只在"没有可终止对象"的分支出现(有的话由 ComposerStopButton 接管), // 所以不再带忙态逻辑:队列里有待发消息时它照样在,用户能继续排。 const submitButton = ( diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts index 05bbc90c6..93b3ba4c2 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts @@ -96,9 +96,7 @@ function directModelCallText(kind: ModelCallKind | null | undefined): string { case 'upstreamFailed': return ( (kind.native ? directNativeKindText(kind.native) : null) ?? - (kind.statusCode >= 500 - ? '智能服务暂时不可用,请稍后重试' - : '智能创作请求无效,请稍后重试') + directUpstreamStatusText(kind.statusCode) ); case 'paidCreditsInsufficient': return '泥点余额不足,本轮游戏生成已中断。请充值后发送“继续”,系统会从当前项目进度接着完成。'; @@ -113,6 +111,23 @@ function directModelCallText(kind: ModelCallKind | null | undefined): string { } } +function directUpstreamStatusText(statusCode: number): string { + switch (statusCode) { + case 401: + return '上游服务拒绝认证(HTTP 401),请重新登录后重试'; + case 403: + return '上游服务拒绝访问(HTTP 403),请检查账号权限后重试'; + case 408: + return '上游请求超时(HTTP 408),请稍后重试'; + case 429: + return '上游服务繁忙(HTTP 429),请稍后重试'; + default: + return statusCode >= 500 + ? `智能服务暂时不可用(HTTP ${statusCode}),请稍后重试` + : `智能创作请求被上游拒绝(HTTP ${statusCode}),请检查请求后重试`; + } +} + function directTurnDeadlineText(deadline: Deadline): string { switch (deadline) { case 'response-idle': 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/planning/PlanningChatView.tsx b/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx index 7e35bc9a0..928d75d03 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx @@ -1,6 +1,6 @@ import { ArrowUp, FileUp, Loader2 } from 'lucide-react'; import type { FormEventHandler, RefObject, UIEventHandler } from 'react'; -import { useMemo, useRef } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import { AgentMessageContent } from '../../../../../../packages/shared/src/components/AgentMessageContent'; import type { @@ -15,6 +15,7 @@ import { projectNameFromPath, projectRuntimeVisibleError, } from '../../../features/agent-runtime'; +import { registerActiveChatComposer } from '../../../features/project-workspace/activeChatComposer'; import { ConversationModelSelect } from '../../../features/project-workspace/ConversationModelSelect'; import { attachmentReferenceProvider } from '../../../features/project-workspace/reference-source/attachmentReferenceProvider'; import { createResourceReferenceProvider } from '../../../features/project-workspace/reference-source/resourceReferenceProvider'; @@ -133,6 +134,23 @@ export function PlanningChatView({ onDesignRetry, }: PlanningChatViewProps) { const designFileInputRef = useRef(null); + /* + 策划输入盒也进「活跃聊天输入区」注册表:画布的引用事件消费者只有 `App.tsx` 一处, + 它不关心当前挂哪条链路。句柄按 ref 转发(`composerRef` 指向的是输入区自己那份 + 可变句柄),注册的那个包装对象因此永远读到最新值,并由它回答「这一批有没有真的插进去」。 + */ + useEffect(() => { + if (!composerRef) return; + return registerActiveChatComposer({ + insertReferences: (references) => { + const handle = composerRef.current; + if (!handle) return false; + handle.insertReferences([...references]); + return true; + }, + focus: () => composerRef.current?.focus(), + }); + }, [composerRef]); /* 策划输入盒的引用来源:**只注入资源与两个静默 provider**。 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/ResourceDependencyOverlay.test.ts b/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts index bc73e068c..9547282a9 100644 --- a/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts +++ b/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts @@ -11,8 +11,11 @@ import { import React from 'react'; import { describe, expect, it, vi } from 'vitest'; -import type { ProjectResourceCanvasPosition } from '../../../packages/shared/src/contracts/gameCreationApp'; -import type { ProjectResourceCanvasCategory } from '../../../packages/shared/src/contracts/gameCreationApp'; +import { + isProjectResourceCanvasCategory, + type ProjectResourceCanvasCategory, + type ProjectResourceCanvasPosition, +} from '../../../packages/shared/src/contracts/gameCreationApp'; import { RESOURCE_CANVAS_CARD_WIDTH, RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP, @@ -130,7 +133,11 @@ function OverlayHarness({ overlayRef?: React.Ref; }) { const sections = Array.from( - new Set(positions.map((position) => position.section)), + new Set( + positions + .map((position) => position.section) + .filter(isProjectResourceCanvasCategory), + ), ); return React.createElement( 'div', @@ -809,7 +816,7 @@ describe('ResourceDependencyOverlay', () => { it('keeps drag previews in plane-local coordinates', async () => { const getBoundingClientRect = vi .spyOn(HTMLElement.prototype, 'getBoundingClientRect') - .mockImplementation(function () { + .mockImplementation(function (this: HTMLElement) { const isPlane = this.hasAttribute('data-resource-section-plane'); return { x: isPlane ? 160 : 20, @@ -1046,7 +1053,9 @@ describe('ResourceDependencyOverlay', () => { .map((value) => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4, ); - return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722; + return ( + channels[0]! * 0.2126 + channels[1]! * 0.7152 + channels[2]! * 0.0722 + ); }; const lineLuminance = luminance('c45f20'); const canvasLuminance = luminance('fffdfa'); diff --git a/apps/ai-game-creator-shell/tests/ResourceEditSurface.test.tsx b/apps/ai-game-creator-shell/tests/ResourceEditSurface.test.tsx index 4ff959c69..49c1d78f9 100644 --- a/apps/ai-game-creator-shell/tests/ResourceEditSurface.test.tsx +++ b/apps/ai-game-creator-shell/tests/ResourceEditSurface.test.tsx @@ -2,13 +2,16 @@ import React from 'react'; import { describe, expect, it, vi } from 'vitest'; -import { ResourceEditSurface } from '../src/view/project-development/ResourceEditSurface'; +import { + type ResourceEditSubmitInput, + ResourceEditSurface, +} from '../src/view/project-development/ResourceEditSurface'; import { fireEvent, render, screen, waitFor } from './appSurface/harness'; describe('统一资源编辑面板', () => { it('失败后锁定原请求字段并使用相同输入重试', async () => { const submit = vi - .fn<(input: { prompt: string; assetName: string }) => Promise>() + .fn<[input: ResourceEditSubmitInput], Promise>() .mockRejectedValueOnce(new Error('网络暂不可用')) .mockResolvedValueOnce(undefined); render( diff --git a/apps/ai-game-creator-shell/tests/accountHost.test.ts b/apps/ai-game-creator-shell/tests/accountHost.test.ts index 7912089d3..7b9a93d73 100644 --- a/apps/ai-game-creator-shell/tests/accountHost.test.ts +++ b/apps/ai-game-creator-shell/tests/accountHost.test.ts @@ -14,9 +14,9 @@ const platformSession = vi.hoisted(() => ({ })); // 与仓库其它 App 内调用方同口径:通过 `window.__TAURI__.core.invoke` 解析原生桥。 -window.__TAURI__ = { +installTauriRuntime({ core: { invoke: (...args: unknown[]) => invoke(...args) }, -} as never; +}); vi.mock('../src/services/platformSession', () => ({ currentPlatformSessionGeneration: () => platformSession.generation, requestPlatformSessionRefresh: (...args: unknown[]) => @@ -31,6 +31,7 @@ import { getClientProfileWalletLedger, redeemClientProfileRewardCode, } from '../src/services/accountHost'; +import { installTauriRuntime } from './tauriRuntimeFake'; beforeEach(() => { invoke.mockReset(); diff --git a/apps/ai-game-creator-shell/tests/activeChatComposer.test.ts b/apps/ai-game-creator-shell/tests/activeChatComposer.test.ts new file mode 100644 index 000000000..759a376e3 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/activeChatComposer.test.ts @@ -0,0 +1,137 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + type ActiveChatComposerHandle, + insertChatReferences, + registerActiveChatComposer, +} from '../src/features/project-workspace/activeChatComposer'; +import type { ChatReference } from '../src/features/project-workspace/resourceReferences'; + +function resourceReference(resourceId: string): ChatReference { + return { + type: 'resource', + resourceId, + kind: 'character', + mediaType: 'image/png', + label: resourceId, + category: 'character', + tags: [], + source: 'resource-card', + }; +} + +/** 一个「插入总是成功」的输入区句柄替身;`inserted` 为 false 时模拟输入区已不在位。 */ +function composerHandle(inserted = true): { + handle: ActiveChatComposerHandle; + insertReferences: ReturnType; + focus: ReturnType; +} { + const insertReferences = vi.fn(() => inserted); + const focus = vi.fn(); + return { handle: { insertReferences, focus }, insertReferences, focus }; +} + +/** + * 「活跃聊天输入区」注册表的合同。 + * + * 这些语义就是 `App.tsx` 那处监听区分「引用落进草稿」与「这一步落空(要留痕)」的唯一依据, + * 所以在这里钉死;链路本身的端到端断言在 `resourceCanvasChatReferenceDrop.test.tsx` 与 + * `appSurface/*.suite.ts`。 + */ +describe('活跃聊天输入区注册表', () => { + // 注册表是模块级单例:用例之间靠对称注销回到空态,只在这里清 mock 记录。 + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('没有任何输入区挂载时插入返回 false', () => { + expect(insertChatReferences([resourceReference('hero')])).toBe(false); + }); + + it('空批次不算成功,也不打扰已挂载的输入区', () => { + const { handle, insertReferences, focus } = composerHandle(); + const unregister = registerActiveChatComposer(handle); + expect(insertChatReferences([])).toBe(false); + expect(insertReferences).not.toHaveBeenCalled(); + expect(focus).not.toHaveBeenCalled(); + unregister(); + }); + + it('挂载期间:整批一次交给输入区、插入成功后才聚焦,返回 true', () => { + const { handle, insertReferences, focus } = composerHandle(); + const unregister = registerActiveChatComposer(handle); + expect( + insertChatReferences([ + resourceReference('hero'), + resourceReference('npc'), + ]), + ).toBe(true); + expect(insertReferences).toHaveBeenCalledTimes(1); + expect(insertReferences).toHaveBeenCalledWith([ + expect.objectContaining({ resourceId: 'hero' }), + expect.objectContaining({ resourceId: 'npc' }), + ]); + expect(focus).toHaveBeenCalledTimes(1); + unregister(); + }); + + it('句柄报「这一批没插进去」时返回 false,且不抢焦点', () => { + const { handle, focus } = composerHandle(false); + const unregister = registerActiveChatComposer(handle); + expect(insertChatReferences([resourceReference('hero')])).toBe(false); + expect(focus).not.toHaveBeenCalled(); + unregister(); + }); + + it('注销按身份校验:旧输入区卸载不会把已经接管的新输入区一起清掉', () => { + // 这条用例故意让两个句柄同时在册(重复注册的告警本身由下一条用例覆盖),先静音。 + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const first = composerHandle(); + const second = composerHandle(); + const unregisterFirst = registerActiveChatComposer(first.handle); + const unregisterSecond = registerActiveChatComposer(second.handle); + + // 旧链路后卸载(切换项目 / 两条链路互换时可能发生):新句柄必须留在注册表里。 + unregisterFirst(); + expect(insertChatReferences([resourceReference('hero')])).toBe(true); + expect(second.insertReferences).toHaveBeenCalledTimes(1); + expect(first.insertReferences).not.toHaveBeenCalled(); + + unregisterSecond(); + expect(insertChatReferences([resourceReference('hero')])).toBe(false); + }); + + it('重复注册:后注册者接管并留一条告警,乱序注销也清不掉更新的句柄', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const first = composerHandle(); + const second = composerHandle(); + const third = composerHandle(); + + const unregisterFirst = registerActiveChatComposer(first.handle); + expect(warn).not.toHaveBeenCalled(); + + // 第二个注册:接线错误(两个聊天面同时挂载),后注册者接管,只在 dev 留线索。 + const unregisterSecond = registerActiveChatComposer(second.handle); + expect(warn.mock.calls.map((call) => String(call[0]))).toEqual([ + expect.stringContaining('第二个聊天输入区注册'), + ]); + expect(insertChatReferences([resourceReference('hero')])).toBe(true); + expect(second.insertReferences).toHaveBeenCalledTimes(1); + + // 再注册第三个(模拟又一条链路接管):同样留线索。 + const unregisterThird = registerActiveChatComposer(third.handle); + expect(warn).toHaveBeenCalledTimes(2); + + // 乱序注销:第一个、第二个先卸载,都不能清掉当前接管的第三个。 + unregisterFirst(); + unregisterSecond(); + expect(insertChatReferences([resourceReference('npc')])).toBe(true); + expect(third.insertReferences).toHaveBeenCalledTimes(1); + expect(first.insertReferences).not.toHaveBeenCalled(); + expect(second.insertReferences).toHaveBeenCalledTimes(1); + + unregisterThird(); + expect(insertChatReferences([resourceReference('hero')])).toBe(false); + warn.mockRestore(); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts index 24b61408c..c1690feb9 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts @@ -10,76 +10,51 @@ import { installUnhandledRejectionBridge } from '../unhandledRejectionBridge'; import { act, AuthenticatedClient, + createTauriEventBridge, expect, fireEvent, + installTauriRuntime, it, React, render, + resetTauriRuntime, screen, testAuthUser, vi, waitFor, } from './harness'; -type AuthEventBridge = { - listeners: Map void>; - emit: (event: string, payload: unknown) => void; -}; - /** * 注入 Tauri 事件替身。 * * jsdom 没有 `__TAURI_INTERNALS__`,`subscribeTauriEvent` 会退回 - * `window.__TAURI__.event.listen`,所以这里按同一入口登记回调。 + * `window.__TAURI__.event.listen`,所以按同一入口登记回调:安装走 + * harness.installTauriRuntime,事件注册 / 派发由 harness.createTauriEventBridge 负责。 */ -function installTauriBridge( - invoke: (command: string, args?: Record) => Promise, - options: { withEvents?: boolean } = {}, -): AuthEventBridge { - const listeners = new Map void>(); - window.__TAURI__ = { - core: { invoke }, - ...(options.withEvents - ? { - event: { - listen: async ( - event: string, - handler: (payload: { payload: unknown }) => void, - ) => { - listeners.set(event, handler); - return () => listeners.delete(event); - }, - }, - } - : {}), - } as never; - return { - listeners, - emit: (event, payload) => { - const handler = listeners.get(event); - if (handler) handler({ payload }); - }, - }; -} - function renderAuthSurface( invoke: (command: string, args?: Record) => Promise, options: { withEvents?: boolean } = {}, ) { - const bridge = installTauriBridge(invoke, options); + const bridge = createTauriEventBridge(); + installTauriRuntime({ + core: { invoke }, + ...(options.withEvents ? { event: { listen: bridge.listen } } : {}), + }); render( - React.createElement(AuthenticatedClient, null, ({ user, logout }) => - React.createElement( - 'main', - { 'aria-label': '已登录' }, - React.createElement('span', null, user.displayName), + // React 19 的 createElement 的 children 形参只接受 ReactNode,渲染函数子节点改传进 props。 + React.createElement(AuthenticatedClient, { + children: ({ user, logout }) => React.createElement( - 'button', - { type: 'button', onClick: logout }, - '退出', + 'main', + { 'aria-label': '已登录' }, + React.createElement('span', null, user.displayName), + React.createElement( + 'button', + { type: 'button', onClick: logout }, + '退出', + ), ), - ), - ), + }), ); return bridge; } @@ -118,7 +93,7 @@ function captureWindowRejections() { export function registerAuthTests() { afterEach(() => { resetPlatformSessionForTests(); - delete window.__TAURI__; + resetTauriRuntime(); }); it('throws a startup timeout through to unhandledrejection and leaves the loading state', async () => { diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index e8c6f5ee7..3d875e617 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -21,6 +21,7 @@ import { createProjectChatRuntimeHarness, expect, fireEvent, + installTauriRuntime, it, pickProjectFromLauncher, React, @@ -108,10 +109,10 @@ async function openDirectCodexSurface( return chatHarness.invoke(command, args); }, ); - window.__TAURI__ = { + installTauriRuntime({ core: { invoke }, event: { listen: chatHarness.listen }, - }; + }); renderLauncherProjectsAt('/?launcher'); pickProjectFromLauncher(DYNAMIC_GAME_PROJECT_PATH); const surface = await screen.findByLabelText('陶泥儿项目对话'); @@ -325,7 +326,7 @@ export function registerChatComposerControlTests() { const manifestAssets = [ { id: 'asset:hero', - kind: 'character', + kind: 'character' as const, mediaType: 'image/png', localPath: 'assets/hero.png', source: { kind: 'uploaded' as const }, diff --git a/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts index d96c09714..f19d826ae 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts @@ -1,9 +1,13 @@ +import type { Mock } from 'vitest'; + +import { dispatchResourceReferenceInsert } from '../../src/features/project-workspace/resourceReferences'; import { act, App, createProjectChatRuntimeHarness, expect, fireEvent, + installTauriRuntime, it, React, render, @@ -12,16 +16,43 @@ import { waitFor, } from './harness'; +type ChatRuntimeHarness = ReturnType; + +/** + * 把夹具的 invoke / listen 装进 `window.__TAURI__`:转换点只有 harness.installTauriRuntime 一处。 + */ +function installChatRuntime(harness: ChatRuntimeHarness) { + installTauriRuntime({ + core: { invoke: harness.invoke }, + event: { listen: harness.listen }, + }); +} + +/** + * harness.invoke 的返回类型被 harness 内全部命令分支收敛成字面量联合,用例覆盖命令时要返回 + * 联合外的自定义值(例如导入文件路径),这里按固定签名 mock 取用,只放宽返回值类型。 + */ +type LooseInvokeMock = Mock< + [command: string, args?: Record], + Promise +>; + +function looseInvoke(harness: ChatRuntimeHarness): LooseInvokeMock { + return harness.invoke as unknown as LooseInvokeMock; +} + +// App 的 props 类型未导出,React 19 的 createElement 重载推断不出参数类型,这里显式转成组件类型。 +const AppComponent = App as React.ComponentType< + NonNullable> +>; + function renderDesignAgent( harness: ReturnType, ) { - window.__TAURI__ = { - core: { invoke: harness.invoke }, - event: { listen: harness.listen }, - }; + installChatRuntime(harness); window.history.pushState({}, '', '/'); return render( - React.createElement(App, { + React.createElement(AppComponent, { initialProjectPath: harness.projectPath, planningStartMode: true, }), @@ -127,13 +158,14 @@ export function registerDesignAgentSurfaceTests() { designAgentView: designConversationView(), designAgentContinueView: designConversationView(), }); - const originalInvoke = harness.invoke.getMockImplementation()!; + const invoke = looseInvoke(harness); + const originalInvoke = invoke.getMockImplementation()!; let failImport = true; let finishImport!: () => void; const importGate = new Promise((resolve) => { finishImport = resolve; }); - harness.invoke.mockImplementation(async (command, args) => { + invoke.mockImplementation(async (command, args) => { if (command === 'import_design_workspace_file') { await importGate; if (args?.fileName === '补充.md' && failImport) @@ -224,8 +256,9 @@ export function registerDesignAgentSurfaceTests() { const harness = createProjectChatRuntimeHarness({ designAgentView: designApprovalView(), }); - const originalInvoke = harness.invoke.getMockImplementation()!; - harness.invoke.mockImplementation(async (command, args) => + const invoke = looseInvoke(harness); + const originalInvoke = invoke.getMockImplementation()!; + invoke.mockImplementation(async (command, args) => command === 'import_design_workspace_file' ? 'references/需求.md' : originalInvoke(command, args), @@ -367,13 +400,10 @@ export function registerDesignAgentSurfaceTests() { ], }, }); - window.__TAURI__ = { - core: { invoke: harness.invoke }, - event: { listen: harness.listen }, - }; + installChatRuntime(harness); window.history.pushState({}, '', '/'); render( - React.createElement(App, { + React.createElement(AppComponent, { initialProjectPath: harness.projectPath, planningStartMode: true, }), @@ -411,9 +441,10 @@ export function registerDesignAgentSurfaceTests() { const harness = createProjectChatRuntimeHarness({ designAgentView: designConversationView(), }); - const originalInvoke = harness.invoke.getMockImplementation()!; + const invoke = looseInvoke(harness); + const originalInvoke = invoke.getMockImplementation()!; let finish!: (view: unknown) => void; - harness.invoke.mockImplementation((command, args) => + invoke.mockImplementation((command, args) => command === 'continue_design_agent_session' ? new Promise((resolve) => { finish = resolve; @@ -467,12 +498,13 @@ export function registerDesignAgentSurfaceTests() { const harness = createProjectChatRuntimeHarness({ designAgentView: { ...designConversationView(), messages: [history] }, }); - const originalInvoke = harness.invoke.getMockImplementation()!; + const invoke = looseInvoke(harness); + const originalInvoke = invoke.getMockImplementation()!; const requests: { clientTurnId: string; finish: (view: unknown) => void; }[] = []; - harness.invoke.mockImplementation((command, args) => + invoke.mockImplementation((command, args) => command === 'continue_design_agent_session' ? new Promise((resolve) => { requests.push({ @@ -489,7 +521,7 @@ export function registerDesignAgentSurfaceTests() { await setComposerText(input, '继续'); fireEvent.submit(input.closest('form')!); await waitFor(() => expect(requests).toHaveLength(1)); - const request = requests[0]; + const request = requests[0]!; const messageId = `${request.clientTurnId}:response:0`; const reply = '整块返回也逐步显示'; const terminal = { @@ -543,7 +575,7 @@ export function registerDesignAgentSurfaceTests() { screen.getByRole('button', { name: '思考中' }).hasAttribute('disabled'), ).toBe(true); expect(screen.queryByText('迟到旧回复')).toBeNull(); - await act(async () => requests[1].finish(terminal)); + await act(async () => requests[1]!.finish(terminal)); }); } @@ -732,13 +764,10 @@ export function registerDesignAgentSurfaceTests() { const harness = createProjectChatRuntimeHarness({ designAgentView: designApprovalView(), }); - window.__TAURI__ = { - core: { invoke: harness.invoke }, - event: { listen: harness.listen }, - }; + installChatRuntime(harness); window.history.pushState({}, '', '/'); render( - React.createElement(App, { + React.createElement(AppComponent, { initialProjectPath: harness.projectPath, planningStartMode: true, }), @@ -778,13 +807,10 @@ export function registerDesignAgentSurfaceTests() { }, }, }); - window.__TAURI__ = { - core: { invoke: harness.invoke }, - event: { listen: harness.listen }, - }; + installChatRuntime(harness); window.history.pushState({}, '', '/'); render( - React.createElement(App, { + React.createElement(AppComponent, { initialProjectPath: harness.projectPath, planningStartMode: true, }), @@ -816,13 +842,10 @@ export function registerDesignAgentSurfaceTests() { designAgentView: designConversationView(), designAgentContinueView: designConversationView(), }); - window.__TAURI__ = { - core: { invoke: harness.invoke }, - event: { listen: harness.listen }, - }; + installChatRuntime(harness); window.history.pushState({}, '', '/'); render( - React.createElement(App, { + React.createElement(AppComponent, { initialProjectPath: harness.projectPath, planningStartMode: true, }), @@ -886,13 +909,10 @@ export function registerDesignAgentSurfaceTests() { const harness = createProjectChatRuntimeHarness({ designAgentView: designHistoryView(), }); - window.__TAURI__ = { - core: { invoke: harness.invoke }, - event: { listen: harness.listen }, - }; + installChatRuntime(harness); window.history.pushState({}, '', '/'); render( - React.createElement(App, { + React.createElement(AppComponent, { initialProjectPath: harness.projectPath, planningStartMode: true, }), @@ -911,9 +931,40 @@ export function registerDesignAgentSurfaceTests() { (element) => element.getAttribute('data-agent-content') === 'process', ), ).toBe(true); - fireEvent.click(summaries[0]); - expect(details[0].open).toBe(true); - expect(details[1].open).toBe(false); - await waitFor(() => expect(summaries[0].textContent).toBe('思考过程')); + fireEvent.click(summaries[0]!); + expect(details[0]!.open).toBe(true); + expect(details[1]!.open).toBe(false); + await waitFor(() => expect(summaries[0]!.textContent).toBe('思考过程')); + }); + + it('画布派发的「引用」落进策划输入盒草稿', async () => { + const harness = createProjectChatRuntimeHarness({ + initialSessionExists: false, + }); + renderDesignAgent(harness); + const editor = await screen.findByLabelText('项目需求'); + + // 资源画布唯一的生产入口就是这个 window 事件;消费者在 `App.tsx`,落到哪份输入盒由 + // 「活跃聊天输入区」注册表回答。策划链路与普通项目共用一个消费者,这条用例钉的是 + // 它在 `PlanningChatView` 这一侧也真的进了草稿。 + act(() => { + dispatchResourceReferenceInsert({ + type: 'resource', + resourceId: 'planning-hero', + kind: 'character', + mediaType: 'image/png', + label: 'hero.png', + category: 'character', + tags: [], + source: 'resource-card', + }); + }); + + await waitFor(() => + expect( + editor.querySelectorAll('[data-resource-reference-id="planning-hero"]'), + ).toHaveLength(1), + ); + expect(editor.textContent).toContain('@hero.png'); }); } diff --git a/apps/ai-game-creator-shell/tests/appSurface/harness.ts b/apps/ai-game-creator-shell/tests/appSurface/harness.ts index 6f51990da..63ba99d30 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/harness.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/harness.ts @@ -15,6 +15,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { resetLlmModelCatalogCacheForTest } from '../../src/services/llmModelCatalog'; import { useLauncherHomeDraftStore } from '../../src/view/home/useHomeDraftStore'; +import { + createTauriEventBridge, + installTauriRuntime, + resetTauriRuntime, +} from '../tauriRuntimeFake'; const nativeClipboardMock = vi.hoisted(() => ({ text: '', @@ -195,20 +200,22 @@ function pickProjectFromLauncher( projectPath: string, action: '打开项目' | '新建项目' = '打开项目', ) { - const invoke = window.__TAURI__?.core?.invoke; - if (!invoke) { + const tauri = window.__TAURI__; + const invoke = tauri?.core?.invoke; + if (!tauri || !invoke) { throw new Error('Tauri invoke is not configured'); } - window.__TAURI__ = { - ...window.__TAURI__, + installTauriRuntime({ + ...tauri, core: { - ...window.__TAURI__.core, - invoke: async (command, args) => + ...tauri.core, + // 泛型 invoke 无法由非泛型包装函数精确表达:命中目录选择命令时固定返回路径,其余转发。 + invoke: (async (command: string, args?: Record) => command === 'pick_local_project_directory' ? projectPath - : invoke(command, args), + : invoke(command, args)) as unknown as typeof invoke, }, - }; + }); fireEvent.click(screen.getByRole('button', { name: action })); } @@ -787,13 +794,13 @@ function createProjectChatRuntimeHarness({ manifestInvalidatedHandlers.delete( handler as unknown as ManifestInvalidatedHandler, ); - if (progressHandler === handler) { + if ((progressHandler as unknown) === handler) { progressHandler = null; } - if (designAgentUpdateHandler === handler) { + if ((designAgentUpdateHandler as unknown) === handler) { designAgentUpdateHandler = null; } - if (directThreadNotifyHandler === handler) { + if ((directThreadNotifyHandler as unknown) === handler) { directThreadNotifyHandler = null; } }; @@ -933,6 +940,14 @@ function createProjectChatRuntimeHarness({ }; } +/** + * 安装测试用 Tauri 替身。 + * + * 定义已收进 `tests/tauriRuntimeFake.ts`(全仓唯一安装点),这里只转出,供 appSurface 各 + * suite 沿用既有的 `./harness` 导入路径。 + */ +export { createTauriEventBridge, installTauriRuntime, resetTauriRuntime }; + export function installResizeObserverStub() { let observerCount = 0; let observerDisconnected = false; @@ -1000,7 +1015,7 @@ afterEach(() => { window.history.pushState({}, '', '/'); window.localStorage.clear(); window.sessionStorage.clear(); - delete window.__TAURI__; + resetTauriRuntime(); vi.restoreAllMocks(); if (originalCreateObjectUrl) { diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 800a6ce7d..98627b517 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -11,6 +11,7 @@ import { findResourceSelectButton, fireEvent, getResourceSelectButton, + installTauriRuntime, it, nativeClipboardMock, pickProjectFromLauncher, @@ -28,6 +29,12 @@ import { within, } from './harness'; +// App 的 props 参数带默认值,`React.ComponentProps` 会带上 undefined, +// createElement 因此推断不出 props;这里收窄成非空组件类型,运行时仍是同一个组件。 +const TestApp = App as React.ComponentType< + NonNullable> +>; + /** * 外壳级浮层提示的挂点。 * @@ -126,7 +133,7 @@ export function registerClientHomeTests() { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherAt('/?launcher'); // 挂载即加载目录,触发按钮直接落在默认模型上,不出现「选择模型」空态。 @@ -174,7 +181,7 @@ export function registerClientHomeTests() { if (command === 'read_game_creator_app_config') return { config: {} }; throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherAt('/?launcher'); await waitFor(() => expect(invoke).toHaveBeenCalledWith('get_game_template_library_access'), @@ -256,7 +263,7 @@ export function registerClientHomeTests() { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherAt('/?launcher'); // 首页推荐位只展示封面、标题、运行时与已下载徽标,本机灵感图库已随模板库上线删除。 @@ -308,7 +315,7 @@ export function registerClientHomeTests() { assets: [ { id: 'live-hero', - kind: 'icon-spritesheet', + kind: 'icon-spritesheet' as const, mediaType: 'image/png', localPath: 'assets/live-hero.png', source: { kind: 'generated' as const, taskId: 'art-asset-plan' }, @@ -398,7 +405,7 @@ export function registerClientHomeTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(WorkspaceLauncherShell, { currentUser: testAuthUser, @@ -540,10 +547,10 @@ export function registerClientHomeTests() { return runtimeHarness.invoke(command, args); }, ); - window.__TAURI__ = { + installTauriRuntime({ core: { invoke }, event: { listen: runtimeHarness.listen }, - }; + }); render( React.createElement(WorkspaceLauncherShell, { currentUser: testAuthUser, @@ -713,10 +720,10 @@ export function registerClientHomeTests() { return runtimeHarness.invoke(command, args); }, ); - window.__TAURI__ = { + installTauriRuntime({ core: { invoke }, event: { listen: runtimeHarness.listen }, - }; + }); render( React.createElement(WorkspaceLauncherShell, { currentUser: testAuthUser, @@ -820,7 +827,7 @@ export function registerClientHomeTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherProjectsAt('/?launcher'); expect(screen.getByLabelText('GameAgent 客户端')).not.toBeNull(); @@ -1017,7 +1024,7 @@ export function registerClientHomeTests() { return undefined; }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '充值' })); @@ -1117,7 +1124,7 @@ export function registerClientHomeTests() { } return undefined; }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '我的' })); @@ -1166,7 +1173,9 @@ export function registerClientHomeTests() { export function registerHomeProjectCreationTests() { it('scopes the 策划补全 option to the game entry and clears it on creation-type switch', () => { - window.__TAURI__ = { core: { invoke: vi.fn(async () => null) } }; + installTauriRuntime({ + core: { invoke: vi.fn(async () => null) }, + }); renderLauncherAt('/?launcher', 'home', true); const creationTypes = screen.getByRole('group', { name: '创作类型' }); @@ -1223,10 +1232,10 @@ export function registerHomeProjectCreationTests() { return chatHarness.invoke(command, args); }, ); - window.__TAURI__ = { + installTauriRuntime({ core: { invoke }, event: { listen: chatHarness.listen }, - }; + }); renderLauncherAt('/?launcher', 'home', true); fireEvent.click(screen.getByRole('checkbox', { name: '策划补全' })); @@ -1279,7 +1288,7 @@ export function registerHomeProjectCreationTests() { 'genarrative-ai-game-creator.recent-workspaces.v1', JSON.stringify(['/tmp/authorized-game']), ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherProjectsAt('/?launcher'); expect(await screen.findByText('authorized-game')).not.toBeNull(); @@ -1329,7 +1338,7 @@ export function registerHomeProjectCreationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherProjectsAt('/?launcher'); pickProjectFromLauncher('/tmp/missing-game'); @@ -1371,7 +1380,7 @@ export function registerHomeProjectCreationTests() { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherProjectsAt('/?launcher'); @@ -1424,7 +1433,7 @@ export function registerHomeProjectCreationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherProjectsAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '打开项目' })); @@ -1457,7 +1466,7 @@ export function registerHomeProjectCreationTests() { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherProjectsAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '打开项目' })); @@ -1477,7 +1486,7 @@ export function registerHomeProjectCreationTests() { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherProjectsAt('/?launcher'); const openButton = screen.getByRole('button', { name: '打开项目' }); @@ -1536,10 +1545,10 @@ export function registerHomeProjectCreationTests() { return chatHarness.invoke(command, args); }, ); - window.__TAURI__ = { + installTauriRuntime({ core: { invoke }, event: { listen: chatHarness.listen }, - }; + }); renderLauncherAt('/?launcher', 'home', true); const creationTypes = screen.getByRole('group', { name: '创作类型' }); @@ -1683,10 +1692,10 @@ export function registerHomeProjectCreationTests() { return chatHarness.invoke(command, args); }, ); - window.__TAURI__ = { + installTauriRuntime({ core: { invoke }, event: { listen: chatHarness.listen }, - }; + }); renderLauncherAt('/?launcher', 'home', true); const fileInput = @@ -1796,7 +1805,7 @@ export function registerHomeProjectCreationTests() { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherAt('/?launcher', 'home', true); const promptInput = screen.getByLabelText('创作想法'); @@ -1868,7 +1877,7 @@ export function registerHomeProjectCreationTests() { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherAt('/?launcher', 'home', true); await act(async () => { await vi.advanceTimersByTimeAsync(0); @@ -1962,10 +1971,10 @@ export function registerHomeProjectCreationTests() { return chatHarness.invoke(command, args); }, ); - window.__TAURI__ = { + installTauriRuntime({ core: { invoke }, event: { listen: chatHarness.listen }, - }; + }); renderLauncherAt('/?launcher', 'home', true); // 做方案走立项链路:建项之后还要写策划运行时,这一步失败时项目目录已经存在。 @@ -2029,10 +2038,10 @@ export function registerHomeProjectCreationTests() { return chatHarness.invoke(command, args); }, ); - window.__TAURI__ = { + installTauriRuntime({ core: { invoke }, event: { listen: chatHarness.listen }, - }; + }); renderLauncherAt('/?launcher', 'home', true); if (modeLabel === '做方案') { @@ -2126,10 +2135,10 @@ export function registerHomeProjectCreationTests() { return chatHarness.invoke(command, args); }, ); - window.__TAURI__ = { + installTauriRuntime({ core: { invoke }, event: { listen: chatHarness.listen }, - }; + }); renderLauncherAt('/?launcher', 'home', true); fireEvent.click(screen.getByRole('button', { name: '做方案' })); @@ -2264,10 +2273,10 @@ export function registerHomeProjectCreationTests() { return chatHarness.invoke(command, args); }, ); - window.__TAURI__ = { + installTauriRuntime({ core: { invoke }, event: { listen: chatHarness.listen }, - }; + }); renderLauncherAt('/?launcher', 'home', true); fireEvent.click(screen.getByRole('button', { name: '做方案' })); @@ -2330,10 +2339,10 @@ export function registerHomeProjectCreationTests() { return runtimeHarness.invoke(command, args); }, ); - window.__TAURI__ = { + installTauriRuntime({ core: { invoke }, event: { listen: runtimeHarness.listen }, - }; + }); renderLauncherAt('/?launcher', 'home', true); const promptInput = screen.getByLabelText('创作想法'); @@ -2360,7 +2369,7 @@ export function registerHomeProjectCreationTests() { const committedAssets = [ { id: 'direct-art-spec', - kind: 'icon-spec', + kind: 'icon-spec' as const, mediaType: 'image/png', localPath: 'assets/art-spec.png', source: { @@ -2370,7 +2379,7 @@ export function registerHomeProjectCreationTests() { }, { id: 'direct-game-background', - kind: 'scene', + kind: 'scene' as const, mediaType: 'image/png', localPath: 'assets/direct-game-background.png', source: { @@ -2380,7 +2389,7 @@ export function registerHomeProjectCreationTests() { }, { id: 'direct-art-spritesheet', - kind: 'icon-spritesheet', + kind: 'icon-spritesheet' as const, mediaType: 'image/png', localPath: 'assets/art-spritesheet.png', source: { @@ -2506,10 +2515,10 @@ export function registerHomeProjectCreationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( - React.createElement(App, { + React.createElement(TestApp, { initialProjectPath: projectPath, initialProjectManifest: manifest, initialPlanningPrompt: '继续修改已有项目', @@ -2590,10 +2599,10 @@ export function registerHomeProjectCreationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( - React.createElement(App, { + React.createElement(TestApp, { initialProjectPath: projectPath, initialProjectManifest: manifest, initialPlanningPrompt: '生成一个游戏', @@ -2624,7 +2633,7 @@ export function registerHomeProjectCreationTests() { // 重进项目:被拒消息不得从历史里被重放,拒绝文案也不该被当成历史条目。 render( - React.createElement(App, { + React.createElement(TestApp, { initialProjectPath: projectPath, initialProjectManifest: manifest, }), @@ -2701,7 +2710,7 @@ export function registerRecentProjectsTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); window.localStorage.setItem( 'genarrative-ai-game-creator.recent-workspaces.v1', JSON.stringify(paths), @@ -2772,7 +2781,7 @@ export function registerRecentProjectsTests() { return undefined; }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherAt('/?launcher'); expect(screen.getByLabelText('最近项目')).not.toBeNull(); @@ -2831,7 +2840,11 @@ export function registerRecentProjectsTests() { '星轨夜航', ); const invoke = vi.fn( - async (command: string, args?: Record) => { + // 回调体内要读 invoke.mock.calls,显式标注返回类型以打断类型推断的自引用环。 + async ( + command: string, + args?: Record, + ): Promise => { if (command === 'get_design_agent_runtime_mode') return null; if (command === 'inspect_local_project_directory') { const renamed = @@ -2858,7 +2871,7 @@ export function registerRecentProjectsTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); window.localStorage.setItem( 'genarrative-ai-game-creator.recent-workspaces.v1', JSON.stringify([projectPath]), @@ -2906,7 +2919,7 @@ export function registerRecentProjectsTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); window.localStorage.setItem( 'genarrative-ai-game-creator.recent-workspaces.v1', JSON.stringify([projectPath]), @@ -2955,7 +2968,7 @@ export function registerRecentProjectsTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); window.localStorage.setItem( 'genarrative-ai-game-creator.recent-workspaces.v1', JSON.stringify(['/tmp/recent-one']), @@ -2994,7 +3007,7 @@ export function registerRecentProjectsTests() { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherProjectsAt('/?launcher'); pickProjectFromLauncher('/tmp/open-failed-game'); @@ -3044,7 +3057,7 @@ export function registerRecentProjectsTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); window.localStorage.setItem( 'genarrative-ai-game-creator.recent-workspaces.v1', JSON.stringify([ @@ -3191,7 +3204,7 @@ export function registerRecentProjectsTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); window.localStorage.setItem( 'genarrative-ai-game-creator.recent-workspaces.v1', JSON.stringify(['/tmp/refreshable-game']), @@ -3256,7 +3269,7 @@ export function registerRecentProjectsTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherAt('/?launcher'); expect(screen.getByLabelText('最近项目')).not.toBeNull(); @@ -3304,7 +3317,7 @@ export function registerRecentProjectsTests() { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherProjectsAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '打开项目' })); @@ -3334,7 +3347,7 @@ export function registerRecentProjectsTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherProjectsAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '新建项目' })); @@ -3375,7 +3388,7 @@ export function registerRecentProjectsTests() { }, ); const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherProjectsAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '新建项目' })); @@ -3421,7 +3434,7 @@ export function registerRecentProjectsTests() { }, ); const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherProjectsAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '新建项目' })); @@ -3474,7 +3487,7 @@ export function registerRecentProjectsTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherProjectsAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '新建项目' })); @@ -3501,7 +3514,7 @@ export function registerRecentProjectsTests() { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherProjectsAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '新建项目' })); @@ -3568,10 +3581,10 @@ export function registerRecentProjectsTests() { return supervisorHarness.invoke(command, args); }, ); - window.__TAURI__ = { + installTauriRuntime({ core: { invoke }, event: { listen: supervisorHarness.listen }, - }; + }); renderLauncherAt('/?launcher', 'home', true); // 设置 → 工作区:默认位置就是不选目录,仍然落在 AGC 管理的应用数据目录。 diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts index 967302480..e0d3ae2d2 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts @@ -10,9 +10,11 @@ import { emptyProjectPolicy, expect, fireEvent, + installTauriRuntime, it, mockRoleAgentReply, renderAppAt, + resetTauriRuntime, roleAgentMockReply, screen, vi, @@ -192,7 +194,7 @@ export function registerProjectConversationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); invoke.mockClear(); fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); @@ -266,7 +268,7 @@ export function registerProjectConversationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); invoke.mockClear(); fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); @@ -352,7 +354,7 @@ export function registerProjectConversationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); invoke.mockClear(); fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); @@ -428,7 +430,7 @@ export function registerProjectConversationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); const input = await screen.findByLabelText('Agent 对话内容'); @@ -520,7 +522,7 @@ export function registerProjectConversationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); const input = await screen.findByLabelText('Agent 对话内容'); @@ -616,7 +618,7 @@ export function registerProjectConversationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); @@ -685,12 +687,12 @@ export function registerProjectConversationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); const input = await screen.findByLabelText('Agent 对话内容'); fireEvent.change(input, { target: { value: '确认运行环境提示' } }); - delete window.__TAURI__; + resetTauriRuntime(); fireEvent.submit(input.closest('form') as HTMLFormElement); expect(await screen.findByText('需要在 Tauri App 内运行')).not.toBeNull(); @@ -787,7 +789,7 @@ export function registerProjectConversationTests() { } throw new Error(`unexpected listen ${eventName}`); }); - window.__TAURI__ = { core: { invoke }, event: { listen } }; + installTauriRuntime({ core: { invoke }, event: { listen } }); renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); const input = await screen.findByLabelText('Agent 对话内容'); @@ -876,7 +878,7 @@ export function registerProjectConversationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); expect(await screen.findByText('旧 Agent 历史消息')).not.toBeNull(); 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..866de0c7c 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 @@ -2,6 +2,7 @@ import userEvent from '@testing-library/user-event'; import type { GameCreationAppAssetKind, + GameCreationAppManifest, ProjectResourceCanvasLayout, ProjectResourceCanvasPosition, } from '../../../../packages/shared/src/contracts/gameCreationApp'; @@ -10,7 +11,6 @@ import { GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND, GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE, } from '../../../../packages/shared/src/contracts/gameCreationApp'; -import { RESOURCE_REFERENCE_INSERT_EVENT } from '../../src/features/project-workspace/resourceReferences'; import { ApprovalModeDialog } from '../../src/view/project-development/ApprovalModeDialog'; import { RESOURCE_BOOK_OVERVIEW_STACK_LIMIT } from '../../src/view/project-development/resourceBookLayout'; import { @@ -30,13 +30,16 @@ import { } from '../resourceGenerationPromptTestUtils'; import { act, + App, cleanup, createGameCreationAppManifest, + createProjectChatRuntimeHarness, expect, findResourceSelectButton, fireEvent, getResourceSelectButton, installResizeObserverStub, + installTauriRuntime, it, openResourceFilterPanel, ProjectDevelopmentView, @@ -51,6 +54,21 @@ import { within, } from './harness'; +// App 的 props 类型未导出,React 19 的 createElement 重载推断不出参数类型,这里显式转成组件类型。 +const AppComponent = App as React.ComponentType< + NonNullable> +>; + +/** + * 历史夹具沿用的旧 kind 字面量(如 `design-document` / `game-code`)已不在当前 + * `GameCreationAppAssetKind` 联合里;这些用例依赖未识别 kind 落「待归类」的运行时语义, + * 所以保留原始字符串,只在这里做类型层收口,不做别名归一。 + */ +function legacyAssetKind(kind: string): GameCreationAppAssetKind { + // 旧 kind 字面量与当前联合结构上不可兼容,这里显式转换以保留原始串的运行时语义。 + return kind as unknown as GameCreationAppAssetKind; +} + function resourceGraphForInputs(args?: Record) { const resources = (args?.resources as @@ -311,7 +329,7 @@ function installResourceBookBottomToolbarInvoke( throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); return { calls }; } @@ -561,7 +579,7 @@ export function registerProjectWorkbenchFoundationTests() { source: { kind: 'generated', taskId: 'art-asset-plan' }, }, ]; - window.__TAURI__ = { + installTauriRuntime({ core: { invoke: vi.fn( async (command: string, args?: Record) => { @@ -595,7 +613,7 @@ export function registerProjectWorkbenchFoundationTests() { }, ), }, - }; + }); render( React.createElement(ProjectDevelopmentView, { @@ -686,7 +704,7 @@ export function registerProjectWorkbenchFoundationTests() { manifest.assets = [ { id: 'section-document', - kind: 'design-document', + kind: legacyAssetKind('design-document'), mediaType: 'text/markdown', localPath: 'memory/section.md', source: { kind: 'generated', taskId: 'design-foundation' }, @@ -707,7 +725,7 @@ export function registerProjectWorkbenchFoundationTests() { }, { id: 'section-code', - kind: 'game-code', + kind: legacyAssetKind('game-code'), mediaType: 'text/javascript', localPath: 'game/section.js', source: { kind: 'generated', taskId: 'code-prototype' }, @@ -767,7 +785,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -1340,7 +1358,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -1584,7 +1602,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -1706,7 +1724,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -1797,7 +1815,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -1939,7 +1957,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -2075,7 +2093,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -2172,7 +2190,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -2232,7 +2250,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -2294,7 +2312,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -2357,7 +2375,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -2455,7 +2473,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const viewProps = { projectName: manifest.name, projectPath: '/tmp/workbench-resource-unread', @@ -2482,7 +2500,7 @@ export function registerProjectWorkbenchFoundationTests() { nextProjectManifest.assets = [ { id: 'next-project-code', - kind: 'game-code', + kind: legacyAssetKind('game-code'), mediaType: 'text/javascript', localPath: 'game/game.js', source: { kind: 'generated' }, @@ -2518,7 +2536,7 @@ export function registerProjectWorkbenchFoundationTests() { manifest.assets = [ { id: 'nav-document', - kind: 'design-document', + kind: legacyAssetKind('design-document'), mediaType: 'text/markdown', localPath: 'memory/nav-document.md', source: { kind: 'generated', taskId: 'design-foundation' }, @@ -2549,7 +2567,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -2635,7 +2653,7 @@ export function registerProjectWorkbenchFoundationTests() { manifest.assets = [ { id: 'imported-design-doc', - kind: 'design-document', + kind: legacyAssetKind('design-document'), mediaType: 'text/markdown', localPath: 'docs/plan.md', source: { kind: 'generated' }, @@ -2674,7 +2692,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const viewProps = { projectName: manifest.name, projectPath: '/tmp/workbench-imported-image-canvas', @@ -2697,7 +2715,7 @@ export function registerProjectWorkbenchFoundationTests() { id: 'imported-image', // canonical `icon` → 「UI 交互」:本用例钉的是"同数量 manifest 更新后栏目跟着变", // 不掺 UI 设计图「(待视觉验收)」标签那套,所以选一个不会带来额外后缀的 kind。 - kind: 'icon', + kind: 'icon' as const, mediaType: 'image/png', localPath: 'assets/uploads/local-imported-image.png', source: { @@ -2728,7 +2746,7 @@ export function registerProjectWorkbenchFoundationTests() { 'workbench-empty-section-overview', '空资源项目', ); - window.__TAURI__ = { + installTauriRuntime({ core: { invoke: vi.fn( async (command: string, args?: Record) => { @@ -2762,7 +2780,7 @@ export function registerProjectWorkbenchFoundationTests() { }, ), }, - }; + }); render( React.createElement(ProjectDevelopmentView, { @@ -2843,7 +2861,7 @@ export function registerProjectWorkbenchFoundationTests() { }, { id: 'design-document', - kind: 'design-document', + kind: legacyAssetKind('design-document'), mediaType: 'text/markdown', localPath: 'memory/design.md', source: { kind: 'generated', taskId: 'design-foundation' }, @@ -2926,7 +2944,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const play = vi .spyOn(HTMLMediaElement.prototype, 'play') .mockResolvedValue(undefined); @@ -3226,7 +3244,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const props = { projectName: manifest.name, projectPath: '/tmp/workbench-preview-scheduler', @@ -3596,7 +3614,7 @@ export function registerProjectWorkbenchFoundationTests() { ); }); - it('工具条里的「引用」用键盘也能插进聊天输入框', async () => { + it('工具条里的「引用」把素材 @ 进真实聊天草稿(键盘与鼠标两条通路)', async () => { const manifest = createGameCreationAppManifest( 'workbench-resource-reference', '引用入口测试', @@ -3611,6 +3629,13 @@ export function registerProjectWorkbenchFoundationTests() { source: { kind: 'generated' }, }); let layoutRevision = 0; + const projectPath = '/tmp/workbench-resource-reference'; + // 对话面用真实 DirectProject 链路(`App` → `DirectProjectChatView` → `DirectProjectComposer`): + // 引用事件的消费者在 `App.tsx`,桩接不住它,链路断点正好在「谁把它落进草稿」。 + const chatHarness = createProjectChatRuntimeHarness({ + projectPath, + initialSessionExists: false, + }); const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'read_local_project_resource_graph') { @@ -3648,20 +3673,45 @@ export function registerProjectWorkbenchFoundationTests() { dataUrl: 'data:image/png;base64,iVBORw0KGgo=', }; } - throw new Error(`unexpected invoke ${command}`); + if (command === 'get_local_game_manifest') { + return manifest; + } + if (command === 'inspect_local_project_directory') { + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: manifest.name, + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'get_local_game_preview_status') { + return { status: 'stopped', url: null, port: null, root: null }; + } + if (command === 'get_design_agent_runtime_mode') { + return null; + } + return chatHarness.invoke(command, args); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ + core: { invoke }, + event: { listen: chatHarness.listen }, + }); render( React.createElement(ProjectDevelopmentView, { projectName: '引用入口测试', - projectPath: '/tmp/workbench-resource-reference', + projectPath, manifest, attachments: [], recentRunStatus: null, recentRunStopReason: null, - chat: React.createElement('div', null, '项目总控'), + chat: React.createElement(AppComponent, { + initialProjectPath: projectPath, + }), onHomeOpen: vi.fn(), onProjectsOpen: vi.fn(), }), @@ -3689,49 +3739,39 @@ export function registerProjectWorkbenchFoundationTests() { '引用资源 hero.png', ); - const inserted: unknown[] = []; - const onInsert = (event: Event) => { - inserted.push( - (event as CustomEvent<{ reference: unknown }>).detail.reference, - ); - }; - window.addEventListener(RESOURCE_REFERENCE_INSERT_EVENT, onInsert); - try { - // 键盘通路:聚焦后回车确认。App 侧监听同一个事件并把它插成输入框里的引用 chip - // (`App.tsx` 的 `handleResourceReferenceInsert`),所以这里钉的是真链路而不是按钮长相。 - referenceButton.focus(); - expect(document.activeElement).toBe(referenceButton); - await userEvent.setup().keyboard('{Enter}'); - expect(inserted).toHaveLength(1); + // 端到端契约:交付结果是草稿里的引用芯片,不是 window 上的一次 dispatch。 + // 链路断点一直是「谁把引用落进草稿」,所以断言必须读真实输入盒的 DOM。 + const draftChipIds = () => + Array.from( + document.querySelectorAll( + 'form.project-chat-composer [data-resource-reference-id]', + ), + ).map((chip) => chip.getAttribute('data-resource-reference-id')); + const composerEditor = () => + document.querySelector( + 'form.project-chat-composer [contenteditable="true"][aria-label="陶泥儿对话内容"]', + )!; + // 芯片显示名走聊天自己的 manifest 口径(文件名去扩展名),与画布卡片上的文件名不同。 + const firstChipLabel = () => + document.querySelector( + 'form.project-chat-composer [data-resource-reference-id] .resource-reference-chip-label', + )?.textContent; - // 鼠标通路仍然只派发一次;两条通路带的是逐字相同的出站负载。 - fireEvent.click(referenceButton); - expect(inserted).toHaveLength(2); - } finally { - window.removeEventListener(RESOURCE_REFERENCE_INSERT_EVENT, onInsert); - } - expect(inserted).toEqual([ - { - type: 'resource', - resourceId: 'scene-hero', - kind: 'image', - mediaType: 'image/png', - label: 'hero.png', - category: 'scene', - tags: ['主舞台'], - source: 'resource-card', - }, - { - type: 'resource', - resourceId: 'scene-hero', - kind: 'image', - mediaType: 'image/png', - label: 'hero.png', - category: 'scene', - tags: ['主舞台'], - source: 'resource-card', - }, - ]); + // 键盘通路:聚焦后回车确认。 + referenceButton.focus(); + expect(document.activeElement).toBe(referenceButton); + await userEvent.setup().keyboard('{Enter}'); + await waitFor(() => expect(draftChipIds()).toEqual(['scene-hero'])); + expect(firstChipLabel()).toBe('hero'); + expect(composerEditor().textContent).toContain('@hero'); + + // 鼠标通路:再点一次,第二枚芯片接在第一枚之后——光标留在插入之后,不覆盖已插的引用。 + fireEvent.click(referenceButton); + await waitFor(() => + expect(draftChipIds()).toEqual(['scene-hero', 'scene-hero']), + ); + // 插入后焦点回到输入盒,用户可以接着打字。 + expect(document.activeElement).toBe(composerEditor()); }); it('信息面板在画布浮层与运行页签里渲染同一份只读字段', async () => { @@ -3800,7 +3840,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -3900,7 +3940,7 @@ export function registerProjectWorkbenchFoundationTests() { manifest.assets.push( { id: 'design-document', - kind: 'design-document', + kind: legacyAssetKind('design-document'), mediaType: 'text/markdown', localPath: 'game/design.md', source: { kind: 'generated' }, @@ -3921,14 +3961,14 @@ export function registerProjectWorkbenchFoundationTests() { }, { id: 'audio-bgm', - kind: 'bgm', + kind: legacyAssetKind('bgm'), mediaType: 'audio/mpeg', localPath: 'assets/bgm.mp3', source: { kind: 'generated' }, }, { id: 'blocked-document', - kind: 'design-document', + kind: legacyAssetKind('design-document'), mediaType: 'text/markdown', localPath: 'game/blocked.md', source: { kind: 'generated' }, @@ -4017,7 +4057,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined); render( @@ -4190,7 +4230,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined); const viewProps = { projectName: manifest.name, @@ -4260,7 +4300,7 @@ export function registerProjectWorkbenchFoundationTests() { manifest.assets.push( { id: 'dependency-spec', - kind: 'metadata', + kind: legacyAssetKind('metadata'), mediaType: 'application/json', localPath: 'assets/spec-source.json', source: { @@ -4271,7 +4311,7 @@ export function registerProjectWorkbenchFoundationTests() { }, { id: 'dependency-ui', - kind: 'metadata', + kind: legacyAssetKind('metadata'), mediaType: 'application/json', localPath: 'assets/ui-dependency.json', source: { @@ -4283,7 +4323,7 @@ export function registerProjectWorkbenchFoundationTests() { }, { id: 'unrelated-cycle', - kind: 'metadata', + kind: legacyAssetKind('metadata'), mediaType: 'application/json', localPath: 'assets/unrelated-cycle.json', source: { @@ -4440,7 +4480,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const view = render( React.createElement(ProjectDevelopmentView, { @@ -4963,11 +5003,15 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { projectName: '延迟依赖图测试', + recentRunStatus: null, + recentRunStopReason: null, + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), projectPath, manifest, attachments: [], @@ -5050,11 +5094,15 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const renderView = (agentResults: ProjectAgentResultSummary[]) => React.createElement(ProjectDevelopmentView, { projectName: '稳定依赖图测试', + recentRunStatus: null, + recentRunStopReason: null, + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), projectPath, manifest, attachments: [], @@ -5117,7 +5165,7 @@ export function registerProjectWorkbenchFoundationTests() { manifest.assets.push( { id: 'truncated-depth-0', - kind: 'design-spec', + kind: legacyAssetKind('design-spec'), mediaType: 'application/json', localPath: 'assets/truncated-depth-0.json', source: { @@ -5127,7 +5175,7 @@ export function registerProjectWorkbenchFoundationTests() { }, { id: 'truncated-depth-1', - kind: 'metadata', + kind: legacyAssetKind('metadata'), mediaType: 'application/json', localPath: 'assets/truncated-depth-1.json', source: { @@ -5138,7 +5186,7 @@ export function registerProjectWorkbenchFoundationTests() { }, { id: 'truncated-depth-2', - kind: 'metadata', + kind: legacyAssetKind('metadata'), mediaType: 'application/json', localPath: 'assets/truncated-depth-2.json', source: { @@ -5297,11 +5345,15 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { projectName: '截断依赖图布局保护测试', + recentRunStatus: null, + recentRunStopReason: null, + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), projectPath, manifest, attachments: [], @@ -5389,12 +5441,16 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); function renderWorkbench() { render( React.createElement(ProjectDevelopmentView, { projectName: '布局持久化测试', + recentRunStatus: null, + recentRunStopReason: null, + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), projectPath, manifest, attachments: [], @@ -5529,11 +5585,15 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { projectName: '卡片点击不误判拖动测试', + recentRunStatus: null, + recentRunStopReason: null, + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), projectPath, manifest, attachments: [], @@ -5608,7 +5668,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -5633,18 +5693,18 @@ export function registerProjectWorkbenchFoundationTests() { hasPointerCapture: { configurable: true, value: vi.fn(() => true) }, releasePointerCapture: { configurable: true, value: vi.fn() }, }); - fireEvent.pointerDown(card, { + fireEvent.pointerDown(card!, { pointerId: 41, button: 0, clientX: 40, clientY: 50, }); - fireEvent.pointerMove(card, { + fireEvent.pointerMove(card!, { pointerId: 41, clientX: 120, clientY: 50, }); - fireEvent.pointerCancel(card, { pointerId: 41 }); + fireEvent.pointerCancel(card!, { pointerId: 41 }); fireEvent.click(card!); expect(card?.querySelector('[aria-pressed="true"]')).not.toBeNull(); @@ -5712,11 +5772,15 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { projectName: '资源子类型排序测试', + recentRunStatus: null, + recentRunStopReason: null, + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), projectPath: '/tmp/workbench-layout-asset-subtypes', manifest, attachments: [], @@ -5776,11 +5840,15 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { projectName: '布局失败测试', + recentRunStatus: null, + recentRunStopReason: null, + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), projectPath: '/tmp/workbench-layout-save-failure', manifest, attachments: [], @@ -6067,7 +6135,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { projectName: manifest.name, @@ -6211,7 +6279,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const viewProps = { projectName: manifest.name, projectPath: '/tmp/workbench-preview-scope-switch-a', @@ -6427,7 +6495,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const onPlay = vi.fn(); render( @@ -6604,7 +6672,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { projectName: manifest.name, @@ -6613,7 +6681,7 @@ export function registerProjectWorkbenchFoundationTests() { attachments: [], recentRunStatus: null, recentRunStopReason: null, - supervisor: React.createElement('div', null, '项目总控'), + chat: React.createElement('div', null, '项目总控'), onHomeOpen: vi.fn(), onProjectsOpen: vi.fn(), }), @@ -6717,7 +6785,7 @@ export function registerProjectAgentStatusTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); expect(screen.queryByRole('button', { name: '调度 Ready' })).toBeNull(); @@ -6748,7 +6816,7 @@ export function registerProjectAgentStatusTests() { manifest.assets = [ { id: 'section-card-document', - kind: 'design-document', + kind: legacyAssetKind('design-document'), mediaType: 'text/markdown', localPath: 'memory/section-card.md', source: { kind: 'generated', taskId: 'design-foundation' }, @@ -6772,7 +6840,7 @@ export function registerProjectAgentStatusTests() { }, { id: 'section-card-unclassified', - kind: 'game-code', + kind: legacyAssetKind('game-code'), mediaType: 'text/javascript', localPath: 'game/section-card.js', source: { kind: 'generated', taskId: 'code-prototype' }, @@ -6804,7 +6872,7 @@ export function registerProjectAgentStatusTests() { const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'read_local_project_resource_graph') { - return resourceGraphForInputs([]); + return resourceGraphForInputs(); } if (command === 'read_local_project_resource_canvas_layout') { return { @@ -6833,7 +6901,7 @@ export function registerProjectAgentStatusTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -6894,7 +6962,7 @@ export function registerProjectAgentStatusTests() { manifest.assets = [ { id: 'all-resources-document', - kind: 'design-document', + kind: legacyAssetKind('design-document'), mediaType: 'text/markdown', localPath: 'memory/all-resources.md', source: { kind: 'generated', taskId: 'design-foundation' }, @@ -6932,7 +7000,7 @@ export function registerProjectAgentStatusTests() { const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'read_local_project_resource_graph') { - return resourceGraphForInputs([]); + return resourceGraphForInputs(); } if (command === 'read_local_project_resource_canvas_layout') { return { @@ -6961,7 +7029,7 @@ export function registerProjectAgentStatusTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -7232,7 +7300,7 @@ export function registerProjectAgentStatusTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -7313,14 +7381,14 @@ export function registerProjectAgentStatusTests() { manifest.assets = [ { id: 'overview-heading-document', - kind: 'design-document', + kind: legacyAssetKind('design-document'), mediaType: 'text/markdown', localPath: 'memory/overview-heading.md', source: { kind: 'generated', taskId: 'design-foundation' }, category: 'document', }, ]; - window.__TAURI__ = { + installTauriRuntime({ core: { invoke: vi.fn( async (command: string, args?: Record) => { @@ -7354,7 +7422,7 @@ export function registerProjectAgentStatusTests() { }, ), }, - }; + }); render( React.createElement(ProjectDevelopmentView, { @@ -7426,7 +7494,7 @@ export function registerProjectAgentStatusTests() { }, { id: 'toolbar-document', - kind: 'design-document', + kind: legacyAssetKind('design-document'), mediaType: 'text/markdown', localPath: 'memory/toolbar-notes.md', source: { kind: 'generated', taskId: 'design-foundation' }, @@ -7831,7 +7899,7 @@ export function registerProjectAgentStatusTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -8013,7 +8081,7 @@ export function registerProjectAgentStatusTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -8105,7 +8173,7 @@ export function registerProjectAgentStatusTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -8446,7 +8514,7 @@ export function registerProjectAgentStatusTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -8839,6 +8907,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/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts index 38595583d..c972a52bc 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts @@ -11,6 +11,7 @@ import { fireEvent, GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, type GameCreationAgentRunTrace, + installTauriRuntime, it, renderLauncherAt, screen, @@ -111,6 +112,9 @@ export function registerAgentStatusDerivationTests() { pass: 1, agent: 'Evaluator', phase: 'evaluation', + taskId: null, + group: null, + role: null, status: 'passed', inputPaths: ['.agent/passes/pass-1/draft.json', '.agent/findings.md'], outputPaths: ['.agent/findings.md'], @@ -119,11 +123,14 @@ export function registerAgentStatusDerivationTests() { { toolId: 'agent.evaluate', status: 'ok', + inputPaths: [], + outputPaths: [], summary: 'Evaluator 质量评审', }, ], }, ], + artifacts: [], taskGraph: { goal: '做一个厨房弹幕游戏', readyTaskIds: ['audio-director'], @@ -220,7 +227,7 @@ export function registerRuntimeSettingsTests() { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '配置' })); @@ -269,7 +276,7 @@ export function registerRuntimeSettingsTests() { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '配置' })); @@ -338,7 +345,7 @@ export function registerRuntimeSettingsTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '配置' })); @@ -412,7 +419,7 @@ export function registerRuntimeSettingsTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '配置' })); @@ -484,7 +491,7 @@ export function registerRuntimeSettingsTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '配置' })); @@ -536,7 +543,7 @@ export function registerRuntimeSettingsTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '配置' })); @@ -617,7 +624,7 @@ export function registerRuntimeSettingsTests() { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); document.documentElement.style.overflow = 'auto'; document.body.style.overflow = 'scroll'; renderLauncherAt('/?launcher'); @@ -659,7 +666,7 @@ export function registerRuntimeSettingsTests() { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '配置' })); @@ -715,7 +722,7 @@ export function registerRuntimeSettingsTests() { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherAt('/?launcher'); // 首页模型选择器挂载后会在目录请求之后读取一次配置,等它完成再打开对话框。 diff --git a/apps/ai-game-creator-shell/tests/appSurface/tool-call-group.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/tool-call-group.suite.ts index 7ed499f06..c66d028ad 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/tool-call-group.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/tool-call-group.suite.ts @@ -58,8 +58,8 @@ export function registerToolCallGroupTests() { 'running', 'completed', ]); - expect(within(rows[0]).getByText('失败')).not.toBeNull(); - expect(within(rows[2]).queryByText('失败')).toBeNull(); + expect(within(rows[0]!).getByText('失败')).not.toBeNull(); + expect(within(rows[2]!).queryByText('失败')).toBeNull(); }); it('summarizes the group as its own operation count', () => { @@ -458,9 +458,9 @@ export function registerToolCallGroupTests() { view.rerender( React.createElement(ToolCallGroup, { calls: [ - { ...calls[0], status: 'completed' as const, updatedAt: lastEnd }, + { ...calls[0]!, status: 'completed' as const, updatedAt: lastEnd }, { - ...calls[1], + ...calls[1]!, status: 'completed' as const, updatedAt: lastEnd - 400, }, diff --git a/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx b/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx index b088f8435..7d4b6d79e 100644 --- a/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx +++ b/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx @@ -28,6 +28,7 @@ import { invoke } from '@tauri-apps/api/core'; import { AuthenticatedClient } from '../src/app/AuthenticatedClient'; import { resetPlatformSessionForTests } from '../src/services/platformSession'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; import { installUnhandledRejectionBridge } from './unhandledRejectionBridge'; type AuthInvoke = ( @@ -42,11 +43,12 @@ function reportCalls() { } function renderLogin(invokeAuth: AuthInvoke) { - window.__TAURI__ = { core: { invoke: invokeAuth } } as never; + installTauriRuntime({ core: { invoke: invokeAuth } }); render( - createElement(AuthenticatedClient, null, () => - createElement('main', { 'aria-label': '已登录' }), - ), + // React 19 的 createElement 的 children 形参只接受 ReactNode,渲染函数子节点改编进 props。 + createElement(AuthenticatedClient, { + children: () => createElement('main', { 'aria-label': '已登录' }), + }), ); } @@ -67,7 +69,7 @@ describe('认证失败的上报判据', () => { afterEach(() => { cleanup(); resetPlatformSessionForTests(); - delete window.__TAURI__; + resetTauriRuntime(); vi.mocked(invoke).mockClear(); }); diff --git a/apps/ai-game-creator-shell/tests/chatComposerAttachmentCap.test.tsx b/apps/ai-game-creator-shell/tests/chatComposerAttachmentCap.test.tsx index 48d990264..c67af4f15 100644 --- a/apps/ai-game-creator-shell/tests/chatComposerAttachmentCap.test.tsx +++ b/apps/ai-game-creator-shell/tests/chatComposerAttachmentCap.test.tsx @@ -7,6 +7,7 @@ import { useDirectProjectChatController, } from '../src/view/project-development/chat/controller/useDirectProjectChatController'; import type { DirectCodexTurnAttachment } from '../src/view/project-development/chat/conversation/directCodexTurnAttachments'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; // jsdom 的 Blob/File 没实现 `arrayBuffer`,而附件导入链路要按字节读文件。 if (typeof Blob.prototype.arrayBuffer !== 'function') { @@ -25,7 +26,7 @@ function files(count: number, prefix: string) { afterEach(() => { cleanup(); vi.restoreAllMocks(); - window.__TAURI__ = undefined; + resetTauriRuntime(); }); describe('聊天输入盒的附件上限', () => { @@ -38,7 +39,7 @@ describe('聊天输入盒的附件上限', () => { const fileName = (args as { fileName?: string }).fileName ?? 'file'; return { localPath: `assets/upload-${(uploadSeq += 1)}-${fileName}` }; }); - window.__TAURI__ = { core: { invoke: invoke as never } }; + installTauriRuntime({ core: { invoke } }); const { result } = renderHook(() => useDirectProjectChatController({ @@ -65,7 +66,7 @@ describe('聊天输入盒的附件上限', () => { }); expect(imported[0]).toHaveLength(5); expect(imported[1]).toHaveLength(3); - expect(imported[0].length + imported[1].length).toBe( + expect(imported[0]!.length + imported[1]!.length).toBe( MAX_CHAT_COMPOSER_ATTACHMENTS, ); }); diff --git a/apps/ai-game-creator-shell/tests/clientAnalytics.test.tsx b/apps/ai-game-creator-shell/tests/clientAnalytics.test.tsx index 043f7ec8d..9568be6b7 100644 --- a/apps/ai-game-creator-shell/tests/clientAnalytics.test.tsx +++ b/apps/ai-game-creator-shell/tests/clientAnalytics.test.tsx @@ -10,6 +10,7 @@ import { beginProjectOpenAnalytics, beginUiSaveAnalytics, } from '../src/services/clientAnalytics'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; const context = { route: { user_id: 'user-a', destination_origin: 'https://a.example' }, @@ -27,7 +28,7 @@ function deferred() { } afterEach(() => { cleanup(); - delete window.__TAURI__; + resetTauriRuntime(); vi.restoreAllMocks(); }); @@ -54,11 +55,12 @@ function mount( return null; }, ); - window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__; + installTauriRuntime({ core: { invoke } }); const hook = renderHook( () => useHomeProjectCreation({ setStatus: vi.fn(), + showTransientNotice: vi.fn(), setLauncherView: vi.fn(), rememberRecentWorkspace: vi.fn(), }), @@ -88,7 +90,7 @@ it.each(['recent', 'picker', 'create'] as const)( ); }); expect(records()).toHaveLength(1); - expect(records()[0][1]).toMatchObject({ context, openSource: source }); + expect(records()[0]![1]).toMatchObject({ context, openSource: source }); }, ); @@ -105,7 +107,7 @@ it('身份抓取挂起不阻塞打开,成功时间在抓取完成前冻结', a captured.resolve(context); }); expect(records()).toHaveLength(1); - expect(Date.parse(String(records()[0][1]?.eventTime))).toBeLessThanOrEqual( + expect(Date.parse(String(records()[0]![1]?.eventTime))).toBeLessThanOrEqual( latestSuccessTime, ); }); @@ -179,7 +181,7 @@ it('同项目再次显式打开是新操作,重渲染不会产生额外打开' await result.current.openProject('C:/project', 'open'); }); expect(records()).toHaveLength(2); - expect(records()[0][1]?.operationId).not.toBe(records()[1][1]?.operationId); + expect(records()[0]![1]?.operationId).not.toBe(records()[1]![1]?.operationId); }); it('身份抓取和记录写入失败静默,不改变成功打开', async () => { diff --git a/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts b/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts index d8b0b27a2..d3a43aa58 100644 --- a/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts +++ b/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts @@ -24,6 +24,7 @@ import type { RuntimeSessionInstallFailed } from '../src/services/generated/Runt import type { SendCodeRejected } from '../src/services/generated/SendCodeRejected'; import type { ServerAddressRejected } from '../src/services/generated/ServerAddressRejected'; import type { UnexpectedRejection } from '../src/services/generated/UnexpectedRejection'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; function expectNever(value: never): void { void value; @@ -127,7 +128,7 @@ function variantMessage(error: ClientAuthError): string { } function installInvoke(handler: () => Promise) { - window.__TAURI__ = { core: { invoke: vi.fn(handler) } } as never; + installTauriRuntime({ core: { invoke: vi.fn(handler) } }); } describe('认证命令失败的 JS 侧载体', () => { @@ -150,7 +151,7 @@ describe('认证命令失败的 JS 侧载体', () => { }); it('认证桥未安装时保持原样抛出,不包装成命令失败', async () => { - delete window.__TAURI__; + resetTauriRuntime(); await expect( loginClientWithPassword( diff --git a/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts b/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts index 6c6393676..4bea6f174 100644 --- a/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts +++ b/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts @@ -18,6 +18,7 @@ import { refreshClientAuthSession, sendClientPhoneLoginCode, } from '../src/services/clientAuth'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; const testUser = { id: 'user-1', @@ -35,9 +36,9 @@ const testUser = { beforeEach(() => { invoke.mockReset(); - window.__TAURI__ = { + installTauriRuntime({ core: { invoke: (...args: unknown[]) => invoke(...args) }, - } as never; + }); }); test('手机号归一化只保留可提交的纯号码', () => { @@ -144,7 +145,7 @@ test('续期结果区分成功、失效与身份变化', async () => { }); test('没有原生宿主时登录能力明确失败关闭', async () => { - delete window.__TAURI__; + resetTauriRuntime(); await expect(readClientAuthState()).rejects.toThrow( '需要在 Tauri App 内登录', ); diff --git a/apps/ai-game-creator-shell/tests/clientHttp.test.ts b/apps/ai-game-creator-shell/tests/clientHttp.test.ts index 30b480e52..2f9dfa43f 100644 --- a/apps/ai-game-creator-shell/tests/clientHttp.test.ts +++ b/apps/ai-game-creator-shell/tests/clientHttp.test.ts @@ -89,7 +89,7 @@ test('预设映射到渠道地址,自定义走归一化', async () => { }); test('开发态切换服务器会落盘并在内存中生效', async () => { - vi.stubEnv('DEV', true); + vi.stubEnv('DEV', 'true'); const mod = await loadClientHttp(); expect(mod.isClientServerSelectionEnabled()).toBe(true); @@ -108,7 +108,7 @@ test('开发态切换服务器会落盘并在内存中生效', async () => { }); test('开发态从 Rust 偏好恢复服务器选择', async () => { - vi.stubEnv('DEV', true); + vi.stubEnv('DEV', 'true'); const mod = await loadClientHttp(); invoke.mockResolvedValue({ clientServerSelection: { diff --git a/apps/ai-game-creator-shell/tests/clientRuntimeErrorBoundary.test.ts b/apps/ai-game-creator-shell/tests/clientRuntimeErrorBoundary.test.ts index 23d82084d..b384874b9 100644 --- a/apps/ai-game-creator-shell/tests/clientRuntimeErrorBoundary.test.ts +++ b/apps/ai-game-creator-shell/tests/clientRuntimeErrorBoundary.test.ts @@ -1,12 +1,13 @@ /** @vitest-environment jsdom */ import { fireEvent, render, screen } from '@testing-library/react'; -import { createElement } from 'react'; +import { createElement, type ReactNode } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { ClientRuntimeErrorBoundary } from '../src/app/AuthenticatedClient'; -function BrokenAuthenticatedContent() { +// 该组件永远抛错,声明 ReactNode 返回类型才能作为 children 传给错误边界。 +function BrokenAuthenticatedContent(): ReactNode { throw new Error('首页渲染失败'); } @@ -20,11 +21,11 @@ describe('AGC authenticated client error boundary', () => { const onLogout = vi.fn(); render( - createElement( - ClientRuntimeErrorBoundary, - { onLogout }, - createElement(BrokenAuthenticatedContent), - ), + createElement(ClientRuntimeErrorBoundary, { + onLogout, + // `children` 是必填 prop(createElement 第三参不再满足必填 props 约束)。 + children: createElement(BrokenAuthenticatedContent), + }), ); expect( diff --git a/apps/ai-game-creator-shell/tests/cocosProjectOpen.test.tsx b/apps/ai-game-creator-shell/tests/cocosProjectOpen.test.tsx index 26a218ac1..d1f52a7ac 100644 --- a/apps/ai-game-creator-shell/tests/cocosProjectOpen.test.tsx +++ b/apps/ai-game-creator-shell/tests/cocosProjectOpen.test.tsx @@ -4,9 +4,10 @@ import { afterEach, expect, test, vi } from 'vitest'; import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; import { useHomeProjectCreation } from '../src/features/app-shell/useHomeProjectCreation'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; afterEach(() => { - delete window.__TAURI__; + resetTauriRuntime(); }); test('打开 Cocos 目录走导入入口,不创建 Web 脚手架或发送初始消息', async () => { @@ -38,13 +39,15 @@ test('打开 Cocos 目录走导入入口,不创建 Web 脚手架或发送初 } throw new Error(`意外调用:${command}`); }); - window.__TAURI__ = { core: { invoke } }; + // TauriInvoke 是泛型函数签名,vi.fn 的具体返回类型无法直接赋给它,这里只在装配处桥接。 + installTauriRuntime({ core: { invoke } }); const setLauncherView = vi.fn(); const { result } = renderHook(() => useHomeProjectCreation({ setStatus: vi.fn(), + // 现行 UseHomeProjectCreationOptions 必填 showTransientNotice,旧的 setAgentChatProjectPath 已移除。 + showTransientNotice: vi.fn(), setLauncherView, - setAgentChatProjectPath: vi.fn(), rememberRecentWorkspace: vi.fn(), }), ); diff --git a/apps/ai-game-creator-shell/tests/customLlmSettings.test.tsx b/apps/ai-game-creator-shell/tests/customLlmSettings.test.tsx index 3a14cdc6a..d4c2d3b09 100644 --- a/apps/ai-game-creator-shell/tests/customLlmSettings.test.tsx +++ b/apps/ai-game-creator-shell/tests/customLlmSettings.test.tsx @@ -18,6 +18,7 @@ import type { } from '../src/app/types'; import { CustomLlmSettings } from '../src/features/runtime-config/CustomLlmSettings'; import { RuntimeConfigDialog } from '../src/features/runtime-config/RuntimeConfigDialog'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; const invoke = vi.fn(); const llm = { @@ -34,11 +35,11 @@ function Harness() { beforeEach(() => { invoke.mockReset(); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); }); afterEach(() => { cleanup(); - delete window.__TAURI__; + resetTauriRuntime(); }); test('reads endpoint models, filters candidates, and previews only checked models', async () => { diff --git a/apps/ai-game-creator-shell/tests/designProjectRestore.test.tsx b/apps/ai-game-creator-shell/tests/designProjectRestore.test.tsx index aaf335618..c5c5f6878 100644 --- a/apps/ai-game-creator-shell/tests/designProjectRestore.test.tsx +++ b/apps/ai-game-creator-shell/tests/designProjectRestore.test.tsx @@ -4,23 +4,25 @@ import { afterEach, expect, it, vi } from 'vitest'; import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; import { useHomeProjectCreation } from '../src/features/app-shell/useHomeProjectCreation'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; afterEach(() => { cleanup(); - delete window.__TAURI__; + resetTauriRuntime(); }); const projectPath = 'C:/test/design-project'; const manifest = createGameCreationAppManifest('test-project', '策划项目'); -function setup(invoke: ReturnType) { - window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__; +function setup( + invoke: (command: string, args?: Record) => Promise, +) { + installTauriRuntime({ core: { invoke } }); return renderHook(() => useHomeProjectCreation({ setStatus: vi.fn(), showTransientNotice: vi.fn(), setLauncherView: vi.fn(), - setAgentChatProjectPath: vi.fn(), rememberRecentWorkspace: vi.fn(), }), ); diff --git a/apps/ai-game-creator-shell/tests/designReplyAnimation.test.tsx b/apps/ai-game-creator-shell/tests/designReplyAnimation.test.tsx index e51bb4fb3..a19b54fbe 100644 --- a/apps/ai-game-creator-shell/tests/designReplyAnimation.test.tsx +++ b/apps/ai-game-creator-shell/tests/designReplyAnimation.test.tsx @@ -5,7 +5,9 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import type { DesignView } from '../src/app/types'; import { useDesignReplyAnimation } from '../src/view/project-development/planning/useDesignReplyAnimation'; -beforeEach(() => vi.useFakeTimers()); +beforeEach(() => { + vi.useFakeTimers(); +}); afterEach(() => { cleanup(); vi.useRealTimers(); @@ -39,7 +41,7 @@ it('keeps each reply progress across tool snapshots and repeated completion', () result.current.receiveText(first.id, first.text); vi.advanceTimersByTime(100); }); - expect(result.current.replies[0].visible).toBe('第一'); + expect(result.current.replies[0]!.visible).toBe('第一'); act(() => result.current.receiveView( view( @@ -49,7 +51,7 @@ it('keeps each reply progress across tool snapshots and repeated completion', () ), ); expect(result.current.replies).toHaveLength(1); - expect(result.current.replies[0].visible).toBe('第一'); + expect(result.current.replies[0]!.visible).toBe('第一'); act(() => result.current.receiveText(second.id, second.text)); const terminal = view([history, first, second]); act(() => { @@ -74,9 +76,9 @@ it('resets only an unpersisted retry attempt and discards failed partial output' result.current.receiveText('t:response:0', '尝试失败'); vi.advanceTimersByTime(100); }); - expect(result.current.replies[0].visible).toBe('尝试'); + expect(result.current.replies[0]!.visible).toBe('尝试'); act(() => result.current.receiveText('t:response:0', '')); - expect(result.current.replies[0].visible).toBe(''); + expect(result.current.replies[0]!.visible).toBe(''); const saved = { id: 't:response:0', role: 'assistant', text: '成功' }; act(() => { result.current.receiveText(saved.id, saved.text); diff --git a/apps/ai-game-creator-shell/tests/designWorkspaceDebug.test.tsx b/apps/ai-game-creator-shell/tests/designWorkspaceDebug.test.tsx index 55f9b4082..0e9949803 100644 --- a/apps/ai-game-creator-shell/tests/designWorkspaceDebug.test.tsx +++ b/apps/ai-game-creator-shell/tests/designWorkspaceDebug.test.tsx @@ -12,6 +12,7 @@ import React from 'react'; import { afterEach, expect, it, vi } from 'vitest'; import { DesignWorkspacePanel } from '../src/features/project-workspace/DesignWorkspacePanel'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; vi.mock('../src/components/ChatMarkdownMessage', () => ({ ChatMarkdownMessage: () => null, @@ -20,7 +21,7 @@ vi.mock('../src/components/ChatMarkdownMessage', () => ({ afterEach(() => { cleanup(); vi.unstubAllEnvs(); - delete window.__TAURI__; + resetTauriRuntime(); }); it('shows imported references after a workspace event without reloading the design session', async () => { @@ -41,7 +42,7 @@ it('shows imported references after a workspace event without reloading the desi if (command === 'read_design_workspace_file') return '# 导入的需求'; throw new Error(`Unexpected command: ${command}`); }); - window.__TAURI__ = { + installTauriRuntime({ core: { invoke }, event: { listen: vi.fn(async (_name, callback) => { @@ -49,7 +50,7 @@ it('shows imported references after a workspace event without reloading the desi return () => undefined; }), }, - } as unknown as typeof window.__TAURI__; + }); render(); await waitFor(() => expect(onUpdate).toBeDefined()); await waitFor(() => @@ -95,7 +96,7 @@ it('prepares debug fixtures from the header and refreshes the phase and files wi } throw new Error(`Unexpected command: ${command}`); }); - window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__; + installTauriRuntime({ core: { invoke } }); render(); await waitFor(() => expect( diff --git a/apps/ai-game-creator-shell/tests/dev-port.test.ts b/apps/ai-game-creator-shell/tests/dev-port.test.ts index e7a8f3036..b72ac4eb8 100644 --- a/apps/ai-game-creator-shell/tests/dev-port.test.ts +++ b/apps/ai-game-creator-shell/tests/dev-port.test.ts @@ -4,14 +4,77 @@ import { createAgcAdminWebEndpoint, createAgcDevEndpoint, readConfiguredAgcAdminWebPort, - resolveAgcAdminWebEndpoint, - resolveAgcDevEndpoint, + resolveAgcAdminWebEndpoint as resolveAgcAdminWebEndpointImpl, + resolveAgcDevEndpoint as resolveAgcDevEndpointImpl, withAgcDevEndpointEnv, } from '../scripts/dev-port.mjs'; +// scripts/dev-port.mjs 没有 JSDoc,TS 按 JS 默认值把可选参数推成了过窄类型 +// (findPort 的 portRange 推成 null、reservedPorts 推成 never[]),与测试注入的 mock 结构不兼容。 +// 下面按运行时真实契约重新声明局部签名,只做类型层校正,调用方式与行为完全不变。 +type EndpointPortRange = { start: number; end: number; label: string }; + +type AgcDevEndpointLike = { + host: string; + port: number; + url: string; + markerUrl: string; + portRange: EndpointPortRange | null; +}; + +type AgcAdminWebEndpointLike = { + host: string; + port: number; + origin: string; + basePath: string; + url: string; + portRange: EndpointPortRange | null; +}; + +type FindPortOptions = { + host?: string; + preferredPort: number; + reservedPorts?: Set; + maxAttempts?: number | null; + portRange?: EndpointPortRange | null; + strict?: boolean; +}; + +type ReservePortRangeResult = + | { range?: EndpointPortRange | null } + | null + | undefined; + +type ResolveAgcDevEndpointOptions = { + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + strictConfigured?: boolean; + reservePortRange?: (options?: { + env?: NodeJS.ProcessEnv; + }) => Promise; + findPort?: (options: FindPortOptions) => number | Promise; +}; + +type ResolveAgcAdminWebEndpointOptions = ResolveAgcDevEndpointOptions & { + reservedPorts?: number[]; +}; + +// 导入实现本身是同一份,仅按运行时契约修正推导过窄的签名(js 无 JSDoc 导致结构上不兼容)。 +const resolveAgcDevEndpoint = resolveAgcDevEndpointImpl as unknown as ( + options?: ResolveAgcDevEndpointOptions, +) => Promise; + +// 同上:reservedPorts 在 JS 推断里被收窄为 never[],与实际可传入的保留端口数组不兼容。 +const resolveAgcAdminWebEndpoint = + resolveAgcAdminWebEndpointImpl as unknown as ( + options?: ResolveAgcAdminWebEndpointOptions, + ) => Promise; + describe('AI 游戏创作 dev 端口', () => { test('Linux 使用用户端口段的 start + 5 槽位', async () => { - const findPort = vi.fn(async ({ preferredPort }) => preferredPort); + const findPort = vi.fn( + async ({ preferredPort }: FindPortOptions) => preferredPort, + ); const endpoint = await resolveAgcDevEndpoint({ env: { USER: 'alice', LOGNAME: 'alice' }, platform: 'linux', @@ -44,7 +107,7 @@ describe('AI 游戏创作 dev 端口', () => { username: 'alice', range: { start: 10000, end: 10099, label: '10000-10099' }, }), - findPort: async ({ preferredPort, portRange }) => { + findPort: async ({ preferredPort, portRange }: FindPortOptions) => { expect(preferredPort).toBe(10005); expect(portRange?.label).toBe('10000-10099'); return 10006; @@ -55,7 +118,9 @@ describe('AI 游戏创作 dev 端口', () => { }); test('父启动器选定端口后子启动器严格使用同一端口', async () => { - const findPort = vi.fn(async ({ preferredPort }) => preferredPort); + const findPort = vi.fn( + async ({ preferredPort }: FindPortOptions) => preferredPort, + ); await resolveAgcDevEndpoint({ env: { USER: 'alice', @@ -134,7 +199,9 @@ describe('AI 游戏创作 dev 端口', () => { describe('AGC 开发态后台 Web 端口', () => { test('Linux 使用用户端口段的 start + 3 槽位', async () => { - const findPort = vi.fn(async ({ preferredPort }) => preferredPort); + const findPort = vi.fn( + async ({ preferredPort }: FindPortOptions) => preferredPort, + ); const endpoint = await resolveAgcAdminWebEndpoint({ env: { USER: 'alice', LOGNAME: 'alice' }, platform: 'linux', @@ -161,7 +228,9 @@ describe('AGC 开发态后台 Web 端口', () => { }); test('AGC Vite 端口作为保留端口传入,避免被后台 Web 抢先占用', async () => { - const findPort = vi.fn(async ({ preferredPort }) => preferredPort); + const findPort = vi.fn( + async ({ preferredPort }: FindPortOptions) => preferredPort, + ); await resolveAgcAdminWebEndpoint({ env: {}, platform: 'win32', @@ -170,11 +239,13 @@ describe('AGC 开发态后台 Web 端口', () => { }); const [call] = findPort.mock.calls; - expect([...call[0].reservedPorts]).toEqual([10005]); + expect([...call![0].reservedPorts!]).toEqual([10005]); }); test('非 Linux 保留 3102 兼容优先端口并允许统一漂移', async () => { - const findPort = vi.fn(async ({ preferredPort }) => preferredPort + 1); + const findPort = vi.fn( + async ({ preferredPort }: FindPortOptions) => preferredPort + 1, + ); const endpoint = await resolveAgcAdminWebEndpoint({ env: {}, platform: 'win32', @@ -189,7 +260,9 @@ describe('AGC 开发态后台 Web 端口', () => { }); test('显式 ADMIN_WEB_PORT 与脚本统一命名并支持严格占用', async () => { - const findPort = vi.fn(async ({ preferredPort }) => preferredPort); + const findPort = vi.fn( + async ({ preferredPort }: FindPortOptions) => preferredPort, + ); await resolveAgcAdminWebEndpoint({ env: { ADMIN_WEB_PORT: '3109' }, platform: 'win32', diff --git a/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx b/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx index 6cb5e3856..52c52c362 100644 --- a/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx +++ b/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx @@ -26,11 +26,15 @@ afterEach(() => { eventFake = null; }); -const ACTIVE_TURN = { +const ACTIVE_TURN: GameCreatorDirectActiveTurn = { projectPath: 'C:/projects/demo', - agentId: 'planning-agent-v2', - runId: 'run-1', -} as unknown as GameCreatorDirectActiveTurn; + projectName: '示例项目', + turnId: 'turn-1', + startedAt: 1, + status: 'running', + updatedAt: 2, + sequence: 1, +}; describe('useDirectActiveTurns', () => { it('keeps the snapshot identity when an event-triggered read returns the same content', async () => { @@ -56,7 +60,10 @@ describe('useDirectActiveTurns', () => { }); it('subscribes before the first snapshot and does not read while quiet', async () => { - const invoke = vi.fn(async () => [ACTIVE_TURN]); + const invoke = vi.fn< + [command: string], + Promise + >(async () => [ACTIVE_TURN]); eventFake = createTauriEventFake({ invoke }); eventFake.install(); const setIntervalSpy = vi.spyOn(window, 'setInterval'); @@ -235,7 +242,7 @@ describe('useDirectActiveTurns', () => { completeOld = resolve; }), ) - .mockResolvedValue([{ ...ACTIVE_TURN, runId: 'new-run' }]); + .mockResolvedValue([{ ...ACTIVE_TURN, turnId: 'new-run' }]); const { result, rerender } = renderHook( ({ enabled }) => useDirectActiveTurns({ invoke: invoke as never, enabled }), @@ -244,7 +251,7 @@ describe('useDirectActiveTurns', () => { rerender({ enabled: false }); rerender({ enabled: true }); await waitFor(() => - expect(result.current.activeTurns[0]?.runId).toBe('new-run'), + expect(result.current.activeTurns[0]?.turnId).toBe('new-run'), ); const current = result.current.activeTurns; await act(async () => { @@ -289,7 +296,7 @@ describe('ActiveProjectRunsPanel', () => { true, false, ]); - fireEvent.click(items[0]); + fireEvent.click(items[0]!); expect(onOpenProject).toHaveBeenCalledWith('C:/projects/first'); }); diff --git a/apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts b/apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts index 3e620c89f..cff60d505 100644 --- a/apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts +++ b/apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts @@ -95,7 +95,7 @@ describe('DirectProject 历史分页连拉', () => { expect(result.error).toBeNull(); const merged = selectDirectChatEntries( mergeDirectHistoryItems( - { turnRunning: false, history: existing, live: [] }, + { ...emptyDirectThreadChatState(), history: existing }, result.items, ), ); diff --git a/apps/ai-game-creator-shell/tests/directProjectProcessStatus.test.tsx b/apps/ai-game-creator-shell/tests/directProjectProcessStatus.test.tsx index 502a23058..70be0266c 100644 --- a/apps/ai-game-creator-shell/tests/directProjectProcessStatus.test.tsx +++ b/apps/ai-game-creator-shell/tests/directProjectProcessStatus.test.tsx @@ -24,6 +24,7 @@ test('运行中状态条的读秒按 100ms 刷新:不足一分钟的耗时以 vi.setSystemTime(STARTED_AT); const view = render( { try { render( & Partial, @@ -26,7 +24,15 @@ function withUserItemId( lifecycle: ThreadEvent, userItemId: string, ): ThreadEvent { - return { ...lifecycle, userItemId }; + // `userItemId` 只属于 `turn.started` / `turn.completed` 两种生命周期事件; + // 其余事件没有这个字段,原样返回。 + if ( + lifecycle.type === 'turn.started' || + lifecycle.type === 'turn.completed' + ) { + return { ...lifecycle, userItemId }; + } + return lifecycle; } /** app-server `item/started`:工具真正开始执行,itemId 就是归一后的唯一身份。 */ @@ -44,12 +50,17 @@ function toolStarted( } /** 原始 response item 的输出条目:Rust 已经把它归一成同一个 itemId。 */ -function toolOutput(): ThreadItem { +function toolOutput( + overrides: Partial< + Extract + > = {}, +): ThreadItem { return { itemType: 'function_call_output', itemId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', output: 'assets\ngame', at: 2000, + ...overrides, }; } @@ -295,7 +306,7 @@ describe('DirectProject 聊天 reducer', () => { id: `direct-codex:${clientTurnId}:user`, content: [{ type: 'input_text', text }], }, - } as Partial); + }); } it('按事件顺序折成队列,取消与放行都只按身份移除', () => { @@ -738,12 +749,12 @@ describe('DirectProject 聊天 reducer', () => { }); it('内部工具的阶段状态:开始事件就是运行中,正式完成事件才结束', () => { - const fileChange = { + const fileChange: Extract = { itemType: 'fileChange', itemId: 'file-1', changes: [{ path: 'game/src/hero.ts', kind: 'update' }], at: 0, - } as const; + }; const started = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ event({ type: 'item.started', at: 1_000_100, item: fileChange }), ]); @@ -811,7 +822,7 @@ describe('DirectProject 聊天 reducer', () => { event({ type: 'turn.started', at: 1_000_000 }), // 宿主收口的毫秒时间:原生回合时间是秒级精度,"上一轮结束之后又来一条开始事件" // 就是新回合,不能因为时间不比终点晚就把它当重放吞掉。 - event({ type: 'turn.completed', status: 'cancelled', at: 1_000_900 }), + event({ type: 'turn.completed', status: 'aborted', at: 1_000_900 }), ]); const next = reduceDirectThreadEvents(done, [ event({ type: 'turn.started', at: 1_000_000 }), diff --git a/apps/ai-game-creator-shell/tests/directThreadChatSubscription.test.tsx b/apps/ai-game-creator-shell/tests/directThreadChatSubscription.test.tsx index 91d0061a9..da10d1fc4 100644 --- a/apps/ai-game-creator-shell/tests/directThreadChatSubscription.test.tsx +++ b/apps/ai-game-creator-shell/tests/directThreadChatSubscription.test.tsx @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { useDirectThreadChatSubscription } from '../src/view/project-development/chat/controller/useDirectThreadChatSubscription'; import { createTauriEventFake, type TauriEventFake } from './tauriEventFake'; +import { resetTauriRuntime } from './tauriRuntimeFake'; const BOOTSTRAP = { subscriptionId: 'direct-subscription-1', @@ -17,8 +18,9 @@ afterEach(() => { cleanup(); eventFake?.restore(); eventFake = null; - delete window.__TAURI__; - delete window.__TAURI_INTERNALS__; + resetTauriRuntime(); + delete (window as Window & { __TAURI_INTERNALS__?: unknown }) + .__TAURI_INTERNALS__; }); function installEventFake( diff --git a/apps/ai-game-creator-shell/tests/directTurnFailure.test.ts b/apps/ai-game-creator-shell/tests/directTurnFailure.test.ts new file mode 100644 index 000000000..2eb77fcc3 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/directTurnFailure.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; + +import { directTurnFailureNoticeText } from '../src/view/project-development/chat/conversation/directTurnFailure'; + +describe('DirectProject 上游失败文案', () => { + it.each([401, 403, 408, 429, 500, 502, 503, 504])( + '保留 HTTP %s 的具体原因', + (statusCode) => { + const text = directTurnFailureNoticeText({ + type: 'modelCallFailed', + kind: { type: 'upstreamFailed', statusCode, native: null }, + detail: `上游 HTTP ${statusCode}`, + }); + + expect(text).toContain(`HTTP ${statusCode}`); + expect(text).not.toContain('智能创作请求无效'); + }, + ); + + it('原生分类优先于 HTTP 状态文案', () => { + const text = directTurnFailureNoticeText({ + type: 'modelCallFailed', + kind: { + type: 'upstreamFailed', + statusCode: 429, + native: { type: 'context-window-exceeded' }, + }, + detail: 'upstream', + }); + + expect(text).toContain('模型上下文已超限'); + expect(text).not.toContain('HTTP 429'); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts b/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts index 7dbe863b7..e0e1dd3a7 100644 --- a/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts +++ b/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts @@ -369,9 +369,9 @@ describe('DirectProject 聊天分区', () => { // 「本轮结束于 … 」隐藏(不再出现 0.0 秒)。 expect(turns[1]?.users).toEqual([]); expect(turns[1]?.startedAt).toBe(0); - expect(turns[1]?.finals.map((block) => block.text)).toEqual([ - '待发消息已达上限(最多 5 条),请等前面几条发完再发送', - ]); + expect( + turns[1]?.finals.map((block) => ('text' in block ? block.text : '')), + ).toEqual(['待发消息已达上限(最多 5 条),请等前面几条发完再发送']); }); it('历史无用户条目时也保留一个回合承载正文', () => { diff --git a/apps/ai-game-creator-shell/tests/errorReporting.test.ts b/apps/ai-game-creator-shell/tests/errorReporting.test.ts index dc4e8e9c7..104ea9fc0 100644 --- a/apps/ai-game-creator-shell/tests/errorReporting.test.ts +++ b/apps/ai-game-creator-shell/tests/errorReporting.test.ts @@ -85,6 +85,7 @@ import { subscribeClientErrorEvents, } from '../src/services/errorReporting'; import type { ClientAuthError } from '../src/services/generated/ClientAuthError'; +import { resetTauriRuntime } from './tauriRuntimeFake'; describe('客户端错误报告池', () => { afterEach(() => { @@ -94,12 +95,12 @@ describe('客户端错误报告池', () => { }); it('按 fingerprint 合并重复错误并累计次数', async () => { - const first = await captureClientError(new Error('重复错误'), { + const first = (await captureClientError(new Error('重复错误'), { source: 'test', - }); - const second = await captureClientError(new Error('重复错误'), { + }))!; + const second = (await captureClientError(new Error('重复错误'), { source: 'test', - }); + }))!; expect(second.eventId).toBe(first.eventId); expect(await getPendingClientErrorEvents()).toHaveLength(1); @@ -129,12 +130,12 @@ describe('客户端错误报告池', () => { }); it('提交成功后只清除本次提交的事件', async () => { - const first = await captureClientError(new Error('第一个错误'), { + const first = (await captureClientError(new Error('第一个错误'), { source: 'test', - }); - const second = await captureClientError(new Error('第二个错误'), { + }))!; + const second = (await captureClientError(new Error('第二个错误'), { source: 'test', - }); + }))!; await submitErrorReportBatch({ events: [first], logs: [] }); await markClientErrorEventsSubmitted([first]); @@ -143,9 +144,9 @@ describe('客户端错误报告池', () => { }); it('提交只把脱敏事件交给 Rust,不在渲染层携带凭据或 submissionId', async () => { - const event = await captureClientError(new Error('可重试错误'), { + const event = (await captureClientError(new Error('可重试错误'), { source: 'test', - }); + }))!; vi.mocked(invoke).mockImplementationOnce(async (command: string) => { if (command !== 'submit_error_report') { throw new Error(`unexpected invoke ${command}`); @@ -174,9 +175,9 @@ describe('客户端错误报告池', () => { it('本地 ack 失败时自动重试且不向调用方抛错', async () => { vi.useFakeTimers(); - const event = await captureClientError(new Error('ack 重试错误'), { + const event = (await captureClientError(new Error('ack 重试错误'), { source: 'test', - }); + }))!; vi.mocked(invoke) .mockRejectedValueOnce(new Error('暂时不可用')) .mockResolvedValueOnce(undefined); @@ -197,9 +198,9 @@ describe('客户端错误报告池', () => { }); it('登录态缺失时把 Rust 的稳定分类原样透出,不清除本地事件', async () => { - const event = await captureClientError(new Error('未登录错误'), { + const event = (await captureClientError(new Error('未登录错误'), { source: 'test', - }); + }))!; vi.mocked(invoke).mockRejectedValueOnce( new Error('authentication-required: 请先登录后再提交错误报告'), ); @@ -211,7 +212,10 @@ describe('客户端错误报告池', () => { }); it('解包 ClientAuthErrorWrapper,把载体序列化后的 message 原样上报,上下文只认显式入参', async () => { - const original = { type: 'authNetworkFailure', reason: 'unreachable' }; + const original: ClientAuthError = { + type: 'authNetworkFailure', + reason: 'unreachable', + }; await captureClientError(new ClientAuthErrorWrapper(original), { source: 'unhandledrejection', @@ -269,7 +273,7 @@ describe('客户端错误报告池', () => { }); it('浏览器预览中订阅错误事件不会产生未处理拒绝', async () => { - delete window.__TAURI__; + resetTauriRuntime(); const unsubscribe = subscribeClientErrorEvents(vi.fn()); unsubscribe(); diff --git a/apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts b/apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts index a55e9fee9..5ae63e1af 100644 --- a/apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts +++ b/apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts @@ -20,7 +20,7 @@ import JSZip from 'jszip'; import { expect, test, vi } from 'vitest'; import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; -import type { StagedGamePackage } from '../src/app/types'; +import type { StagedGamePackage, TauriInvoke } from '../src/app/types'; import { uploadPlatformMediaAsset } from '../src/services/assetDirectUpload'; import { setClientServerSelection } from '../src/services/clientHttp'; import { publishLocalProjectGame } from '../src/services/gameDistributionPublish'; @@ -461,6 +461,7 @@ liveTest( } as unknown as GameCreationAppManifest; // 记录本次发布实际发送过的分片偏移,用来证明「中断后不重传已收字节」。 const sentOffsets: number[] = []; + // 该 mock 只按命令返回固定结构,无法满足 TauriInvoke 的泛型逐命令建模,故整体断言为 TauriInvoke。 const invoke = vi.fn( async (command: string, args?: Record) => { if (command !== 'publish_local_project_game') { @@ -544,7 +545,7 @@ liveTest( fileCount: staged.packageFileCount, }; }, - ); + ) as unknown as TauriInvoke; // 服务端要求发布必须带封面。真实 Tauri 里 `upload_platform_media_asset` 由 Rust // 完成「凭证 → 对象存储直传 → confirm」;jsdom 没有原生宿主,这里用同一个 // invoke 合同按服务端协议代跑,证明渲染层只提交字节和结构化元数据。 @@ -553,14 +554,17 @@ liveTest( assetKind: 'game_distribution_cover', pathSegments: ['game-distribution', 'cover', stamp], entityId: 'game-distribution-cover', - invoke: async (command, args) => { + invoke: async ( + command: string, + args?: Record, + ): Promise => { if (command !== 'upload_platform_media_asset') { throw new Error(`未预期的命令:${command}`); } - return await simulateNativePlatformAssetUpload( + return (await simulateNativePlatformAssetUpload( args as { input: Record }, token, - ); + )) as T; }, }); expect(uploadedCover.assetObjectId).toMatch(/\S/u); diff --git a/apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx b/apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx index ddc3458b8..98de312bf 100644 --- a/apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx +++ b/apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx @@ -115,10 +115,11 @@ function renderPanel( beforeEach(() => { vi.mocked(readGameCoverGenerationPrice).mockImplementation( - () => new Promise(() => undefined), + // 永不落定的占位 Promise:用 never 兼容各返回类型,运行时行为不变。 + () => new Promise(() => undefined), ); vi.mocked(suggestGameDistributionPublishMetadata).mockImplementation( - () => new Promise(() => undefined), + () => new Promise(() => undefined), ); }); @@ -130,7 +131,9 @@ afterEach(() => { vi.mocked(suggestGameDistributionPublishMetadata).mockReset(); vi.mocked(uploadPlatformMediaAsset).mockReset(); delete (window as unknown as { __TAURI__?: unknown }).__TAURI__; - window.localStorage.clear(); + // Node 26 下 vitest 的 jsdom 不再注入 localStorage(Node 全局 localStorage 为 undefined), + // 本用例也不读写它;这里只做存在性保护,避免清理阶段抛错。 + (window.localStorage as Storage | undefined)?.clear(); }); describe('GameDistributionPublishPanel', () => { @@ -201,8 +204,9 @@ describe('GameDistributionPublishPanel', () => { await waitFor(() => { expect(suggestGameDistributionPublishMetadata).toHaveBeenCalled(); }); + // waitFor 已确保至少调用过一次,这里用非空断言取出首次调用的入参。 const [request] = vi.mocked(suggestGameDistributionPublishMetadata).mock - .calls[0]; + .calls[0]!; const context = String(request.context ?? ''); expect(context.length).toBeGreaterThanOrEqual(5_000); expect(context.length).toBeLessThanOrEqual(6_000); diff --git a/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx b/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx index 786c790a7..8bae3c725 100644 --- a/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx +++ b/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx @@ -10,6 +10,7 @@ import { readFileSync } from 'node:fs'; import { beforeEach, describe, it, vi } from 'vitest'; import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; +import type { ProjectPermissionPolicyView } from '../src/app/types'; import { App, createGameCreationAppManifest, @@ -28,6 +29,7 @@ import { parseStyleSheet, resolveDeclarations, } from './styleCascade'; +import { installTauriRuntime } from './tauriRuntimeFake'; const readGamePublishAvailabilityMock = vi.hoisted(() => vi.fn(async () => true), @@ -77,7 +79,7 @@ function installTauri( options: { exportPackage?: () => unknown; manifest?: GameCreationAppManifest; - policy?: ReturnType; + policy?: ProjectPermissionPolicyView; readPolicy?: () => unknown; } = {}, ) { @@ -106,10 +108,10 @@ function installTauri( return chatHarness.invoke(command, args); }, ); - window.__TAURI__ = { - core: { invoke: invoke as never }, - event: { listen: chatHarness.listen as never }, - }; + installTauriRuntime({ + core: { invoke }, + event: { listen: chatHarness.listen }, + }); return { invoke }; } diff --git a/apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx b/apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx index 34cea47b3..99325fff0 100644 --- a/apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx +++ b/apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx @@ -8,10 +8,11 @@ import { HOME_WEB_PREFLIGHT_IPC_FAILURE, } from '../src/features/app-shell/homeWebPreflight'; import { useHomeProjectCreation } from '../src/features/app-shell/useHomeProjectCreation'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; afterEach(() => { cleanup(); - delete window.__TAURI__; + resetTauriRuntime(); }); function mount(preflight: () => Promise) { @@ -31,13 +32,12 @@ function mount(preflight: () => Promise) { return { activeRuntime: 'design' }; return null; }); - window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__; + installTauriRuntime({ core: { invoke } }); const hook = renderHook(() => useHomeProjectCreation({ setStatus: vi.fn(), showTransientNotice: vi.fn(), setLauncherView: vi.fn(), - setAgentChatProjectPath: vi.fn(), rememberRecentWorkspace: vi.fn(), }), ); diff --git a/apps/ai-game-creator-shell/tests/imageAssetSelector.test.tsx b/apps/ai-game-creator-shell/tests/imageAssetSelector.test.tsx index a012f2d60..3cb629c5a 100644 --- a/apps/ai-game-creator-shell/tests/imageAssetSelector.test.tsx +++ b/apps/ai-game-creator-shell/tests/imageAssetSelector.test.tsx @@ -9,9 +9,10 @@ import { import userEvent from '@testing-library/user-event'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { SpriteAsset } from '../src/features/ui-editor/types/SpriteAsset'; import { ImageAssetSelector } from '../src/view/ui-editor/components/Inspector/Components/ImageAssetSelector'; -const sprites = { +const sprites: Record = { hero: { asset_id: 'hero', metadata: { name: '主角', asset_type: 'sprite' }, diff --git a/apps/ai-game-creator-shell/tests/launcherNoticeShellWiring.test.tsx b/apps/ai-game-creator-shell/tests/launcherNoticeShellWiring.test.tsx index 1b75163a8..6741e4ea3 100644 --- a/apps/ai-game-creator-shell/tests/launcherNoticeShellWiring.test.tsx +++ b/apps/ai-game-creator-shell/tests/launcherNoticeShellWiring.test.tsx @@ -26,6 +26,7 @@ import { pickProjectFromLauncher, testAuthUser, } from './appSurface/harness'; +import { installTauriRuntime } from './tauriRuntimeFake'; const PROJECT_PATH = '/tmp/run-notice-shell-project'; @@ -115,10 +116,10 @@ function renderWorkbench() { return runtimeHarness.invoke(command, args); }, ); - window.__TAURI__ = { - core: { invoke: invoke as never }, - event: { listen: runtimeHarness.listen as never }, - }; + installTauriRuntime({ + core: { invoke }, + event: { listen: runtimeHarness.listen }, + }); render( { } return runtimeHarness.invoke(command); }); - window.__TAURI__ = { - core: { invoke: invoke as never }, - event: { listen: runtimeHarness.listen as never }, - }; + installTauriRuntime({ + core: { invoke }, + event: { listen: runtimeHarness.listen }, + }); render( { }; const oldChildRect = resolvePageRect(child.layout.transform, oldParentRect); - const [{ transform }] = resolveChildrenTransformsForParentRect( + const [resolvedChild] = resolveChildrenTransformsForParentRect( [child], oldParentRect, newParentRect, ); - expect(resolvePageRect(transform, newParentRect)).toEqual(oldChildRect); + expect(resolvePageRect(resolvedChild!.transform, newParentRect)).toEqual( + oldChildRect, + ); }); it('keeps the page rectangle stable when inspector anchors change', () => { diff --git a/apps/ai-game-creator-shell/tests/platformSession.test.ts b/apps/ai-game-creator-shell/tests/platformSession.test.ts index 10734765c..29273e89e 100644 --- a/apps/ai-game-creator-shell/tests/platformSession.test.ts +++ b/apps/ai-game-creator-shell/tests/platformSession.test.ts @@ -8,6 +8,8 @@ */ import { beforeEach, expect, test, vi } from 'vitest'; +import type { AuthUser } from '../../../packages/shared/src/contracts/auth'; + const refreshClientAuthSession = vi.hoisted(() => vi.fn()); vi.mock('../src/services/clientAuth', () => ({ @@ -24,7 +26,7 @@ import { subscribePlatformSessionGeneration, } from '../src/services/platformSession'; -const userA = { +const userA: AuthUser = { id: 'user-a', publicUserCode: 'tn-a', displayName: '账号 A', diff --git a/apps/ai-game-creator-shell/tests/pluginHost.test.ts b/apps/ai-game-creator-shell/tests/pluginHost.test.ts index 416ffeca6..66f3a9afa 100644 --- a/apps/ai-game-creator-shell/tests/pluginHost.test.ts +++ b/apps/ai-game-creator-shell/tests/pluginHost.test.ts @@ -5,7 +5,9 @@ import { startAvailableAgcEditorPlugins, } from '../src/services/pluginHost'; -afterEach(() => vi.unstubAllGlobals()); +afterEach(() => { + vi.unstubAllGlobals(); +}); const editorPlugins = [ { id: 'agc-cocos-editor', name: 'Cocos Creator' }, diff --git a/apps/ai-game-creator-shell/tests/previewActivation.test.tsx b/apps/ai-game-creator-shell/tests/previewActivation.test.tsx index 68a7af95a..8816daaac 100644 --- a/apps/ai-game-creator-shell/tests/previewActivation.test.tsx +++ b/apps/ai-game-creator-shell/tests/previewActivation.test.tsx @@ -32,6 +32,7 @@ import { screen, waitFor, } from './appSurface/harness'; +import { installTauriRuntime } from './tauriRuntimeFake'; const PROJECT_PATH = '/tmp/preview-activation-project'; const PROJECT_ID = 'preview-activation-project'; @@ -75,10 +76,10 @@ function installTauri( return chatHarness.invoke(command, args); }, ); - window.__TAURI__ = { - core: { invoke: invoke as never }, - event: { listen: chatHarness.listen as never }, - }; + installTauriRuntime({ + core: { invoke }, + event: { listen: chatHarness.listen }, + }); return { invoke, invokeCounts }; } diff --git a/apps/ai-game-creator-shell/tests/previewRightPanDrag.test.tsx b/apps/ai-game-creator-shell/tests/previewRightPanDrag.test.tsx index aab60e92a..2773dec5f 100644 --- a/apps/ai-game-creator-shell/tests/previewRightPanDrag.test.tsx +++ b/apps/ai-game-creator-shell/tests/previewRightPanDrag.test.tsx @@ -60,12 +60,6 @@ function node( } const activeImage: UIDesignImage = { - metadata: { - name: '测试界面', - description: '', - role: null, - slave_to: null, - }, path: 'assets/page.png', pixel_size: [1200, 800], pixels_per_unit: 1, @@ -102,6 +96,7 @@ function createCanvas( sprites: {}, fontFaces: {}, tree: { src_ui_design: 'page-1', root }, + selectedTreeId: null, selectedNode: null, selectedNodeId: null, keepChildrenUnchanged: false, @@ -240,7 +235,9 @@ describe('PreviewWorkspace tree drag', () => { pointerId: 11, }); expect(committed.canvas.setTreeOffset).toHaveBeenCalledTimes(1); - expect(committed.canvas.setTreeOffset.mock.calls[0]?.[0]).toBe('page-1'); + expect(vi.mocked(committed.canvas.setTreeOffset).mock.calls[0]?.[0]).toBe( + 'page-1', + ); cleanup(); const cancelled = renderTreeDrag(); diff --git a/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx b/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx index 6570fb463..a302621da 100644 --- a/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx +++ b/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx @@ -70,6 +70,7 @@ function createCanvas( sprites: {}, fontFaces: {}, tree: { src_ui_design: 'page-1', root }, + selectedTreeId: null, selectedNode: null, selectedNodeId: null, keepChildrenUnchanged: false, @@ -85,6 +86,7 @@ function createCanvas( insertNode: vi.fn(), insertNodeAfter: vi.fn(), deleteNode: vi.fn(), + setTreeOffset: vi.fn(), openClearDialog: vi.fn(), ...overrides, }; @@ -136,7 +138,14 @@ describe('PreviewWorkspace quick zoom', () => { }); it('applies the adaptive screen-space spacing to the preview background', () => { - const rendered = render(); + const rendered = render( + , + ); const preview = screen.getByRole('region', { name: 'UI 预览画布' }); const scale = logicalViewportScale(rendered.container); const [backgroundWidth] = preview.style.backgroundSize.split('px'); @@ -147,7 +156,14 @@ describe('PreviewWorkspace quick zoom', () => { }); it('uses the toolbar zoom step while the preview is hovered', () => { - const rendered = render(); + const rendered = render( + , + ); const preview = screen.getByRole('region', { name: 'UI 预览画布' }); const before = logicalViewportScale(rendered.container); @@ -164,7 +180,14 @@ describe('PreviewWorkspace quick zoom', () => { }); it('requires hover or focus and ignores interactive targets', () => { - const rendered = render(); + const rendered = render( + , + ); const preview = screen.getByRole('region', { name: 'UI 预览画布' }); const zoomButton = screen.getByRole('button', { name: '放大画布' }); const initial = logicalViewportScale(rendered.container); @@ -202,7 +225,14 @@ describe('PreviewWorkspace quick zoom', () => { }); it('keeps the actual-size shortcut within the preview scope', () => { - const rendered = render(); + const rendered = render( + , + ); const preview = screen.getByRole('region', { name: 'UI 预览画布' }); fireEvent.focus(preview); @@ -215,7 +245,14 @@ describe('PreviewWorkspace quick zoom', () => { }); it('refits the preview when the zoom percentage button is clicked', () => { - const rendered = render(); + const rendered = render( + , + ); const fitButton = screen.getByRole('button', { name: '适配画布' }); const zoomInButton = screen.getByRole('button', { name: '放大画布' }); const initial = logicalViewportScale(rendered.container); @@ -237,6 +274,9 @@ describe('PreviewWorkspace quick zoom', () => { activeImageId: null, tree: null, })} + showFrame={false} + showOriginImage={false} + showComponent={true} />, ); @@ -268,12 +308,24 @@ describe('PreviewWorkspace quick zoom', () => { }, }; - const single = render(); + const single = render( + , + ); const singleScale = logicalViewportScale(single.container); single.unmount(); const joint = render( - , + , ); const jointScale = logicalViewportScale(joint.container); // 联合边界(两棵树横跨 1600 单位)明显比单棵树宽:首次进入必须适配全部树。 @@ -283,6 +335,9 @@ describe('PreviewWorkspace quick zoom', () => { joint.rerender( , ); expect(logicalViewportScale(joint.container)).toBe(jointScale); @@ -303,6 +358,9 @@ describe('PreviewWorkspace quick zoom', () => { canvas={createCanvas({ uiTrees: [{ src_ui_design: 'page-1', root: movedRoot }], })} + showFrame={false} + showOriginImage={false} + showComponent={true} />, ); diff --git a/apps/ai-game-creator-shell/tests/processSessionRealE2eFixture.test.ts b/apps/ai-game-creator-shell/tests/processSessionRealE2eFixture.test.ts index 0a80c86b2..327a742cc 100644 --- a/apps/ai-game-creator-shell/tests/processSessionRealE2eFixture.test.ts +++ b/apps/ai-game-creator-shell/tests/processSessionRealE2eFixture.test.ts @@ -67,7 +67,7 @@ describe('process-session real E2E fixture', () => { return new Promise((resolve, reject) => { const waiter = { predicate, - resolve: (line) => { + resolve: (line: string) => { clearTimeout(timer); waiters.delete(waiter); resolve(line); diff --git a/apps/ai-game-creator-shell/tests/projectCreationDirectory.test.ts b/apps/ai-game-creator-shell/tests/projectCreationDirectory.test.ts index 3b5fbfa05..d3551d9d4 100644 --- a/apps/ai-game-creator-shell/tests/projectCreationDirectory.test.ts +++ b/apps/ai-game-creator-shell/tests/projectCreationDirectory.test.ts @@ -9,11 +9,12 @@ import { resetWorkspacePreferencesForTests, setProjectCreationDirectory, } from '../src/features/app-shell/workspacePreferences'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; describe('项目创建目录偏好', () => { beforeEach(() => { resetWorkspacePreferencesForTests(); - delete window.__TAURI__; + resetTauriRuntime(); }); it('去掉首尾空白与多余分隔符,并保留盘根', () => { @@ -81,7 +82,7 @@ describe('项目创建目录偏好', () => { } throw new Error('unexpected invoke ' + command); }); - window.__TAURI__ = { + installTauriRuntime({ core: { invoke }, event: { listen: vi.fn(async (_event, handler) => { @@ -91,7 +92,7 @@ describe('项目创建目录偏好', () => { }; }), }, - }; + }); const { result } = renderHook(() => useProjectCreationDirectory()); await waitFor(() => { diff --git a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx index 574229d91..0cb0a8e0f 100644 --- a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx +++ b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx @@ -2,7 +2,11 @@ import React, { useState } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; +import type { + GameCreationAppAssetKind, + GameCreationAppAssetManifestEntry, + GameCreationAppManifest, +} from '../../../packages/shared/src/contracts/gameCreationApp'; const canvasFixture = vi.hoisted(() => ({ manifest: null as GameCreationAppManifest | null, @@ -29,9 +33,20 @@ import { waitFor, within, } from './appSurface/harness'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; const projectPath = '/tmp/live-canvas-integration'; +/** + * 历史夹具沿用旧 kind `art-image` / `game-rules`,它们已不在当前 + * `GameCreationAppAssetKind` 联合里;用例依赖「未识别 kind」的运行时语义, + * 因此保留原字符串,只在类型层收口。 + */ +function legacyAssetKind(kind: string): GameCreationAppAssetKind { + // 旧 kind 字面量与当前联合结构上不可兼容,只能显式转换以保留原串的运行时语义。 + return kind as unknown as GameCreationAppAssetKind; +} + /** * 切栏目走「资源总览」的栏目缩略卡片(左侧大纲导航已删除)。 * 与两个 appSurface suite 里的同名助手同口径:切完必须落到目标栏目的子画布。 @@ -183,7 +198,7 @@ function LiveWorkbench() { initial.assets = [ { id: 'source-art', - kind: 'art-image', + kind: legacyAssetKind('art-image'), mediaType: 'image/png', localPath: 'assets/source-art.png', source: { kind: 'canvas', taskId: null, resourceId: 'source-resource' }, @@ -279,7 +294,7 @@ function DerivedWorkbench({ initial.assets = [ { id: 'source-rules', - kind: 'game-rules', + kind: legacyAssetKind('game-rules'), mediaType: 'text/markdown', localPath: 'docs/rules.md', source: { kind: 'generated', resourceId: 'rules-resource' }, @@ -288,11 +303,11 @@ function DerivedWorkbench({ ? [ { id: 'source-art', - kind: 'art-image', + kind: legacyAssetKind('art-image'), mediaType: 'image/png', localPath: `assets/${artName}`, source: { kind: 'generated', resourceId: 'art-resource' }, - }, + } satisfies GameCreationAppAssetManifestEntry, ] : []), ...(includeCharacter @@ -304,7 +319,7 @@ function DerivedWorkbench({ mediaType: 'image/png', localPath: `assets/${characterName}`, source: { kind: 'generated', resourceId: 'hero-resource' }, - }, + } satisfies GameCreationAppAssetManifestEntry, ] : []), ]; @@ -415,27 +430,33 @@ function MixedCategoryWorkbench() { '实时画布项目', ); initial.assets = [ - ...Array.from({ length: 6 }, (_, index) => ({ - id: `asset-doc-${index + 1}`, - kind: 'document', - category: 'document' as const, - mediaType: 'text/markdown', - localPath: `docs/note-0${index + 1}.md`, - source: { - kind: 'generated' as const, - resourceId: `doc-resource-${index + 1}`, - }, - })), - ...Array.from({ length: 6 }, (_, index) => ({ - id: `asset-image-${index + 1}`, - kind: 'art-image', - mediaType: 'image/png', - localPath: `assets/img-0${index + 1}.png`, - source: { - kind: 'generated' as const, - resourceId: `image-resource-${index + 1}`, - }, - })), + ...Array.from( + { length: 6 }, + (_, index): GameCreationAppAssetManifestEntry => ({ + id: `asset-doc-${index + 1}`, + kind: 'document', + category: 'document' as const, + mediaType: 'text/markdown', + localPath: `docs/note-0${index + 1}.md`, + source: { + kind: 'generated' as const, + resourceId: `doc-resource-${index + 1}`, + }, + }), + ), + ...Array.from( + { length: 6 }, + (_, index): GameCreationAppAssetManifestEntry => ({ + id: `asset-image-${index + 1}`, + kind: legacyAssetKind('art-image'), + mediaType: 'image/png', + localPath: `assets/img-0${index + 1}.png`, + source: { + kind: 'generated' as const, + resourceId: `image-resource-${index + 1}`, + }, + }), + ), ]; const [manifest, setManifest] = useState(initial); canvasFixture.manifest = manifest; @@ -470,7 +491,7 @@ function SwitchableDerivedWorkbench({ manifest.assets = [ { id: 'source-rules', - kind: 'game-rules', + kind: legacyAssetKind('game-rules'), mediaType: 'text/markdown', localPath: 'docs/rules.md', source: { kind: 'generated', resourceId: 'rules-resource' }, @@ -512,7 +533,7 @@ function SwitchableDerivedWorkbench({ describe('project resource live canvas integration', () => { afterEach(() => { - delete window.__TAURI__; + resetTauriRuntime(); canvasFixture.manifest = null; canvasFixture.revision = 0; }); @@ -656,7 +677,7 @@ describe('project resource live canvas integration', () => { ); const asset = { id: `edit-${operationId}`, - kind: 'game-rules', + kind: legacyAssetKind('game-rules'), mediaType: 'text/markdown', localPath: `assets/edits/${operationId}-recovered-rules.md`, source: { @@ -732,7 +753,7 @@ describe('project resource live canvas integration', () => { const assetId = `edit-${operationId}`; const asset = { id: assetId, - kind: 'game-rules', + kind: legacyAssetKind('game-rules'), mediaType: 'text/markdown', localPath: `assets/edits/${operationId}-rules.md`, source: { @@ -806,10 +827,10 @@ describe('project resource live canvas integration', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { + installTauriRuntime({ core: { invoke }, event: { listen: async () => () => undefined }, - }; + }); return { invoke, deriveCalls, @@ -1348,7 +1369,7 @@ describe('project resource live canvas integration', () => { ); } const base = canvasFixture.manifest!; - const asset = { + const asset: GameCreationAppAssetManifestEntry = { id: `edit-${operation.operationId}`, kind: 'video', mediaType: 'video/mp4', @@ -1619,7 +1640,7 @@ describe('project resource live canvas integration', () => { ); const staleAsset = { id: 'stale-recovered-asset', - kind: 'game-rules', + kind: legacyAssetKind('game-rules'), mediaType: 'text/markdown', localPath: 'assets/stale-recovered-rules.md', source: { @@ -1773,11 +1794,11 @@ describe('project resource live canvas integration', () => { ); const actions = screen.getAllByRole('button', { name: '继续原编辑' }); - fireEvent.click(actions[0]); - fireEvent.click(actions[0]); - expect(actions[0].hasAttribute('disabled')).toBe(true); - expect(actions[1].hasAttribute('disabled')).toBe(false); - fireEvent.click(actions[1]); + fireEvent.click(actions[0]!); + fireEvent.click(actions[0]!); + expect(actions[0]!.hasAttribute('disabled')).toBe(true); + expect(actions[1]!.hasAttribute('disabled')).toBe(false); + fireEvent.click(actions[1]!); await waitFor(() => expect(resumeCalls).toHaveLength(2)); expect(resumeCalls.map((call) => call.operationId)).toEqual([ @@ -1825,8 +1846,8 @@ describe('project resource live canvas integration', () => { }), ); const actions = screen.getAllByRole('button', { name: '继续原编辑' }); - fireEvent.click(actions[0]); - fireEvent.click(actions[1]); + fireEvent.click(actions[0]!); + fireEvent.click(actions[1]!); await act(async () => { firstResume.reject(new Error('并行恢复一失败')); @@ -1894,7 +1915,7 @@ describe('project resource live canvas integration', () => { const base = canvasFixture.manifest!; const asset = { id: 'reopened-dialog-output', - kind: 'game-rules', + kind: legacyAssetKind('game-rules'), mediaType: 'text/markdown', localPath: 'assets/reopened-dialog-output.md', source: { diff --git a/apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts b/apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts index 08ff3209a..d9e4ee0f5 100644 --- a/apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts +++ b/apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts @@ -411,7 +411,10 @@ describe('素材上传后的清单快照必须配对', () => { revision: 3, manifest: manifest('project-live', []), }; - const invoke = async (command: string): Promise => { + const invoke = async ( + command: string, + _args?: Record, + ): Promise => { switch (command) { case 'upload_local_asset': if (advanceOnUpload) { diff --git a/apps/ai-game-creator-shell/tests/projectResourceProjectionModel.test.ts b/apps/ai-game-creator-shell/tests/projectResourceProjectionModel.test.ts index 829ed15c0..4a38324a9 100644 --- a/apps/ai-game-creator-shell/tests/projectResourceProjectionModel.test.ts +++ b/apps/ai-game-creator-shell/tests/projectResourceProjectionModel.test.ts @@ -45,7 +45,9 @@ describe('项目资源投影', () => { }, { id: 'registered-bgm', - kind: 'bgm', + // `bgm` 已不在 canonical kind 词表里,运行时会收口成 `unknown`(落「待归类」); + // 这里直接写真实 union 成员,保持投影结果不变。 + kind: 'unknown', mediaType: 'audio/wav', localPath: 'audio/registered-bgm.wav', source: { kind: 'generated' }, @@ -59,7 +61,8 @@ describe('项目资源投影', () => { }, { id: 'unknown-binary', - kind: 'binary', + // 真实词表用 `unknown` 表示无法解析的二进制产物,替换已退役的 `binary`。 + kind: 'unknown', mediaType: 'application/octet-stream', localPath: 'build/game.bundle', source: { kind: 'generated' }, diff --git a/apps/ai-game-creator-shell/tests/projectSnapshotWorkspace.test.tsx b/apps/ai-game-creator-shell/tests/projectSnapshotWorkspace.test.tsx index 88d12ef53..63f651063 100644 --- a/apps/ai-game-creator-shell/tests/projectSnapshotWorkspace.test.tsx +++ b/apps/ai-game-creator-shell/tests/projectSnapshotWorkspace.test.tsx @@ -5,16 +5,17 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { useProjectSnapshotWorkspace } from '../src/features/app-shell/useProjectSnapshotWorkspace'; import { createProjectSnapshotWorkspaceRegistration } from '../src/services/projectSnapshotWorkspace'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; afterEach(() => { cleanup(); - delete window.__TAURI__; + resetTauriRuntime(); }); describe('项目快照窗口登记', () => { it('在无 projectPath URL 的单窗口登记、切换、离开,StrictMode 不误关项目', async () => { const invoke = vi.fn(async () => undefined); - window.__TAURI__ = { core: { invoke: invoke as never } }; + installTauriRuntime({ core: { invoke } }); function Workspace({ path }: { path: string | null }) { useProjectSnapshotWorkspace(path); return null; diff --git a/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx b/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx index 1bbb6d1d7..f94eebda4 100644 --- a/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx +++ b/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx @@ -3,6 +3,7 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { afterEach, expect, test, vi } from 'vitest'; import { useRecentProjects } from '../src/features/app-shell/useRecentProjects'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; const READY_PROJECT = { projectPath: '/tmp/ready-project', @@ -21,7 +22,7 @@ const READY_PROJECT = { }; afterEach(() => { - delete window.__TAURI__; + resetTauriRuntime(); window.localStorage.clear(); }); @@ -47,7 +48,7 @@ test('刷新坏项目时保留其它项目已确认的正常状态', async () => return READY_PROJECT; }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); window.localStorage.setItem( 'genarrative-ai-game-creator.recent-workspaces.v1', JSON.stringify(['/tmp/ready-project']), @@ -106,7 +107,7 @@ test('单次目录检查失败会就地重试,不会把整行钉成「检查 return READY_PROJECT; }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); window.localStorage.setItem( 'genarrative-ai-game-creator.recent-workspaces.v1', JSON.stringify(['/tmp/ready-project']), @@ -148,7 +149,7 @@ test('失败结果不跨轮保留:刷新时该项回到「检查中」并重 return { ...READY_PROJECT, projectPath }; }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); window.localStorage.setItem( 'genarrative-ai-game-creator.recent-workspaces.v1', JSON.stringify(['/tmp/broken-project']), @@ -193,7 +194,7 @@ test('提权类失败不重试:不放大 UAC 弹窗', async () => { ); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); window.localStorage.setItem( 'genarrative-ai-game-creator.recent-workspaces.v1', JSON.stringify(['/tmp/elevation-project']), @@ -238,7 +239,7 @@ test('Rust 偏好事件与 command 返回同时到达时只检查新增项目一 throw new Error('unexpected invoke ' + command); }, ); - window.__TAURI__ = { + installTauriRuntime({ core: { invoke }, event: { listen: vi.fn(async (_event, handler) => { @@ -248,7 +249,7 @@ test('Rust 偏好事件与 command 返回同时到达时只检查新增项目一 }; }), }, - }; + }); const { result } = renderHook(() => useRecentProjects(vi.fn())); await waitFor(() => { @@ -282,7 +283,7 @@ test('Rust 偏好事件与 command 返回同时到达时只检查新增项目一 ); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); window.localStorage.setItem( 'genarrative-ai-game-creator.recent-workspaces.v1', JSON.stringify(['/tmp/denied-elevation-project']), diff --git a/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts b/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts index 05a19b7b4..98ce0cd79 100644 --- a/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts +++ b/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts @@ -19,6 +19,7 @@ import { type RuntimeRegionReference, } from '../src/features/project-workspace/resourceReferences'; import type { UserContentPart } from '../src/view/project-development/chat/generated/UserContentPart'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; function asset( id: string, @@ -67,7 +68,7 @@ const attachmentPart: UserContentPart = { afterEach(() => { cleanup(); vi.restoreAllMocks(); - window.__TAURI__ = undefined; + resetTauriRuntime(); }); describe('资源引用 provider', () => { @@ -292,7 +293,7 @@ describe('附件 provider', () => { describe('运行画面区域 provider', () => { it('静默且只认运行画面区域 part,可选字段缺失时收成 undefined', () => { expect(runtimeRegionReferenceProvider.trigger).toBe(null); - expect(runtimeRegionReferenceProvider.match).toBeUndefined(); + expect(runtimeRegionReferenceProvider.fuzzyLookup).toBeUndefined(); expect( runtimeRegionReferenceProvider.toReference(runtimeRegionPart), ).toEqual({ @@ -368,7 +369,7 @@ describe('Skill provider', () => { if (command === 'list_client_extensions') return []; throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke: invoke as never } }; + installTauriRuntime({ core: { invoke } }); const { result } = renderHook(() => useSkillReferenceProvider()); expect(result.current.trigger).toBe('$'); @@ -420,7 +421,7 @@ describe('Skill provider', () => { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke: invoke as never } }; + installTauriRuntime({ core: { invoke } }); const { result } = renderHook(() => useSkillReferenceProvider()); act(() => { @@ -450,7 +451,7 @@ describe('Skill provider', () => { if (command === 'list_client_extensions') return []; throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke: invoke as never } }; + installTauriRuntime({ core: { invoke } }); const { result } = renderHook(() => useSkillReferenceProvider()); @@ -511,7 +512,7 @@ describe('Skill provider', () => { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke: invoke as never } }; + installTauriRuntime({ core: { invoke } }); const { result } = renderHook(() => useSkillReferenceProvider()); act(() => { @@ -542,7 +543,7 @@ describe('Skill provider', () => { throw new Error(`unexpected invoke ${command}`); }) .mockRejectedValueOnce(new Error('transient')); - window.__TAURI__ = { core: { invoke: invoke as never } }; + installTauriRuntime({ core: { invoke } }); const { result } = renderHook(() => useSkillReferenceProvider()); act(() => { @@ -568,7 +569,7 @@ describe('Skill provider', () => { it('`toReference` / `refresh` / `mentionToken` 只认 Skill', async () => { const invoke = vi.fn(async () => []); - window.__TAURI__ = { core: { invoke: invoke as never } }; + installTauriRuntime({ core: { invoke } }); const { result } = renderHook(() => useSkillReferenceProvider()); expect( diff --git a/apps/ai-game-creator-shell/tests/resourceBatchTagsIntegration.test.tsx b/apps/ai-game-creator-shell/tests/resourceBatchTagsIntegration.test.tsx index c1d0863b7..3598255aa 100644 --- a/apps/ai-game-creator-shell/tests/resourceBatchTagsIntegration.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceBatchTagsIntegration.test.tsx @@ -17,6 +17,7 @@ import { waitFor, within, } from './appSurface/harness'; +import { installTauriRuntime } from './tauriRuntimeFake'; const PROJECT_PATH = '/tmp/batch-tags-project'; const PROJECT_ID = 'batch-tags-project'; @@ -158,7 +159,7 @@ function BatchTagsHost({ recentRunStopReason={null} onHomeOpen={() => undefined} onProjectsOpen={() => undefined} - supervisor={
} + chat={
} onManifestChange={(path, next) => { manifestChanges.push({ path, projectId: next.projectId }); // 壳按当前项目身份收敛:只有当前项目的回写才落到工作台状态里。 @@ -294,10 +295,10 @@ function installHostTauri( throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { - core: { invoke: invoke as never }, - event: { listen: (async () => () => undefined) as never }, - }; + installTauriRuntime({ + core: { invoke }, + event: { listen: async () => () => undefined }, + }); return { invoke, batchWrites, diff --git a/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts b/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts index e2099510f..32d4dab3c 100644 --- a/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts @@ -85,7 +85,7 @@ describe('resourceBookOverviewCardLayout', () => { }); describe('selectResourceBookOverviewCards', () => { - const cards = ['a', 'b', 'c', 'd'].map(resource); + const cards = ['a', 'b', 'c', 'd'].map((id) => resource(id)); const positions = new Map([ ['a', { x: 0, y: 0 }], ['b', { x: 1_000, y: 0 }], @@ -147,7 +147,9 @@ describe('groupResourceBookStacks', () => { }); describe('buildResourceBookScenePlan', () => { - const resources = ['art-0', 'art-1', 'art-2', 'art-3'].map(resource); + const resources = ['art-0', 'art-1', 'art-2', 'art-3'].map((id) => + resource(id), + ); const positions = new Map( resources.map((item, index) => [item.id, { x: index * 200, y: 0 }]), ); @@ -156,12 +158,20 @@ describe('buildResourceBookScenePlan', () => { ); const base = { visibleCategoryOrder: ['unclassified'] as const, - resourcesByCategory: new Map([['unclassified', resources]]), - overviewRects: new Map([['unclassified', rect]]), + // 显式标注 key 为 ResourceBookTarget:否则 new Map 会推成 Map, + // 与 buildResourceBookScenePlan 的 ReadonlyMap 不兼容。 + resourcesByCategory: new Map([ + ['unclassified', resources], + ]), + overviewRects: new Map([ + ['unclassified', rect], + ]), positions, cardSizes, visibleResourceIds: new Set(resources.map((item) => item.id)), - categoryViewCenters: new Map([['unclassified', { x: 0, y: 64 }]]), + categoryViewCenters: new Map([ + ['unclassified', { x: 0, y: 64 }], + ]), cardsReady: true, }; @@ -197,11 +207,11 @@ describe('buildResourceBookScenePlan', () => { const plan = buildResourceBookScenePlan({ ...base, visibleCategoryOrder: ['unclassified', 'scene'], - resourcesByCategory: new Map([ + resourcesByCategory: new Map([ ['unclassified', resources], ['scene', [resource('code-0')]], ]), - overviewRects: new Map([ + overviewRects: new Map([ ['unclassified', rect], ['scene', { ...rect, left: 500 }], ]), @@ -335,7 +345,10 @@ describe('buildResourceBookScenePlan', () => { positions: allPositions, cardSizes: allCardSizes, visibleResourceIds: new Set(allResources.map((item) => item.id)), - categoryViewCenters: new Map([ + categoryViewCenters: new Map< + ResourceBookTarget, + { x: number; y: number } + >([ ['unclassified', { x: 0, y: 64 }], ['document', { x: 900, y: 64 }], ]), @@ -998,7 +1011,7 @@ describe('resourceBookAllLayoutKey', () => { categoryOrder: ['document', 'unclassified'] as const, resourcesByCategory: new Map([ ['document', [documentResource(1)]], - ['unclassified', [imageAssetResource(1, { width: 180, height: 180 })]], + ['unclassified', [imageAssetResource(1)]], ]), cardSizes: new Map([ ['agent-result:design:run-1', { width: 180, height: 128 }], diff --git a/apps/ai-game-creator-shell/tests/resourceBookModel.test.ts b/apps/ai-game-creator-shell/tests/resourceBookModel.test.ts index 356c85f08..e65608448 100644 --- a/apps/ai-game-creator-shell/tests/resourceBookModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceBookModel.test.ts @@ -147,7 +147,7 @@ describe('resource book card presentation', () => { expect(resourceBookCategoryCardPresentation(entering, 'scene')).toBe( 'child', ); - expect(resourceBookCategoryCardPresentation(entering, 'code')).toBe( + expect(resourceBookCategoryCardPresentation(entering, 'audio')).toBe( 'overview', ); }); @@ -163,7 +163,7 @@ describe('resource book card presentation', () => { ); expect(resourceBookCategoryCardPresentation(steady, 'scene')).toBe('child'); - expect(resourceBookCategoryCardPresentation(steady, 'code')).toBeNull(); + expect(resourceBookCategoryCardPresentation(steady, 'audio')).toBeNull(); }); it('returns every category to the stack and exposes the exiting window', () => { @@ -179,7 +179,7 @@ describe('resource book card presentation', () => { expect(resourceBookCategoryCardPresentation(returning, 'scene')).toBe( 'overview', ); - expect(resourceBookCategoryCardPresentation(returning, 'code')).toBe( + expect(resourceBookCategoryCardPresentation(returning, 'audio')).toBe( 'overview', ); expect(resourceBookShowsExitingCards(returning)).toBe(true); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts index da51d6641..89b49797d 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test, vi } from 'vitest'; +import type { GameCreationAppAssetKind } from '../../../packages/shared/src/contracts/gameCreationApp'; import { createResourceCanvasAssetGenerationQueue, mergeResourceCanvasAssetGenerationTasksWithRecords, @@ -21,6 +22,15 @@ import { } from '../src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel'; import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel'; +/** + * 未识别 kind 字面量(如 `nope`)不在当前 `GameCreationAppAssetKind` 联合里;本用例依赖 + * 「未识别 kind 不编造文案」的运行时语义,因此保留原始字符串,只在类型层收口。 + */ +function unknownAssetKind(kind: string): GameCreationAppAssetKind { + // 未识别 kind 与当前联合结构上不可兼容,这里显式转换以保留原始串的运行时语义。 + return kind as unknown as GameCreationAppAssetKind; +} + const uiPrototypeAction: ResourceCanvasAssetToolAction = { id: 'generate-ui-prototype', route: 'asset', @@ -205,7 +215,9 @@ describe('生成任务模型', () => { '生成 UI 设计图', ); expect(resourceCanvasAssetGenerationKindLabel('image')).toBe('生成图片'); - expect(resourceCanvasAssetGenerationKindLabel('nope')).toBeNull(); + expect( + resourceCanvasAssetGenerationKindLabel(unknownAssetKind('nope')), + ).toBeNull(); }); test('已耗时文案按分秒呈现', () => { @@ -311,7 +323,7 @@ describe('本地排队驱动器', () => { const harness = createHarness(); let notify: (() => void) | null = null; let releaseCount = 0; - const wait = vi.fn(harness.deps.wait); + const wait = vi.fn(harness.deps.wait!); harness.deps.wait = wait; harness.deps.subscribe = async (_projectPath, _taskId, onChanged) => { notify = onChanged; @@ -328,7 +340,8 @@ describe('本地排队驱动器', () => { expect(wait).not.toHaveBeenCalled(); harness.advance('task-event', 'completed'); - notify?.(); + // 订阅回调在闭包里赋值,控制流分析仍把 notify 收窄为 null,这里显式放宽回声明类型。 + (notify as (() => void) | null)?.(); await settled; expect(wait).not.toHaveBeenCalled(); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationReferences.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationReferences.test.tsx index 22dfdb814..e0960a072 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationReferences.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationReferences.test.tsx @@ -9,7 +9,10 @@ import { import userEvent from '@testing-library/user-event'; import { afterEach, describe, expect, test, vi } from 'vitest'; -import type { GameCreationAppAssetManifestEntry } from '../../../packages/shared/src/contracts/gameCreationApp'; +import type { + GameCreationAppAssetKind, + GameCreationAppAssetManifestEntry, +} from '../../../packages/shared/src/contracts/gameCreationApp'; import { type ChatReference, directCodexContentToPromptText, @@ -89,7 +92,7 @@ function asset( id: string, mediaType: string, localPath: string, - kind = 'image', + kind: GameCreationAppAssetKind = 'image', ): GameCreationAppAssetManifestEntry { return { id, kind, mediaType, localPath, source: { kind: 'canvas' } }; } @@ -253,8 +256,10 @@ describe('参考图模型', () => { describe('生成面板的参考接线', () => { test('图片入口呈现参考计数与引用输入区,提交带回引用', async () => { const user = userEvent.setup(); - const onSubmit = - vi.fn<(input: ResourceCanvasAssetGenerationSubmitInput) => void>(); + const onSubmit = vi.fn< + [input: ResourceCanvasAssetGenerationSubmitInput], + void + >(); const imageAsset = asset('asset-a', 'image/png', 'assets/素材-a.png'); const references = [resourceReferenceFromAsset(imageAsset, 'asset-picker')]; render( @@ -305,8 +310,10 @@ describe('生成面板的参考接线', () => { */ test('图标规范入口呈现参考计数与引用输入区,提交带回引用', async () => { const user = userEvent.setup(); - const onSubmit = - vi.fn<(input: ResourceCanvasAssetGenerationSubmitInput) => void>(); + const onSubmit = vi.fn< + [input: ResourceCanvasAssetGenerationSubmitInput], + void + >(); const imageAsset = asset('asset-a', 'image/png', 'assets/素材-a.png'); const references = [resourceReferenceFromAsset(imageAsset, 'asset-picker')]; render( @@ -422,8 +429,10 @@ describe('生成面板的参考接线', () => { test('图集入口不呈现参考选择,只提交纯提示词', async () => { const user = userEvent.setup(); - const onSubmit = - vi.fn<(input: ResourceCanvasAssetGenerationSubmitInput) => void>(); + const onSubmit = vi.fn< + [input: ResourceCanvasAssetGenerationSubmitInput], + void + >(); render( { test('超限时提交被挡住并给出原因', async () => { const user = userEvent.setup(); - const onSubmit = - vi.fn<(input: ResourceCanvasAssetGenerationSubmitInput) => void>(); + const onSubmit = vi.fn< + [input: ResourceCanvasAssetGenerationSubmitInput], + void + >(); render( cssLengthToPx(term)) .reduce((total, term) => total + term, 0); @@ -270,9 +270,12 @@ describe('「生成任务」侧栏样式', () => { */ const titlebarMinHeight = cssLengthToPx( declaration( - resolveDeclarations(globalWidthRules, [ - '.game-resource-book-scene-titlebar', - ]), + resolveDeclarations( + globalWidthRules, + ['.game-resource-book-scene-titlebar'], + // 标题栏 min-height 不在宽度媒体查询里,取与本套件其余断言一致的桌面宽度。 + 1280, + ), 'min-height', ), ); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx index cb7c5f6f2..04ef0a349 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx @@ -8,6 +8,7 @@ import { } from '@testing-library/react'; import { afterEach, describe, expect, test, vi } from 'vitest'; +import type { GameCreationAppAssetManifestEntry } from '../../../packages/shared/src/contracts/gameCreationApp'; import { EDITOR_IMAGE_DIMENSION_OPTIONS, IMAGE_MODEL_NANOBANANA2, @@ -197,7 +198,10 @@ describe('resourceCanvasBottomToolbarModel', () => { }); test('权威规范图判据与 Rust 同口径:精确落点 + icon-spec + 画布来源', () => { - const canvasIconSpec = { + const canvasIconSpec: Pick< + GameCreationAppAssetManifestEntry, + 'kind' | 'localPath' | 'mediaType' | 'source' + > = { kind: 'icon-spec', localPath: RESOURCE_CANVAS_ICON_SPEC_LOCAL_PATH, mediaType: 'image/png', diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasCardName.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasCardName.test.tsx index dad22664f..06a934d0c 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasCardName.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasCardName.test.tsx @@ -22,8 +22,9 @@ vi.mock('@tauri-apps/api/core', async () => ({ ...(await vi.importActual( '@tauri-apps/api/core', )), + // `core` / `invoke` 在全局声明里都是可选:这里断言它们存在。 invoke: (command: string, args?: Record) => - window.__TAURI__!.core.invoke(command, args), + window.__TAURI__!.core!.invoke!(command, args), })); const PROJECT_PATH = '/tmp/workbench-card-name'; @@ -58,7 +59,9 @@ function createCardNameManifest(): GameCreationAppManifest { }, { id: 'asset-notes', - kind: 'design-document', + // 旧夹具用非法 kind 'design-document' 表达「无法归类」;canonical 词表里 + // 与之等价的是 unknown(同样派生到「待归类」),mediaType 仍让它走文档卡。 + kind: 'unknown', mediaType: 'text/markdown', localPath: 'memory/设计文档.md', source: { kind: 'generated' }, @@ -228,7 +231,7 @@ function renderWorkbench(manifest: GameCreationAppManifest) { attachments: [], recentRunStatus: null, recentRunStopReason: null, - supervisor: React.createElement('div', null, '项目总控'), + chat: React.createElement('div', null, '项目总控'), onHomeOpen: vi.fn(), onProjectsOpen: vi.fn(), }), diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx index 446f721ee..e62f08807 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx @@ -11,16 +11,26 @@ import type { import { type ChatReference, RESOURCE_REFERENCE_INSERT_MANY_EVENT, + type ResourceReference, type ResourceReferenceInsertManyEventDetail, } from '../src/features/project-workspace/resourceReferences'; import ProjectDevelopmentView from '../src/view/project-development'; +import type { ProjectAttachmentResult } from '../src/view/project-development/resourceProjectionModel'; import { + App, createGameCreationAppManifest, + createProjectChatRuntimeHarness, fireEvent, React, render, screen, } from './appSurface/harness'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; + +// App 的 props 类型未导出,React 19 的 createElement 重载推断不出参数类型,这里显式转成组件类型。 +const AppComponent = App as React.ComponentType< + NonNullable> +>; /** * 「拖动素材到对话 = 批量 @ 引用」的行为级验收。 @@ -87,10 +97,21 @@ type LayoutWrite = { positions: ProjectResourceCanvasPosition[]; }; -function installTauri(): { layoutWrites: LayoutWrite[] } { +/** 画布与真实 DirectProject 聊天面共用的项目身份。 */ +const CANVAS_PROJECT_PATH = '/tmp/chat-reference-drop'; + +function installTauri(manifest: GameCreationAppManifest): { + layoutWrites: LayoutWrite[]; +} { const layoutWrites: LayoutWrite[] = []; const persisted = new Map(); const revisions = new Map(); + // 聊天面是**真的** DirectProject:它自己的清单 / 订阅 / 历史 IPC 全部由这份运行时夹具回答, + // 画布命令再叠在它上面。夹具与 `chat-composer.suite` 同一套,不另造聊天替身。 + const chatHarness = createProjectChatRuntimeHarness({ + projectPath: CANVAS_PROJECT_PATH, + initialSessionExists: false, + }); const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'get_local_game_project_revision') { @@ -140,10 +161,33 @@ function installTauri(): { layoutWrites: LayoutWrite[] } { if (command === 'list_local_project_asset_generations') { return []; } - return undefined; + if (command === 'get_local_game_manifest') { + return manifest; + } + if (command === 'inspect_local_project_directory') { + return { + projectPath: CANVAS_PROJECT_PATH, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: manifest.name, + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'get_local_game_preview_status') { + return { status: 'stopped', url: null, port: null, root: null }; + } + if (command === 'get_design_agent_runtime_mode') { + return null; + } + return chatHarness.invoke(command, args); }, ); - window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__; + installTauriRuntime({ + core: { invoke }, + event: { listen: chatHarness.listen }, + }); return { layoutWrites }; } @@ -185,21 +229,34 @@ function collectReferenceInserts() { }; } -async function mountCanvas() { - const tauri = installTauri(); +async function mountCanvas( + options: { + /** 项目附件(无 manifest 登记的素材来源)。 */ + attachments?: ProjectAttachmentResult[]; + /** 打开哪个栏目找卡片;未登记素材统一落在「待归类」。 */ + categoryLabel?: string; + } = {}, +) { + const { attachments = [], categoryLabel = '角色与对象' } = options; const manifest = manifestFor('chat-reference-drop', [ characterAsset('drop-a', 'a.png'), characterAsset('drop-b', 'b.png'), ]); + const tauri = installTauri(manifest); render( React.createElement(ProjectDevelopmentView, { projectName: manifest.name, - projectPath: '/tmp/chat-reference-drop', + projectPath: CANVAS_PROJECT_PATH, manifest, - attachments: [], + attachments, recentRunStatus: null, recentRunStopReason: null, - supervisor: React.createElement('div', null, '项目总控'), + // 对话面是真实链路:`App`(`directProjectMode`)→ `DirectProjectChatView` → + // `DirectProjectComposer`。桩接不住引用事件,链路断点正好在「谁把它落进草稿」, + // 所以这里不能再拿 `
项目总控
` 顶替。 + chat: React.createElement(AppComponent, { + initialProjectPath: CANVAS_PROJECT_PATH, + }), onHomeOpen: vi.fn(), onProjectsOpen: vi.fn(), }), @@ -220,7 +277,7 @@ async function mountCanvas() { toJSON: () => ({}), } as DOMRect); act(() => window.dispatchEvent(new Event('resize'))); - fireEvent.click(screen.getByRole('button', { name: '打开角色与对象' })); + fireEvent.click(screen.getByRole('button', { name: `打开${categoryLabel}` })); await settle(); const chat = document.querySelector('.game-workbench-chat')!; vi.spyOn(chat, 'getBoundingClientRect').mockReturnValue(CHAT_RECT); @@ -234,8 +291,22 @@ function cardIn(manager: HTMLElement, resourceId: string) { )!; } +/** + * 真实 DirectProject 输入盒草稿里的资源引用芯片,按 DOM 顺序读出 `resourceId`。 + * + * 事件派发本身不是交付结果:链路断点一直是「谁把它落进草稿」,所以断言必须读草稿, + * 不能再读 window 上的一次 dispatch。 + */ +function draftReferenceIds() { + return Array.from( + document.querySelectorAll( + 'form.project-chat-composer [data-resource-reference-id]', + ), + ).map((chip) => chip.getAttribute('data-resource-reference-id') ?? ''); +} + afterEach(() => { - delete window.__TAURI__; + resetTauriRuntime(); vi.restoreAllMocks(); }); @@ -279,6 +350,8 @@ describe('拖动素材到对话:批量 @ 引用', () => { kind: 'character', source: 'resource-card', }); + // 端到端:这一批引用真的落进了真实 DirectProject 输入盒的草稿。 + await waitFor(() => expect(draftReferenceIds()).toEqual(['drop-a'])); // 拖到对话不是排版:一条坐标都不写。 expect(tauri.layoutWrites).toHaveLength(writesBefore); expect(screen.queryByText('松手即可 @ 引用 1 项素材')).toBeNull(); @@ -316,8 +389,19 @@ describe('拖动素材到对话:批量 @ 引用', () => { expect(inserts).toHaveLength(1); expect( - new Set(inserts[0]!.map((reference) => reference.resourceId)), + new Set( + inserts[0]! + .filter( + (reference): reference is ResourceReference => + reference.type === 'resource', + ) + .map((reference) => reference.resourceId), + ), ).toEqual(new Set(['drop-a', 'drop-b'])); + // 端到端:整批一次插进同一份草稿,顺序就是拖动集合(画布可见顺序)的顺序。 + await waitFor(() => + expect(draftReferenceIds()).toEqual(['drop-a', 'drop-b']), + ); expect(tauri.layoutWrites).toHaveLength(writesBefore); dispose(); }); @@ -395,4 +479,81 @@ describe('拖动素材到对话:批量 @ 引用', () => { expect(inserts).toHaveLength(0); dispose(); }); + + it('未登记素材:不出现「引用」按钮,拖到对话栏只给原因', async () => { + // 导入的附件没有 manifest 登记(`manifestAssetId: null`),画布把它归到「待归类」。 + const { manager, inserts, dispose } = await mountCanvas({ + categoryLabel: '待归类', + attachments: [ + { + fileName: 'raw-shot.png', + mediaType: 'image/png', + status: 'imported', + localPath: 'assets/raw-shot.png', + }, + ], + }); + const card = cardIn(manager, 'attachment:assets/raw-shot.png'); + expect(card).not.toBeNull(); + + fireEvent.click(card); + // 引用入口与「编辑 / 删除」同一条 `manifestAssetId` 判据:没登记就没有这枚按钮。 + expect(screen.queryByRole('button', { name: /^引用资源/ })).toBeNull(); + + fireEvent.pointerDown(card, { + pointerId: 74, + button: 0, + clientX: 100, + clientY: 100, + }); + fireEvent.pointerMove(card, { + pointerId: 74, + buttons: 1, + clientX: 1000, + clientY: 300, + }); + // 落点提示也说清原因,不承诺一次插不进去的引用。 + expect( + screen.getByText('这些素材还没登记为项目资源,不能 @ 引用'), + ).not.toBeNull(); + + fireEvent.pointerUp(card, { + pointerId: 74, + button: 0, + clientX: 1000, + clientY: 300, + }); + await settle(); + + expect( + screen.getByText('选中的素材都还没登记为项目资源,暂时不能 @ 引用'), + ).not.toBeNull(); + expect(inserts).toHaveLength(0); + expect(draftReferenceIds()).toEqual([]); + dispose(); + }); + + it('空批次引用事件不报「没有可用的聊天输入区」', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { dispose } = await mountCanvas(); + // 派发器自己会挡掉空批次(`dispatchResourceReferenceInsertMany` 对空数组直接 return), + // 这里直接造事件:钉的是 App 侧不把「没有要插的东西」说成「没有输入区」。 + act(() => { + window.dispatchEvent( + new CustomEvent(RESOURCE_REFERENCE_INSERT_MANY_EVENT, { + detail: { references: [] }, + }), + ); + }); + await settle(); + + expect( + warn.mock.calls + .map((call) => String(call[0])) + .filter((message) => message.includes('没有可用的聊天输入区')), + ).toEqual([]); + expect(draftReferenceIds()).toEqual([]); + warn.mockRestore(); + dispose(); + }); }); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasFilterModel.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasFilterModel.test.ts index b8102325e..f6eab39e7 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasFilterModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasFilterModel.test.ts @@ -82,7 +82,7 @@ describe('资源画布筛选模型', () => { 'unclassified', 'version', ]); - expect(RESOURCE_FILTER_REGION_OPTIONS[0].label).toBe('全部区域'); + expect(RESOURCE_FILTER_REGION_OPTIONS[0]!.label).toBe('全部区域'); }); it('区域显示名沿用 @ 面板那份唯一中文口径,并为项目版本单列', () => { @@ -257,23 +257,14 @@ describe('资源画布筛选模型', () => { }); it('筛选生效判据只看关键词与标签:区域只是画布当前栏目,不计入高亮', () => { - expect( - resourceFilterIsActive({ region: 'all', tags: [], keyword: '' }), - ).toBe(false); + expect(resourceFilterIsActive({ tags: [], keyword: '' })).toBe(false); // 纯空白关键词等同没有条件,否则按钮会为一次空输入长期高亮。 - expect( - resourceFilterIsActive({ region: 'all', tags: [], keyword: ' ' }), - ).toBe(false); + expect(resourceFilterIsActive({ tags: [], keyword: ' ' })).toBe(false); // 区域切到别的栏目不等于「有筛选」:用户滚轮换栏时按钮不该常亮。 - expect( - resourceFilterIsActive({ region: 'audio', tags: [], keyword: '' }), - ).toBe(false); - expect( - resourceFilterIsActive({ region: 'all', tags: ['主页'], keyword: '' }), - ).toBe(true); - expect( - resourceFilterIsActive({ region: 'all', tags: [], keyword: '按钮' }), - ).toBe(true); + // 现行签名只接收 tags/keyword,区域不在判据入参内。 + expect(resourceFilterIsActive({ tags: [], keyword: '' })).toBe(false); + expect(resourceFilterIsActive({ tags: ['主页'], keyword: '' })).toBe(true); + expect(resourceFilterIsActive({ tags: [], keyword: '按钮' })).toBe(true); }); it('标签库只统计传入区域内的资源,按使用次数降序、同次数按标签升序', () => { diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx index ce3da42c4..64872ff66 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx @@ -38,7 +38,7 @@ const RESOURCE_FOCUS_SOURCE_LAYER: CanvasLayer = { originalWidth: 1024, originalHeight: 1024, zIndex: 1, - sourceType: 'upload', + sourceType: 'uploaded', }; describe('resourceCanvasFocusModel', () => { diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanelChrome.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanelChrome.test.tsx index e6608e7f6..46d5c29c3 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanelChrome.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanelChrome.test.tsx @@ -7,7 +7,10 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { ResourceCanvasAssetGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView'; import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel'; -import { ResourceCanvasGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasGenerationPanelView'; +import { + ResourceCanvasGenerationPanelView, + type ResourceCanvasGenerationSubmitInput, +} from '../src/features/resource-canvas/ResourceCanvasGenerationPanelView'; import { RESOURCE_CANVAS_GENERATION_PANEL_MAX_WIDTH, RESOURCE_CANVAS_GENERATION_PANEL_VIEWPORT_INSET, @@ -62,7 +65,10 @@ describe('生成浮层的面板外观与高度合同', () => { test('音频入口的浮层同样带 chrome 类,并复用宿主给的提交身份重试', async () => { const user = userEvent.setup(); - const onSubmit = vi.fn(async () => undefined); + // 显式泛型:否则 mock.calls 被推成空元组,下面取提交入参会报越界。 + const onSubmit = vi.fn<[input: ResourceCanvasGenerationSubmitInput], void>( + async () => undefined, + ); render( Supervisor
} + chat={null} onHomeOpen={() => undefined} onProjectsOpen={() => undefined} onManifestChange={(_path, nextManifest) => setManifest(nextManifest)} @@ -357,7 +358,7 @@ function HostWorkbenchWithReferenceRemoval({ attachments={[]} recentRunStatus={null} recentRunStopReason={null} - supervisor={
Supervisor
} + chat={null} onHomeOpen={() => undefined} onProjectsOpen={() => undefined} onManifestChange={(_path, nextManifest) => setManifest(nextManifest)} diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationLanding.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationLanding.test.tsx index 5c5b7bcb8..2ce2aea2a 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationLanding.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationLanding.test.tsx @@ -29,10 +29,11 @@ import { within, } from './appSurface/harness'; import { typeGenerationPrompt } from './resourceGenerationPromptTestUtils'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; afterEach(() => { cleanup(); - delete window.__TAURI__; + resetTauriRuntime(); vi.restoreAllMocks(); }); @@ -230,7 +231,7 @@ function installTauri({ return undefined; }, ); - window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__; + installTauriRuntime({ core: { invoke } }); return { invoke, layoutWrites, generationStarts, deriveInputs, unexpected }; } @@ -281,7 +282,8 @@ function Workbench({ attachments={[]} recentRunStatus={null} recentRunStopReason={null} - supervisor={
Supervisor
} + // ProjectDevelopmentViewProps 现在必填 chat;本用例只验资源落点,不需要对话区。 + chat={null} onHomeOpen={() => undefined} onProjectsOpen={() => undefined} // 收尾时宿主会重读权威清单:把它接进状态,新素材才会进资源投影(落点的前提)。 diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationTasksSidebarDismiss.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationTasksSidebarDismiss.test.tsx index b6c627d0b..c7f2f107e 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationTasksSidebarDismiss.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationTasksSidebarDismiss.test.tsx @@ -15,6 +15,7 @@ import { within, } from './appSurface/harness'; import { repoPath } from './repoPath'; +import { installTauriRuntime } from './tauriRuntimeFake'; /** * 「生成任务」侧栏与右上角入口的三条口径: @@ -61,7 +62,8 @@ function installInvoke() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + // 该 mock 按命令返回固定结构,无法满足 TauriInvoke 的泛型逐命令建模,故断言为 window.__TAURI__。 + installTauriRuntime({ core: { invoke } }); } async function renderWorkbench(projectId: string) { @@ -188,7 +190,7 @@ describe('「生成任务」侧栏的开合与入口位置', () => { attachments: [], recentRunStatus: null, recentRunStopReason: null, - supervisor: React.createElement('div', null, '项目总控'), + chat: React.createElement('div', null, '项目总控'), onHomeOpen: vi.fn(), onProjectsOpen: vi.fn(), }), diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts index e49d9ee46..1310098b7 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts @@ -4,6 +4,7 @@ import { GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE, GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE, isSafeProjectResourceCanvasCoordinate, + type ProjectResourceCanvasPosition, } from '../../../packages/shared/src/contracts/gameCreationApp'; import { createEmptyResourceCanvasLayout, diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx index d9edbb541..d90f37c1b 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx @@ -4,6 +4,7 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { + GameCreationAppAssetKind, GameCreationAppAssetManifestEntry, GameCreationAppManifest, ProjectResourceCanvasCategory, @@ -25,6 +26,7 @@ import { screen, within, } from './appSurface/harness'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; /** * 「AGC 资源画布:新素材不自动重排 + 生成后自动聚焦 + 显式整理画布」的行为级验收。 @@ -41,6 +43,15 @@ const NEW_RESOURCE_ID = `asset:${NEW_ASSET_ID}`; type AssetFixture = GameCreationAppAssetManifestEntry; +/** + * 历史夹具沿用旧 kind `game-rules`,它已不在当前 `GameCreationAppAssetKind` 联合里; + * 用例依赖「未识别 kind」的运行时语义,因此保留原字符串,只在类型层收口。 + */ +function legacyAssetKind(kind: string): GameCreationAppAssetKind { + // 旧 kind 字面量与当前联合结构上不可兼容,这里显式转换以保留原始串的运行时语义。 + return kind as unknown as GameCreationAppAssetKind; +} + function pngAsset(id: string, fileName: string): AssetFixture { return { id, @@ -55,7 +66,7 @@ function pngAsset(id: string, fileName: string): AssetFixture { function markdownAsset(id: string, fileName: string): AssetFixture { return { id, - kind: 'game-rules', + kind: legacyAssetKind('game-rules'), mediaType: 'text/markdown', localPath: `docs/${fileName}`, source: { kind: 'generated', resourceId: `${id}-resource` }, @@ -217,7 +228,7 @@ function installLayoutTauri( }, ); - window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__; + installTauriRuntime({ core: { invoke } }); return { invoke, layoutWrites, layoutReads, unexpectedCommands }; } @@ -336,7 +347,7 @@ function LayoutWorkbench({ } afterEach(() => { - delete window.__TAURI__; + resetTauriRuntime(); vi.restoreAllMocks(); }); @@ -719,9 +730,9 @@ describe('资源画布手动重排口径', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { + installTauriRuntime({ core: { invoke }, - } as unknown as typeof window.__TAURI__; + }); const resources: ResourceCanvasItem[] = ['resource-a', 'resource-b'].map( (id) => ({ @@ -1148,7 +1159,9 @@ describe('资源画布手动重排口径', () => { // 位置:整理画布仍在排序组左侧,不会被读成排列方式的一部分。 const rowButtons = Array.from(actionsRow!.querySelectorAll('button')); expect(rowButtons.at(-1)).not.toBe(rederiveButton); - const rederiveIndex = rowButtons.indexOf(rederiveButton); + const rederiveIndex = rowButtons.findIndex( + (button) => button === rederiveButton, + ); const sortGroupIndex = rowButtons.findIndex((button) => sortGroup.contains(button), ); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditDraft.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditDraft.test.tsx index c98db582b..bc1605d71 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditDraft.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditDraft.test.tsx @@ -22,7 +22,7 @@ vi.mock('@tauri-apps/api/core', async () => ({ '@tauri-apps/api/core', )), invoke: (command: string, args?: Record) => - window.__TAURI__!.core.invoke(command, args), + window.__TAURI__!.core!.invoke!(command, args), })); const PROJECT_PATH = '/tmp/quick-edit-draft'; @@ -43,21 +43,21 @@ function createDraftManifest(): GameCreationAppManifest { manifest.assets = [ { id: 'source-art', - kind: 'art-image', + kind: 'image', mediaType: 'image/png', localPath: 'assets/source-art.png', source: { kind: 'generated', resourceId: 'art-resource' }, }, { id: 'source-art-2', - kind: 'art-image', + kind: 'image', mediaType: 'image/png', localPath: 'assets/source-art-2.png', source: { kind: 'generated', resourceId: 'art-resource-2' }, }, { id: 'source-rules', - kind: 'game-rules', + kind: 'document', mediaType: 'text/markdown', localPath: 'docs/rules.md', source: { kind: 'generated', resourceId: 'rules-resource' }, @@ -81,7 +81,7 @@ function createCanvasTaskArtManifest(): GameCreationAppManifest { manifest.assets = [ { id: 'source-rules', - kind: 'game-rules', + kind: 'document', mediaType: 'text/markdown', localPath: 'docs/rules.md', source: { kind: 'generated', resourceId: 'rules-resource' }, @@ -214,7 +214,7 @@ function installInvoke( normalizeCalls.push(input); const asset = { id: 'normalized-task-art', - kind: 'art-image', + kind: 'image' as const, mediaType: 'image/png', localPath: CANVAS_TASK_ART_PATH, source: { @@ -246,7 +246,7 @@ function installInvoke( const assetId = `edit-${operationId}`; const asset = { id: assetId, - kind: 'art-image', + kind: 'image' as const, mediaType: 'image/png', localPath: `assets/edits/${operationId}-art.png`, source: { @@ -290,7 +290,7 @@ function DraftWorkbench({ * 触发的正规化无关——这正是「别的流程换了卡片 id」的那条旁路。 */ externalAssetPath?: string | null; -} = {}) { +}) { const [manifest, setManifest] = React.useState(createManifest); return React.createElement( React.Fragment, @@ -307,7 +307,7 @@ function DraftWorkbench({ ...current.assets, { id: 'external-art', - kind: 'art-image', + kind: 'image', mediaType: 'image/png', localPath: externalAssetPath, source: { @@ -328,7 +328,7 @@ function DraftWorkbench({ attachments: [], recentRunStatus: null, recentRunStopReason: null, - supervisor: React.createElement('div', null, '项目总控'), + chat: React.createElement('div', null, '项目总控'), onHomeOpen: vi.fn(), onProjectsOpen: vi.fn(), onManifestChange: ( diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditModel.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditModel.test.ts index 4f9c6caf8..41f088ce0 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditModel.test.ts @@ -26,13 +26,15 @@ const SOURCE_LAYER: CanvasLayer = { originalWidth: 1024, originalHeight: 1024, zIndex: 1, - sourceType: 'upload', + // 现行 CanvasSourceType 只认 'uploaded' / 'generated' / 'mock_generated'。 + sourceType: 'uploaded', }; const RULES_REFERENCE: ChatReference = { type: 'resource', resourceId: 'rules-resource', - kind: 'game-rules', + // 旧的 `game-rules` kind 已不在当前联合里,夹具按真实类型收敛到最接近的 document。 + kind: 'document', mediaType: 'text/markdown', label: 'rules', category: 'document', diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasToolbarModel.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasToolbarModel.test.ts index 14d349f43..532e238d9 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasToolbarModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasToolbarModel.test.ts @@ -198,7 +198,7 @@ describe('resource canvas toolbar actions', () => { completedManifest, createResource({ manifestAssetId: null, - producerTaskId: completedManifest.tasks[0].id, + producerTaskId: completedManifest.tasks[0]!.id, }), ), ).toEqual(['quick-edit', 'download']); @@ -212,7 +212,7 @@ describe('resource canvas toolbar actions', () => { runningManifest, createResource({ manifestAssetId: null, - producerTaskId: runningManifest.tasks[0].id, + producerTaskId: runningManifest.tasks[0]!.id, }), ), ).toEqual(['download']); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasVersionBindingModel.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasVersionBindingModel.test.ts index eff1f4445..8468fe223 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasVersionBindingModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasVersionBindingModel.test.ts @@ -86,10 +86,10 @@ describe('运行模块版本绑定判定', () => { }); test('labels a version with its Chinese created reason and creation time', () => { - expect(formatIterationVersionLabel(versions[0])).toBe( + expect(formatIterationVersionLabel(versions[0]!)).toBe( `初始版本 · ${new Date(1_788_075_047 * 1000).toLocaleString('zh-CN')}`, ); - expect(formatIterationVersionLabel(versions[1])).toBe( + expect(formatIterationVersionLabel(versions[1]!)).toBe( `智能体修订 · ${new Date(1_788_075_104 * 1000).toLocaleString('zh-CN')}`, ); }); @@ -100,7 +100,7 @@ describe('运行模块版本绑定判定', () => { const createdAtLocal = new Date(1_788_075_047 * 1000).toLocaleString( 'zh-CN', ); - const label = formatIterationVersionLabel(versions[0]); + const label = formatIterationVersionLabel(versions[0]!); expect(label).toBe(`初始版本 · ${createdAtLocal}`); // 年 / 月 / 日必须来自真实时刻,而不是 1970。这里用显式数值选项断言, diff --git a/apps/ai-game-creator-shell/tests/resourceCardPreviewAlphaBackground.test.tsx b/apps/ai-game-creator-shell/tests/resourceCardPreviewAlphaBackground.test.tsx index f1a0ccc52..4a7ef6eb6 100644 --- a/apps/ai-game-creator-shell/tests/resourceCardPreviewAlphaBackground.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCardPreviewAlphaBackground.test.tsx @@ -42,6 +42,7 @@ import { parseStyleSheet, resolveDeclarations, } from './styleCascade'; +import { installTauriRuntime } from './tauriRuntimeFake'; const PROJECT_PATH = '/tmp/workbench-preview-alpha'; const PROJECT_ID = 'workbench-preview-alpha'; @@ -153,7 +154,7 @@ function createFixtureManifest(): GameCreationAppManifest { mediaType: string, ) => ({ id, - kind: 'character', + kind: 'character' as const, category: 'character' as const, mediaType, localPath, @@ -248,7 +249,7 @@ function installPreviewTauri(manifest: GameCreationAppManifest) { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke: invoke as never } }; + installTauriRuntime({ core: { invoke } }); return invoke; } diff --git a/apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts b/apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts index abf5030af..c7285b08d 100644 --- a/apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts @@ -11,6 +11,7 @@ import { describe, expect, test } from 'vitest'; import { gameCreationAppAssetCategory, + type GameCreationAppAssetKind, type GameCreationAppAssetManifestEntry, } from '../../../packages/shared/src/contracts/gameCreationApp'; import { projectResourceCardPreviewKind } from '../src/view/project-development/resourceCardPreviewModel'; @@ -74,6 +75,9 @@ const REAL_ASSET_SHAPES: readonly Shape[] = [ }, ]; +/** 真机磁盘上遗留的 raw kind:不在 canonical 词表内,故意保留原始串。 */ +const REAL_FUTURE_UI_KIND: string = 'future-ui'; + function buildRealAssets(): GameCreationAppAssetManifestEntry[] { const assets: GameCreationAppAssetManifestEntry[] = []; let index = 0; @@ -82,7 +86,9 @@ function buildRealAssets(): GameCreationAppAssetManifestEntry[] { index += 1; assets.push({ id: `asset-${index}`, - kind: shape.kind, + // 夹具要复现真机磁盘上的非法 kind 串(解析后收口成 unknown), + // 故这里从 string 收窄到 canonical 词表类型,只走类型层。 + kind: shape.kind as GameCreationAppAssetKind, mediaType: shape.mediaType, localPath: `assets/probe-${index}.${shape.ext}`, source: { kind: 'generated' }, @@ -120,7 +126,9 @@ describe('真机 manifest 取证:占位卡片计数与栏目分布', () => { test('存量 57 条无法解析的 kind 的落盘 unclassified 停在「待归类」', () => { const assets = realManifest().assets; - const uiAssets = assets.filter((asset) => asset.kind === 'future-ui'); + const uiAssets = assets.filter( + (asset) => asset.kind === REAL_FUTURE_UI_KIND, + ); // 前置事实:这 57 条落盘值确实是 unclassified(历史误判现场)。 expect(uiAssets).toHaveLength(57); @@ -156,9 +164,14 @@ describe('真机 manifest 取证:占位卡片计数与栏目分布', () => { }), ).toBe('ui-interaction'); // 无法解析的输入统一收口为 unknown → unclassified。 - for (const raw of ['UI', 'future-kind', 'future-ui']) { + // raw 显式声明为 string:夹具要喂 canonical 词表之外的串,从 string 收窄只走类型层。 + const rawKinds: string[] = ['UI', 'future-kind', 'future-ui']; + for (const raw of rawKinds) { expect( - gameCreationAppAssetCategory({ kind: raw, category: 'unclassified' }), + gameCreationAppAssetCategory({ + kind: raw as GameCreationAppAssetKind, + category: 'unclassified', + }), ).toBe('unclassified'); } }); @@ -166,7 +179,12 @@ describe('真机 manifest 取证:占位卡片计数与栏目分布', () => { test('落盘 unclassified 且 kind 派生也是 unclassified 时不受影响', () => { // image / video / code 的 canonical 分类本来就是 unclassified, // 这类资产必须继续信任落盘值,不被重派生规则误伤。 - for (const kind of ['image', 'video', 'code', 'publication-material']) { + for (const kind of [ + 'image', + 'video', + 'code', + 'publication-material', + ] as const) { expect( gameCreationAppAssetCategory({ kind, category: 'unclassified' }), ).toBe('unclassified'); diff --git a/apps/ai-game-creator-shell/tests/resourceCocosPreviewContract.test.tsx b/apps/ai-game-creator-shell/tests/resourceCocosPreviewContract.test.tsx index 81f031887..8e2596d03 100644 --- a/apps/ai-game-creator-shell/tests/resourceCocosPreviewContract.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCocosPreviewContract.test.tsx @@ -3,6 +3,8 @@ import { render, screen, waitFor } from '@testing-library/react'; import type { ReactNode } from 'react'; import { describe, expect, it, vi } from 'vitest'; +import type { GameCreationAppAssetKind } from '../../../packages/shared/src/contracts/gameCreationApp'; + // 浮层外壳(portal + 焦点陷阱)不在本用例关注面内:与文档预览浮层的用例同口径只渲染子节点。 vi.mock('../src/components/modal/ThemedModal', () => ({ ThemedModal: ({ @@ -40,7 +42,7 @@ import { type EngineCase = { path: string; mediaType: string; - kind: string; + kind: GameCreationAppAssetKind; projectedKind: 'art' | 'audio' | 'code' | 'document'; previewKind: | 'model' @@ -234,15 +236,43 @@ const ENGINE_CASES: EngineCase[] = [ }, ]; +/** + * 预览分支只读 `path` / `mediaType` / `subtype`,但预览模型的入参是完整的 + * `ProjectResource`;这里补齐投影产物的其余必填字段,构造与被测链路同形状的对象。 + */ +function resourceFixture(input: { + id: string; + path: string; + mediaType: string; + subtype: string; +}): ProjectResource { + return { + id: input.id, + category: 'unclassified', + subtype: input.subtype, + label: input.path.split('/').pop() ?? input.path, + path: input.path, + mediaType: input.mediaType, + sourceLabel: '', + taskTitle: null, + manifestAssetId: null, + producerTaskId: null, + externalResourceId: null, + referenceResourceIds: [], + dependencies: [], + dependencyDepth: 0, + }; +} + describe('Cocos 资源在资源画布上的准入与预览契约', () => { it('每个引擎资源都能进画布、落到预期卡面分支与类型角标', () => { for (const entry of ENGINE_CASES) { - const resource = { + const resource = resourceFixture({ id: entry.path, path: entry.path, mediaType: entry.mediaType, subtype: entry.kind, - }; + }); expect( projectedResourceKind({ path: entry.path, @@ -266,20 +296,24 @@ describe('Cocos 资源在资源画布上的准入与预览契约', () => { it('模型走 model 读取分支、图片容器走 art,二进制容器不读字节', () => { expect( - projectResourceMediaPreviewCategory({ - id: 'glb', - path: 'assets/model/hero.glb', - mediaType: 'model/gltf-binary', - subtype: 'scene', - }), + projectResourceMediaPreviewCategory( + resourceFixture({ + id: 'glb', + path: 'assets/model/hero.glb', + mediaType: 'model/gltf-binary', + subtype: 'scene', + }), + ), ).toBe('model'); expect( - projectResourceMediaPreviewCategory({ - id: 'tga', - path: 'assets/tex/grass.tga', - mediaType: 'image/x-tga', - subtype: 'image', - }), + projectResourceMediaPreviewCategory( + resourceFixture({ + id: 'tga', + path: 'assets/tex/grass.tga', + mediaType: 'image/x-tga', + subtype: 'image', + }), + ), ).toBe('art'); }); }); diff --git a/apps/ai-game-creator-shell/tests/resourceFilterPanel.test.tsx b/apps/ai-game-creator-shell/tests/resourceFilterPanel.test.tsx index 66107fa25..a8a214249 100644 --- a/apps/ai-game-creator-shell/tests/resourceFilterPanel.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceFilterPanel.test.tsx @@ -4,16 +4,17 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { createRef } from 'react'; import { describe, expect, it, vi } from 'vitest'; +import type { ResourceFilterRegion } from '../src/view/project-development/resourceCanvasFilterModel'; import { ResourceFilterPanel } from '../src/view/project-development/ResourceFilterPanel'; function renderPanel( overrides: { keyword?: string; - region?: 'all' | 'ui-interaction' | 'character'; + region?: ResourceFilterRegion; activeTags?: string[]; tagItems?: Array<{ tag: string; assetCount: number }>; onKeywordChange?: (value: string) => void; - onRegionChange?: (region: string) => void; + onRegionChange?: (region: ResourceFilterRegion) => void; onToggleTag?: (tag: string) => void; onClose?: () => void; } = {}, @@ -35,7 +36,7 @@ function renderPanel( keyword={overrides.keyword ?? ''} onKeywordChange={handlers.onKeywordChange} region={overrides.region ?? 'all'} - onRegionChange={handlers.onRegionChange as (region: 'all') => void} + onRegionChange={handlers.onRegionChange} activeTags={overrides.activeTags ?? []} onToggleTag={handlers.onToggleTag} tagItems={ diff --git a/apps/ai-game-creator-shell/tests/resourceJsonCanvas.test.tsx b/apps/ai-game-creator-shell/tests/resourceJsonCanvas.test.tsx index 36230170a..fc6f70325 100644 --- a/apps/ai-game-creator-shell/tests/resourceJsonCanvas.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceJsonCanvas.test.tsx @@ -1,7 +1,10 @@ /** @vitest-environment jsdom */ import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { GameCreationAppAssetKind } from '../../../packages/shared/src/contracts/gameCreationApp'; +import type { + GameCreationAppAssetKind, + GameCreationAppAssetManifestEntry, +} from '../../../packages/shared/src/contracts/gameCreationApp'; import { createGameCreationAppManifest, fireEvent, @@ -13,13 +16,14 @@ import { waitFor, within, } from './appSurface/harness'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; vi.mock('@tauri-apps/api/core', async () => ({ ...(await vi.importActual( '@tauri-apps/api/core', )), invoke: (command: string, args?: Record) => - window.__TAURI__!.core.invoke(command, args), + window.__TAURI__!.core!.invoke!(command, args), })); const projectId = 'json-canvas'; @@ -37,11 +41,14 @@ async function mountJsonCanvas( ) { installResizeObserverStub(); const manifest = createGameCreationAppManifest(projectId, 'JSON 资源测试'); - manifest.assets = [ + const baseAssets: Array< + Pick + > = [ { id: 'design', kind, localPath: 'ui/design.json' }, { id: 'ordinary', kind: 'unknown', localPath: 'assets/ordinary.json' }, { id: 'spoof', kind: 'unknown', localPath: 'ui/spoof.json' }, - ].map((asset) => ({ + ]; + manifest.assets = baseAssets.map((asset) => ({ ...asset, mediaType: 'application/json', category: 'document' as const, @@ -118,7 +125,7 @@ async function mountJsonCanvas( throw new Error(`unexpected command ${command}`); }, ); - window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__; + installTauriRuntime({ core: { invoke } }); const onManifestChange = vi.fn(); render( 对话
} + onHomeOpen={() => {}} + onProjectsOpen={() => {}} onManifestChange={onManifestChange} />, ); @@ -146,7 +155,7 @@ async function mountJsonCanvas( } afterEach(() => { - delete window.__TAURI__; + resetTauriRuntime(); }); describe('JSON 画布卡片与入口', () => { diff --git a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx index d07631abb..96115110d 100644 --- a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx @@ -22,6 +22,7 @@ import { import { afterEach, describe, expect, test, vi } from 'vitest'; import type { + GameCreationAppAssetKind, GameCreationAppAssetManifestEntry, GameIterationVersion, } from '../../../packages/shared/src/contracts/gameCreationApp'; @@ -67,6 +68,7 @@ import { resourceReferenceTagLibrary, type RuntimeRegionReference, } from '../src/features/project-workspace/resourceReferences'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; /** * jsdom 没有 `DragEvent` / `ClipboardEvent` 构造器,而 Lexical 的默认粘贴路径按构造器名字判断 @@ -129,7 +131,7 @@ defineResizeObserverShim(); function asset( id: string, - kind: string, + kind: GameCreationAppAssetKind, mediaType: string, localPath: string, ): GameCreationAppAssetManifestEntry { @@ -415,7 +417,7 @@ describe('ResourceReferenceInput', () => { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke: invoke as never } }; + installTauriRuntime({ core: { invoke } }); try { render( @@ -438,7 +440,7 @@ describe('ResourceReferenceInput', () => { expect(invoke).toHaveBeenCalledWith('list_agc_skill_catalog'); }); } finally { - delete window.__TAURI__; + resetTauriRuntime(); } }); @@ -606,7 +608,7 @@ describe('ResourceReferenceInput', () => { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke: invoke as never } }; + installTauriRuntime({ core: { invoke } }); try { const ref = createRef(); @@ -652,7 +654,7 @@ describe('ResourceReferenceInput', () => { { type: 'input_text', text: ' 出图' }, ]); } finally { - delete window.__TAURI__; + resetTauriRuntime(); } }); @@ -694,7 +696,7 @@ describe('ResourceReferenceInput', () => { }); test('AI 润色只换文本时不会把引用一轮轮追加成重复 @显示名', async () => { - const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const onChange = vi.fn<[draft: ChatComposerDraft], void>(); const reference = resourceReferenceFromAsset(assets[0]!, 'asset-picker'); function Controlled() { const composerRef = createRef(); @@ -739,7 +741,7 @@ describe('ResourceReferenceInput', () => { }); test('引用后面的段落分隔原样落进 content:读回的 prompt 与编辑器分段逐字一致', async () => { - const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const onChange = vi.fn<[draft: ChatComposerDraft], void>(); const reference = resourceReferenceFromAsset(assets[0]!, 'asset-picker'); function Controlled() { const composerRef = createRef(); @@ -785,7 +787,7 @@ describe('ResourceReferenceInput', () => { }); test('润色改写掉 `@附件名` token 时,附件 part 补在末尾而不是丢掉', async () => { - const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const onChange = vi.fn<[draft: ChatComposerDraft], void>(); const reference = resourceReferenceFromAsset(assets[0]!, 'asset-picker'); const attachment = { type: 'agc_attachment_reference' as const, @@ -828,7 +830,7 @@ describe('ResourceReferenceInput', () => { }); test('润色回包还留着 `@名称` token 时,引用与附件都在原位换回真 part', async () => { - const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const onChange = vi.fn<[draft: ChatComposerDraft], void>(); const reference = resourceReferenceFromAsset(assets[0]!, 'asset-picker'); const attachment = { type: 'agc_attachment_reference' as const, @@ -878,7 +880,7 @@ describe('ResourceReferenceInput', () => { }); test('润色回写时 provider 答不出 token 的 part 仍留在草稿里,不在这门翻译里消失', async () => { - const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const onChange = vi.fn<[draft: ChatComposerDraft], void>(); const reference = resourceReferenceFromAsset(assets[0]!, 'asset-picker'); // provider 契约被破坏:解析得出引用,但答不出 token。它仍然必须活过这一轮润色回写。 const tokenlessProvider: ReferenceProvider = { @@ -982,7 +984,7 @@ describe('ResourceReferenceInput', () => { test('opens the asset picker, supports multi-select, and inserts stable references', async () => { const user = userEvent.setup(); - const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const onChange = vi.fn<[draft: ChatComposerDraft], void>(); render( { }); test('一次 insertReferences 按给定顺序插入整批 chip', async () => { - const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const onChange = vi.fn<[draft: ChatComposerDraft], void>(); const composerRef = createRef(); render( { test('removing a chip keeps the remaining text editable', async () => { const user = userEvent.setup(); - const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const onChange = vi.fn<[draft: ChatComposerDraft], void>(); render( { test('keeps the whole reference after one delete and drops its data in one step', async () => { const user = userEvent.setup(); - const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const onChange = vi.fn<[draft: ChatComposerDraft], void>(); render( { test('refreshes chip and candidate display names after a resource rename', async () => { const user = userEvent.setup(); - const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const onChange = vi.fn<[draft: ChatComposerDraft], void>(); const composerRef = createRef(); const renamedAssets = [ asset('hero', 'character', 'image/png', 'assets/hero-final.png'), @@ -1513,7 +1515,7 @@ describe('ResourceReferenceInput', () => { test('restores a cross-session draft with the caret at the end of the text', async () => { const user = userEvent.setup(); - const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const onChange = vi.fn<[draft: ChatComposerDraft], void>(); const composerRef = createRef(); render( { }); test('聊天与快速编辑提示词插入同一素材:草稿文本与引用完全一致', async () => { - const chatOnChange = vi.fn<(draft: ChatComposerDraft) => void>(); - const quickEditOnChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const chatOnChange = vi.fn<[draft: ChatComposerDraft], void>(); + const quickEditOnChange = vi.fn<[draft: ChatComposerDraft], void>(); render( <> ({ + versions: manifest.versions!.map((version) => ({ ...version, resourceBindings: [ { slotId: 'asset:asset-final', resourceId: 'asset-final' }, @@ -356,7 +357,7 @@ function renderReplacementWorkbench(options: RenderOptions = {}) { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const onActiveVersionChange = vi.fn(); const onPlay = vi.fn(); diff --git a/apps/ai-game-creator-shell/tests/resourceVersionReplacementModel.test.ts b/apps/ai-game-creator-shell/tests/resourceVersionReplacementModel.test.ts index 2352a8a09..fca11932e 100644 --- a/apps/ai-game-creator-shell/tests/resourceVersionReplacementModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceVersionReplacementModel.test.ts @@ -1,7 +1,10 @@ /** @vitest-environment jsdom */ import { describe, expect, it, vi } from 'vitest'; -import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; +import type { + GameCreationAppAssetKind, + GameCreationAppManifest, +} from '../../../packages/shared/src/contracts/gameCreationApp'; import type { LocalProjectVersionReplacementCandidate, ProjectVersionResourceCompatibility, @@ -21,6 +24,7 @@ import { replaceVersionResource, } from '../src/features/resource-canvas/resourceVersionReplacementTransport'; import { projectResourcesFromReadModels } from '../src/view/project-development/resourceProjectionModel'; +import { installTauriRuntime } from './tauriRuntimeFake'; function candidate( resourceId: string, @@ -47,7 +51,7 @@ function candidate( function manifestWithAssets( assets: Array<{ id: string; - kind: string; + kind: GameCreationAppAssetKind; mediaType: string; localPath: string; }>, @@ -357,7 +361,7 @@ describe('候选择序与资源映射', () => { describe('资源替换的 IPC 边界', () => { it('两个命令都只传一个 input 对象,字段名与 Rust 结构体逐字一致', async () => { const invoke = vi.fn(async () => ({ ok: true })); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); await readVersionResourceReplacementCandidates({ projectPath: '/tmp/project', diff --git a/apps/ai-game-creator-shell/tests/resourceVersionSwitch.test.tsx b/apps/ai-game-creator-shell/tests/resourceVersionSwitch.test.tsx index 758668eaa..ba5b4bf63 100644 --- a/apps/ai-game-creator-shell/tests/resourceVersionSwitch.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceVersionSwitch.test.tsx @@ -109,6 +109,7 @@ function renderWorkbench( props: { activeVersionId?: string | null; onActiveVersionChange?: (versionId: string) => void; + onPlay?: () => void; } = {}, ) { const { rerender } = render( @@ -321,7 +322,7 @@ describe('C7 运行模块版本切换', () => { try { render( React.createElement(GameRunVersionPicker, { - versions: createVersionedManifest().versions, + versions: createVersionedManifest().versions ?? [], activeVersionId: null, onSelectVersion: vi.fn(), }), @@ -358,7 +359,7 @@ describe('C7 运行模块版本切换', () => { const onSelectVersion = vi.fn(); render( React.createElement(GameRunVersionPicker, { - versions: createVersionedManifest().versions, + versions: createVersionedManifest().versions ?? [], activeVersionId: null, onSelectVersion, }), @@ -389,7 +390,7 @@ describe('C7 运行模块版本切换', () => { test('keeps the portal menu mounted when the click lands on the menu itself', async () => { render( React.createElement(GameRunVersionPicker, { - versions: createVersionedManifest().versions, + versions: createVersionedManifest().versions ?? [], activeVersionId: null, onSelectVersion: vi.fn(), }), diff --git a/apps/ai-game-creator-shell/tests/runPreviewBrowserOpen.test.tsx b/apps/ai-game-creator-shell/tests/runPreviewBrowserOpen.test.tsx index 029445850..15d53e54e 100644 --- a/apps/ai-game-creator-shell/tests/runPreviewBrowserOpen.test.tsx +++ b/apps/ai-game-creator-shell/tests/runPreviewBrowserOpen.test.tsx @@ -4,8 +4,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; import ProjectDevelopmentView from '../src/view/project-development'; +import { installTauriRuntime } from './tauriRuntimeFake'; -const openUrl = vi.fn(async () => undefined); +const openUrl = vi.fn(async (_url: string) => undefined); vi.mock('@tauri-apps/plugin-opener', () => ({ openUrl: (url: string) => openUrl(url), })); @@ -13,7 +14,7 @@ vi.mock('@tauri-apps/plugin-opener', () => ({ const PREVIEW_URL = 'http://127.0.0.1:4173/'; function installInvoke() { - window.__TAURI__ = { + installTauriRuntime({ core: { invoke: vi.fn(async (command: string, args?: Record) => { if (command === 'read_local_project_resource_graph') { @@ -38,7 +39,7 @@ function installInvoke() { throw new Error(`unexpected invoke ${command}`); }), }, - } as unknown as typeof window.__TAURI__; + }); } function renderRunView(options: { withPreview?: boolean } = {}) { @@ -61,7 +62,7 @@ function renderRunView(options: { withPreview?: boolean } = {}) { ? null : { status: 'running', url: PREVIEW_URL, port: 4173 } } - supervisor={
项目总控
} + chat={
项目总控
} onHomeOpen={vi.fn()} onProjectsOpen={vi.fn()} onNotice={onNotice} diff --git a/apps/ai-game-creator-shell/tests/runPreviewFullscreen.test.tsx b/apps/ai-game-creator-shell/tests/runPreviewFullscreen.test.tsx index 2b289d564..199efc3e6 100644 --- a/apps/ai-game-creator-shell/tests/runPreviewFullscreen.test.tsx +++ b/apps/ai-game-creator-shell/tests/runPreviewFullscreen.test.tsx @@ -4,11 +4,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; import ProjectDevelopmentView from '../src/view/project-development'; +import { installTauriRuntime } from './tauriRuntimeFake'; const PREVIEW_URL = 'http://127.0.0.1:4173/'; function installInvoke() { - window.__TAURI__ = { + installTauriRuntime({ core: { invoke: vi.fn(async (command: string, args?: Record) => { if (command === 'read_local_project_resource_graph') { @@ -33,7 +34,7 @@ function installInvoke() { throw new Error(`unexpected invoke ${command}`); }), }, - } as unknown as typeof window.__TAURI__; + }); } function renderRunView() { @@ -51,7 +52,8 @@ function renderRunView() { recentRunStatus={null} recentRunStopReason={null} preview={{ status: 'running', url: PREVIEW_URL, port: 4173 }} - supervisor={
项目总控
} + // ProjectDevelopmentViewProps 现在必填 chat;旧的 supervisor 入参已不存在。 + chat={null} onHomeOpen={vi.fn()} onProjectsOpen={vi.fn()} onNotice={vi.fn()} diff --git a/apps/ai-game-creator-shell/tests/separationOverview.test.ts b/apps/ai-game-creator-shell/tests/separationOverview.test.ts index 4761832ad..ead2bf2cc 100644 --- a/apps/ai-game-creator-shell/tests/separationOverview.test.ts +++ b/apps/ai-game-creator-shell/tests/separationOverview.test.ts @@ -72,7 +72,9 @@ function node( source: 'System', }, component, + children_display_mode: 'Stack', children, + offset: { min: [0, 0], max: [0, 0] }, }; } @@ -157,7 +159,7 @@ describe('separation status writeback', () => { ]), ).toEqual([]); expect( - problematicTree[0].root.children[0]?.metadata.component_status, + problematicTree[0]?.root.children[0]?.metadata.component_status, ).toEqual({ NeedReview: '自动切分重试已达上限(3 次)\n第一次建议\n最后建议', }); diff --git a/apps/ai-game-creator-shell/tests/sessionPreview.test.ts b/apps/ai-game-creator-shell/tests/sessionPreview.test.ts index 558a8a6a4..2443b834c 100644 --- a/apps/ai-game-creator-shell/tests/sessionPreview.test.ts +++ b/apps/ai-game-creator-shell/tests/sessionPreview.test.ts @@ -3,22 +3,41 @@ import { readFileSync } from 'node:fs'; import { describe, expect, it, vi } from 'vitest'; import type { GameCreationAppPreviewState } from '../../../packages/shared/src/contracts/gameCreationApp'; -import { resolveSessionPreviewOnProjectOpen } from '../src/features/app-shell/sessionPreview'; +import { + type PreviewCommandInvoke, + resolveSessionPreviewOnProjectOpen, +} from '../src/features/app-shell/sessionPreview'; import { repoPath } from './repoPath'; const projectPath = '/tmp/session-preview-project'; +type RawPreviewInvoke = ( + command: string, + args?: Record, +) => Promise; + +// 泛型 invoke 与具体的非泛型 mock 结构上不可兼容,只能用 as unknown as 收敛; +// 运行时原样返回同一个 mock,调用方仍持有原引用。 +function asPreviewCommandInvoke( + invoke: RawPreviewInvoke, +): PreviewCommandInvoke { + return invoke as unknown as PreviewCommandInvoke; +} + function createInvoke( responses: Record, calls: Array<[string, Record | undefined]> = [], ) { - return vi.fn(async (command: string, args?: Record) => { - calls.push([command, args]); - if (!(command in responses)) { - throw new Error(`unexpected invoke ${command}`); - } - return responses[command]; - }); + const invoke = vi.fn( + async (command: string, args?: Record) => { + calls.push([command, args]); + if (!(command in responses)) { + throw new Error(`unexpected invoke ${command}`); + } + return responses[command]; + }, + ); + return asPreviewCommandInvoke(invoke); } describe('进入项目时的预览活体核验', () => { @@ -94,14 +113,14 @@ describe('进入项目时的预览活体核验', () => { it('registry 不可读时不顺手停预览:读失败不等于确认没有在跑', async () => { const calls: Array<[string, Record | undefined]> = []; - const invoke = vi.fn( - async (command: string, args?: Record) => { + const invoke = asPreviewCommandInvoke( + vi.fn(async (command: string, args?: Record) => { calls.push([command, args]); if (command === 'get_local_game_preview_status') { throw new Error('需要在陶泥儿客户端内运行'); } return { status: 'stopped', url: null, port: null, root: null }; - }, + }), ); const resolution = await resolveSessionPreviewOnProjectOpen({ diff --git a/apps/ai-game-creator-shell/tests/stageStatusOverview.test.ts b/apps/ai-game-creator-shell/tests/stageStatusOverview.test.ts index b7677ead3..970f7e95f 100644 --- a/apps/ai-game-creator-shell/tests/stageStatusOverview.test.ts +++ b/apps/ai-game-creator-shell/tests/stageStatusOverview.test.ts @@ -40,6 +40,7 @@ function node(id: string, status: StageStatus, children: Node[] = []): Node { component: null, children_display_mode: 'Stack', children, + offset: { min: [0, 0], max: [0, 0] }, }; } diff --git a/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts b/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts index efbd54f1d..20c943336 100644 --- a/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts +++ b/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts @@ -7,23 +7,23 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, test, vi } from 'vitest'; import { - ensureAdminWeb, - ensureBackend, + ensureAdminWeb as ensureAdminWebImpl, + ensureBackend as ensureBackendImpl, formatOwnerLabel, formatStartupSummary, - isBackendReady, + isBackendReady as isBackendReadyImpl, isProcessGroupAlive, isWorktreeApiServerOwner, isWorktreeSpacetimeOwner, - preflightExistingVite, + preflightExistingVite as preflightExistingViteImpl, readAdminWebEnabled, readBackendServiceFailure, - readLinuxProcessGroupAlive, - readWindowsPortOwnerIdentities, + readLinuxProcessGroupAlive as readLinuxProcessGroupAliveImpl, + readWindowsPortOwnerIdentities as readWindowsPortOwnerIdentitiesImpl, resolveBackendTargetsFromState, - runWindowsTaskkill, + runWindowsTaskkill as runWindowsTaskkillImpl, spawnChild, - startAdminWeb, + startAdminWeb as startAdminWebImpl, stopChild, terminateChildTree, verifyAgcBackendOwnership, @@ -31,6 +31,209 @@ import { waitForChildTermination, } from '../scripts/start-dev-stack.mjs'; +// scripts/*.mjs 没有 JSDoc:TS 只能按默认值推导签名,可选回调参数被推成具体实现、 +// 注入点被推成 null/never[],与测试使用的 mock 结构不兼容。下面按运行时真实契约重新 +// 声明局部签名并重绑定导入实现,只做类型层校正,调用方式与运行时行为完全不变。 + +type EndpointPortRange = { start: number; end: number; label: string }; + +type PortOwnerIdentity = { + port?: number; + processId?: number; + name?: string | null; + executablePath?: string | null; + commandLine?: string | null; +}; + +type BackendServiceFixture = { + status: string; + url: string; + repoRoot?: string; + instanceId?: string; + exitCode?: number; + signal?: string | null; +}; + +type BackendStateFixture = { + schemaVersion: number; + repoRoot?: string; + instanceId?: string; + database: string; + updatedAt: string; + spacetimeDataDir?: string; + services: Record; +}; + +type BackendOwnershipResult = { + ok: boolean; + reason: string; + owners?: Map; + apiOwner?: PortOwnerIdentity; + workerOwner?: PortOwnerIdentity; + spacetimeOwner?: PortOwnerIdentity; +}; + +type IsBackendReadyOptions = { + state?: BackendStateFixture; + isReady?: (url: string) => boolean | Promise; + verifyOwnership?: (options?: { + apiUrl?: string; + spacetimeUrl?: string; + bgfilterWorkerUrl?: string; + }) => BackendOwnershipResult | Promise; + onOwnershipRejected?: ((ownership: BackendOwnershipResult) => void) | null; +}; + +// 导入实现本身是同一份,仅按运行时契约修正推导过窄的签名(JS 默认值导致结构上不可兼容)。 +const isBackendReady = isBackendReadyImpl as unknown as ( + options?: IsBackendReadyOptions, +) => Promise; + +type SpawnSyncLike = ( + command: string, + args: readonly string[], + options?: { env?: Record }, +) => { status: number | null; error?: unknown; stdout: string }; + +type ReadWindowsPortOwnersOptions = { + spawnImpl?: SpawnSyncLike; + env?: Record; + now?: () => number; + commandLineTtlMs?: number; + commandLineCache?: Map; +}; + +// 同上:spawnImpl 在 JS 推断里被收窄为 Node 的 spawnSync 重载,测试注入的同步 mock 不可直接赋值。 +const readWindowsPortOwnerIdentities = + readWindowsPortOwnerIdentitiesImpl as unknown as ( + ports: number[], + options?: ReadWindowsPortOwnersOptions, + ) => Map | null; + +type ReadLinuxProcessGroupAliveOptions = { + readdirImpl?: (path: string) => string[]; + readFileImpl?: (path: string, encoding: string) => string; +}; + +// 同上:readdirImpl/readFileImpl 在 JS 推断里被收窄为 readdirSync/readFileSync 的重载。 +const readLinuxProcessGroupAlive = + readLinuxProcessGroupAliveImpl as unknown as ( + processGroupId: number, + options?: ReadLinuxProcessGroupAliveOptions, + ) => boolean | null; + +type ChildLike = EventEmitter & { + pid?: number; + exitCode?: number | null; + signalCode?: string | null; + kill?: (signal?: string | number) => unknown; +}; + +type EnsureBackendOptions = { + onBackendChild?: (child: ChildLike) => void; + checkBackendReady?: () => boolean | Promise; + resolveTargets?: () => unknown; + spawnBackend?: () => ChildLike; + waitUntilReady?: (child: ChildLike) => unknown | Promise; +}; + +// 同上:spawnBackend 在 JS 推断里被收窄为返回完整 ChildProcessWithoutNullStreams。 +const ensureBackend = ensureBackendImpl as unknown as ( + options?: EnsureBackendOptions, +) => Promise<{ backendChild: ChildLike | null; targets: unknown }>; + +type TaskkillChildLike = EventEmitter & { + kill: (signal?: string | number) => unknown; +}; + +type RunWindowsTaskkillOptions = { + spawnImpl?: ( + command: string, + args: readonly string[], + options: { shell: boolean; stdio: string; windowsHide: boolean }, + ) => TaskkillChildLike; + timeoutMs?: number; +}; + +// 同上:spawnImpl 在 JS 推断里被收窄为 Node 的 spawn 重载,测试注入的 EventEmitter mock 不可直接赋值。 +const runWindowsTaskkill = runWindowsTaskkillImpl as unknown as ( + processId: number, + options?: RunWindowsTaskkillOptions, +) => Promise<{ timedOut: boolean; code: number | null; error: unknown }>; + +type PreflightEndpoint = { + host: string; + port: number; + url: string; + markerUrl: string; + portRange: EndpointPortRange | null; +}; + +type PreflightExistingViteOptions = { + endpoint?: PreflightEndpoint; + readServer?: ( + endpoint: PreflightEndpoint, + ) => Promise<{ statusCode: number; body: string } | null>; + portListening?: (endpoint: PreflightEndpoint) => Promise; + readMarker?: (endpoint: PreflightEndpoint) => Promise; +}; + +// 同上:endpoint 在 JS 推断里被收窄为 portRange: null(createAgcDevEndpoint 的默认值)。 +const preflightExistingVite = preflightExistingViteImpl as unknown as ( + options?: PreflightExistingViteOptions, +) => Promise<{ status: string; apiTarget: string }>; + +type AdminWebEndpoint = { + host: string; + port: number; + basePath?: string; + url: string; +}; + +type AdminWebChild = { pid?: number }; + +type StartAdminWebOptions = { + env?: Record; + spawnImpl?: ( + command: string, + args: string[], + options: { cwd: string; env: Record }, + ) => AdminWebChild; +}; + +// 同上:spawnImpl 在 JS 推断里被收窄为 spawnChild 的完整子进程返回类型。 +const startAdminWeb = startAdminWebImpl as unknown as ( + apiUrl: string, + endpoint: AdminWebEndpoint, + options?: StartAdminWebOptions, +) => AdminWebChild; + +type EnsureAdminWebOptions = { + apiUrl: string; + reservedPorts?: number[]; + env?: Record; + enabled?: boolean; + resolveEndpoint?: (options: { + env?: Record; + reservedPorts?: number[]; + }) => AdminWebEndpoint | Promise; + spawnAdminWeb?: ( + apiUrl: string, + endpoint: AdminWebEndpoint, + options: { env?: Record }, + ) => AdminWebChild; + waitForExit?: (child: AdminWebChild) => unknown | Promise; + warn?: (message: string) => void; +}; + +// 同上:apiUrl 等无默认值的解构项未被 JS 推断保留,reservedPorts 被推成 never[]。 +const ensureAdminWeb = ensureAdminWebImpl as unknown as ( + options: EnsureAdminWebOptions, +) => Promise<{ + endpoint: AdminWebEndpoint | null; + child: AdminWebChild | null; +}>; + const expectedDatabase = 'genarrative-game-creator-dev'; const expectedDataDir = resolve('server-rs/.spacetimedb/ai-game-creator/data'); const expectedExePath = resolve('server-rs/target/debug/api-server.exe'); @@ -40,7 +243,10 @@ const ownedBackend = async () => ({ owners: new Map(), }); -function backendState(spacetimeDataDir?: string, includeBgfilterWorker = true) { +function backendState( + spacetimeDataDir?: string, + includeBgfilterWorker = true, +): BackendStateFixture { const instanceId = 'fixture-instance'; const service = (url: string) => ({ status: 'running', @@ -180,9 +386,9 @@ describe('AI 游戏创作配套后端复用门禁', () => { test('后端服务失败时返回具体失败服务,避免外层无限等待', () => { const state = backendState(expectedDataDir); - state.services['bgfilter-worker'].status = 'failed'; - state.services['bgfilter-worker'].exitCode = 1; - state.services['bgfilter-worker'].signal = null; + state.services['bgfilter-worker']!.status = 'failed'; + state.services['bgfilter-worker']!.exitCode = 1; + state.services['bgfilter-worker']!.signal = null; expect(readBackendServiceFailure(state)).toEqual({ serviceName: 'bgfilter-worker', @@ -192,8 +398,8 @@ describe('AI 游戏创作配套后端复用门禁', () => { test('不匹配的旧状态失败记录不会阻断当前后端启动', () => { const state = backendState(resolve('server-rs/.spacetimedb/other/data')); - state.services['bgfilter-worker'].status = 'failed'; - state.services['bgfilter-worker'].exitCode = 1; + state.services['bgfilter-worker']!.status = 'failed'; + state.services['bgfilter-worker']!.exitCode = 1; expect(readBackendServiceFailure(state)).toBeNull(); }); @@ -203,8 +409,8 @@ describe('AI 游戏创作配套后端复用门禁', () => { initialState.updatedAt = '2026-09-04T08:00:00.000Z'; const state = backendState(expectedDataDir); state.updatedAt = '2026-09-04T08:00:01.000Z'; - state.services['bgfilter-worker'].status = 'failed'; - state.services['bgfilter-worker'].exitCode = 98; + state.services['bgfilter-worker']!.status = 'failed'; + state.services['bgfilter-worker']!.exitCode = 98; const child = Object.assign(new EventEmitter(), { exitCode: null, signalCode: null, @@ -426,7 +632,7 @@ describe('AI 游戏创作配套后端归属校验', () => { }); test('端口探测改用 netstat 取监听 PID,不再每次调用 WMI 的 Get-NetTCPConnection', () => { - const spawnImpl = vi.fn((..._args: unknown[]) => ({ + const spawnImpl = vi.fn((_command: string, args: readonly string[]) => ({ status: 0, error: null, stdout: '[]', @@ -434,7 +640,7 @@ describe('AI 游戏创作配套后端归属校验', () => { readWindowsPortOwnerIdentities([8082], { spawnImpl, env: {} }); - const probeArgs = spawnImpl.mock.calls[0][1] ?? []; + const probeArgs = spawnImpl.mock.calls[0]![1] ?? []; const probeScript = String(probeArgs[probeArgs.length - 1] ?? ''); expect(probeScript).toContain('netstat -ano -p tcp'); expect(probeScript).not.toContain('Get-NetTCPConnection'); @@ -470,7 +676,7 @@ describe('AI 游戏创作配套后端归属校验', () => { commandLineTtlMs: 60_000, }; const envAt = (call: number) => - (spawnImpl.mock.calls[call][2] as { env: Record }).env; + (spawnImpl.mock.calls[call]![2] as { env: Record }).env; readWindowsPortOwnerIdentities([3102], options); expect(envAt(0)).toEqual({ GENARRATIVE_QUERY_PORTS: '3102' }); @@ -595,9 +801,10 @@ describe('AI 游戏创作启动子进程生命周期', () => { await waitForFile(stoppedPath); } finally { - if (Number.isInteger(child?.pid)) { + const childPid = child?.pid; + if (Number.isInteger(childPid)) { try { - process.kill(-child.pid, 'SIGKILL'); + process.kill(-childPid!, 'SIGKILL'); } catch { // 测试后代已经退出。 } @@ -747,7 +954,13 @@ describe('AGC 开发态后台 Web', () => { }); test('后台 Vite 指向配套后端且严格使用解析后的端口', () => { - const spawnImpl = vi.fn(() => ({ pid: 4242 })); + const spawnImpl = vi.fn( + ( + _command: string, + _args: string[], + _options: { cwd: string; env: Record }, + ) => ({ pid: 4242 }), + ); startAdminWeb( 'http://127.0.0.1:8084', { @@ -760,7 +973,7 @@ describe('AGC 开发态后台 Web', () => { ); expect(spawnImpl).toHaveBeenCalledTimes(1); - const [command, args, options] = spawnImpl.mock.calls[0]; + const [command, args, options] = spawnImpl.mock.calls[0]!; expect(command).toBe(process.platform === 'win32' ? 'npm.cmd' : 'npm'); expect(args).toEqual([ '--prefix', diff --git a/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts b/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts index b3de87823..fc784f551 100644 --- a/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts +++ b/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts @@ -7,7 +7,7 @@ import { describe, expect, test, vi } from 'vitest'; import { readCargoTarget, - resolveEditorFeatures, + resolveEditorFeatures as resolveEditorFeaturesImpl, } from '../scripts/cargo-features.mjs'; import { isProcessGroupAlive, @@ -17,11 +17,91 @@ import { import { buildTauriArguments, buildTauriDevProcessEnv, - prepareBundledResourcesBeforeTauri, - runTauriDev as runTauriDevImpl, + prepareBundledResourcesBeforeTauri as prepareBundledResourcesBeforeTauriImpl, + runTauriDev as runTauriDevRaw, withDevCargoFeatures, } from '../scripts/start-tauri-dev.mjs'; +// scripts/*.mjs 没有 JSDoc:TS 只能按默认值推导签名,可选回调参数被推成具体实现、 +// argv 默认值被推成 never[],与测试注入的 mock / 参数结构不兼容。下面按运行时真实契约 +// 重新声明局部签名并重绑定导入实现,只做类型层校正,调用方式与运行时行为完全不变。 + +type EndpointPortRange = { start: number; end: number; label: string }; + +type DevEndpoint = { + host: string; + port: number; + url: string; + markerUrl: string; + portRange: EndpointPortRange | null; +}; + +type ResolveEditorFeaturesOptions = { + argv?: string[]; + target?: string; + env?: Record; +}; + +// 同上:函数的 argv 默认值 [] 被 JS 推断成 never[],无法接收测试传入的 string[]。 +const resolveEditorFeatures = resolveEditorFeaturesImpl as unknown as ( + options?: ResolveEditorFeaturesOptions, +) => string[]; + +type PrepareBundledResourcesOptions = { + target: string; + features: Iterable; +}; + +type PrepareBundledResourcesBeforeTauriOptions = { + prepare?: (options: PrepareBundledResourcesOptions) => unknown[]; + log?: (message: string) => void; + hostTarget?: string | null; +}; + +// 同上:prepare 在 JS 推断里被收窄为 prepareBundledResources 的完整可选参数签名。 +const prepareBundledResourcesBeforeTauri = + prepareBundledResourcesBeforeTauriImpl as unknown as ( + features: Iterable, + argv?: string[], + options?: PrepareBundledResourcesBeforeTauriOptions, + ) => void; + +type ChildProcessLike = { + pid?: number; + exitCode?: number | null; + signalCode?: string | null; +}; + +type RunTauriDevOptions = { + resolveDevEndpoint?: (options?: { + strictConfigured?: boolean; + }) => DevEndpoint | Promise; + preflight?: (options: { + endpoint: DevEndpoint; + }) => unknown | Promise; + prepareFrontend?: ( + endpoint: DevEndpoint, + options: { + signal: AbortSignal; + onChild: (child: ChildProcessLike) => void; + }, + ) => unknown | Promise; + spawnCli?: ( + argv: string[], + options: { env: Record }, + ) => ChildProcessLike; + waitForCli?: (child: ChildProcessLike) => unknown | Promise; + terminateTree?: (child: ChildProcessLike) => unknown | Promise; + prepareResources?: (...args: unknown[]) => void; +}; + +// 同上:resolveDevEndpoint / preflight / spawnCli / terminateTree 在 JS 推断里被收窄为 +// 具体实现的返回结构,测试注入的 mock 不可直接赋值。 +const runTauriDevImpl = runTauriDevRaw as unknown as ( + argv?: string[], + options?: RunTauriDevOptions, +) => Promise; + const testEndpoint = { host: '127.0.0.1', port: 10005, @@ -227,7 +307,7 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => { } }, 10); `; - let cliChild; + let cliChild: ReturnType | undefined; try { const result = await runTauriDev([], { resolveDevEndpoint: resolveTestEndpoint, @@ -245,9 +325,10 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => { expect(result).toBe(42); await waitForFile(stoppedPath); } finally { - if (Number.isInteger(cliChild?.pid)) { + const cliPid = cliChild?.pid; + if (Number.isInteger(cliPid)) { try { - process.kill(-cliChild.pid, 'SIGKILL'); + process.kill(-cliPid!, 'SIGKILL'); } catch { // 进程组已经由启动器收束。 } @@ -283,7 +364,7 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => { } }, 10); `; - let cliChild; + let cliChild: ReturnType | undefined; try { const result = await runTauriDev([], { resolveDevEndpoint: resolveTestEndpoint, @@ -304,11 +385,12 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => { }); expect(result).toBe(42); - expect(isProcessGroupAlive(cliChild.pid)).toBe(false); + expect(isProcessGroupAlive(cliChild!.pid)).toBe(false); } finally { - if (Number.isInteger(cliChild?.pid)) { + const cliPid = cliChild?.pid; + if (Number.isInteger(cliPid)) { try { - process.kill(-cliChild.pid, 'SIGKILL'); + process.kill(-cliPid!, 'SIGKILL'); } catch { // 进程组已经由启动器强制收束。 } @@ -417,7 +499,7 @@ describe('AI 游戏创作 Tauri dev 随包资源准备目标', () => { 'x86_64-pc-windows-msvc', hostTarget, ]); - expect(calls[0][1]).toEqual(windowsFeatures); + expect(calls[0]![1]).toEqual(windowsFeatures); }); test('宿主平台不受声明覆盖时跳过准备,且不为其新增随包支持', () => { @@ -471,12 +553,12 @@ describe('AI 游戏创作 Tauri dev 随包资源准备目标', () => { expect(prepared.map(([features]) => features)).toEqual([ ['custom-feature'], ]); - expect(spawned[0].slice(0, 3)).toEqual(['dev', '-f', 'custom-feature']); - expect(spawned[0]).not.toContain( + expect(spawned[0]!.slice(0, 3)).toEqual(['dev', '-f', 'custom-feature']); + expect(spawned[0]!).not.toContain( `--features=${windowsFeatures.join(',')}`, ); // 随包资源准备拿到的是最终交给 Tauri 的参数,不是启动器收到的原始参数。 - expect(prepared[0][1]).toEqual(spawned[0]); + expect(prepared[0]![1]).toEqual(spawned[0]!); } finally { vi.unstubAllEnvs(); } @@ -492,14 +574,14 @@ describe('AI 游戏创作 Tauri dev 随包资源准备目标', () => { expect(result).toBe(0); expect(prepared.map(([features]) => features)).toEqual([[]]); - expect(prepared[0][1]).toEqual([ + expect(prepared[0]![1]).toEqual([ 'dev', '--target', 'x86_64-apple-darwin', '--config', devConfig, ]); - expect(spawned[0]).toEqual(prepared[0][1]); + expect(spawned[0]!).toEqual(prepared[0]![1]); } finally { vi.unstubAllEnvs(); } @@ -515,7 +597,7 @@ describe('AI 游戏创作 Tauri dev 随包资源准备目标', () => { expect(result).toBe(0); expect(prepared.map(([features]) => features)).toEqual([[]]); - expect(spawned[0]).toEqual([ + expect(spawned[0]!).toEqual([ 'dev', '--config', devConfig, @@ -523,7 +605,7 @@ describe('AI 游戏创作 Tauri dev 随包资源准备目标', () => { '--', '--features=app-tool', ]); - expect(prepared[0][1]).toEqual(spawned[0]); + expect(prepared[0]![1]).toEqual(spawned[0]!); } finally { vi.unstubAllEnvs(); } @@ -537,7 +619,7 @@ describe('AI 游戏创作 Tauri dev 随包资源准备目标', () => { expect(result).toBe(0); expect(prepared.map(([features]) => features)).toEqual([[]]); expect( - spawned[0].some((argument) => argument.startsWith('--features')), + spawned[0]!.some((argument) => argument.startsWith('--features')), ).toBe(false); } finally { vi.unstubAllEnvs(); diff --git a/apps/ai-game-creator-shell/tests/tauriEventFake.ts b/apps/ai-game-creator-shell/tests/tauriEventFake.ts index bcc8b4d7d..008145180 100644 --- a/apps/ai-game-creator-shell/tests/tauriEventFake.ts +++ b/apps/ai-game-creator-shell/tests/tauriEventFake.ts @@ -102,8 +102,8 @@ export function createTauriEventFake( flushRegistrationEvals() { const pending = registrations.splice(0, registrations.length); for (const listener of pending) { - registry[listener.event] ??= {}; - registry[listener.event][listener.eventId] = { + const eventRegistry = (registry[listener.event] ??= {}); + eventRegistry[listener.eventId] = { handlerId: listener.handlerId, }; } diff --git a/apps/ai-game-creator-shell/tests/tauriRuntimeFake.ts b/apps/ai-game-creator-shell/tests/tauriRuntimeFake.ts new file mode 100644 index 000000000..74c3e98f7 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/tauriRuntimeFake.ts @@ -0,0 +1,59 @@ +/** + * 测试用 Tauri 全局桥替身:全仓唯一的 `window.__TAURI__` 安装点与类型转换点。 + * + * `Window['__TAURI__']` 声明的是泛型签名(`core.invoke` / `event.listen` 都是泛型函数), + * 而各套件的替身是 `vi.fn` 或具体签名的 mock,二者结构上不兼容。若在每个用例里直接赋值, + * 就得反复写 `as never` / `as unknown as`,类型检查形同虚设;这里把转换收敛到一处, + * 调用方只声明自己真正装了哪些能力。 + * + * 参数用「可调用」下界而不是 `unknown`:非函数的误装(字符串、`undefined`)会在编译期被挡下, + * 同时不与各夹具更具体的 mock 签名冲突;`core` / `event` 都可缺省。 + */ + +export type TauriRuntimeInstall = { + core?: { + invoke?: ( + command: string, + args?: Record, + ) => Promise; + }; + event?: { + listen?: ( + event: string, + handler: (event: { payload: unknown }) => void, + ) => Promise<() => void>; + }; +}; + +export function installTauriRuntime(runtime: TauriRuntimeInstall) { + window.__TAURI__ = runtime as unknown as Window['__TAURI__']; +} + +/** 清掉替身,供 `afterEach` / `beforeEach` 复位(等价于仓库既有的 `delete window.__TAURI__`)。 */ +export function resetTauriRuntime() { + delete window.__TAURI__; +} + +/** + * 测试用 Tauri 事件注册表:只登记 `event.listen` 的回调并按事件名派发。 + * + * 不碰 `window.__TAURI__`、不做类型转换;安装由 `installTauriRuntime` 负责,需要事件桥的 + * 用例把 `bridge.listen` 交给它即可。 + */ +export function createTauriEventBridge() { + const listeners = new Map void>(); + return { + listen: async ( + event: string, + handler: (event: { payload: unknown }) => void, + ) => { + listeners.set(event, handler); + return () => { + listeners.delete(event); + }; + }, + emit: (event: string, payload: unknown) => { + listeners.get(event)?.({ payload }); + }, + }; +} diff --git a/apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx b/apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx index 5e3d3086d..83583e196 100644 --- a/apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx +++ b/apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx @@ -2,9 +2,11 @@ import { fireEvent, render, screen, within } from '@testing-library/react'; import { beforeAll, describe, expect, it, vi } from 'vitest'; +import type { InitLocalProjectResult } from '../src/app/types'; import { computeTemplateGridLayout } from '../src/features/template-library/templateLibraryGrid'; import type { GameTemplateEntry, + InstalledGameTemplate, TemplateLibraryFilters, } from '../src/features/template-library/templateLibraryModel'; import { @@ -285,8 +287,16 @@ describe('TemplateLibraryView', () => { }); it('starts a download and a template project from the card actions', () => { - const downloadTemplate = vi.fn(async () => undefined); - const createProjectFromTemplate = vi.fn(async () => undefined); + // 这两个 mock 只用于断言「被调用」,视图会 `.catch` 掉结果并丢弃; + // 运行时仍返回已 resolve 的 undefined,类型层用 `as unknown as` 补齐合同返回值。 + const downloadTemplate = vi.fn< + [template: GameTemplateEntry], + Promise + >(async () => undefined as unknown as InstalledGameTemplate); + const createProjectFromTemplate = vi.fn< + [template: GameTemplateEntry], + Promise + >(async () => undefined as unknown as InitLocalProjectResult); render( { it('rejects an empty image list without invoking the command', async () => { @@ -25,10 +28,15 @@ describe('ui design resource bridge', () => { committedProjectRevision: 7, }; const invoke = vi.fn().mockResolvedValue(created); + // 真实类型用「省略 path」表达没有路径,而本用例的调用断言固定了 `path: null`; + // 结构上无法用该联合类型表达,只能断言收敛,运行时载荷原样保留。 + const images = [ + { assetId: 'image-a', path: null }, + ] as unknown as UiDesignDocImageReference[]; const result = await createUiDesignDocFromImages({ projectPath: '/project', expectedProjectId: 'project-1', - images: [{ assetId: 'image-a', path: null }], + images, invoke, }); expect(invoke).toHaveBeenCalledWith('create_ui_design_doc_from_images', { diff --git a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts index 6575d7c54..62a4c6243 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts @@ -37,6 +37,7 @@ import type { IUiDesignStateStore, UiDesignStateSnapshot, } from '../src/features/ui-editor/uiDesignStateStore'; +import type { UiEditorOperationResult } from '../src/features/ui-editor/useUiEditorState'; import UiEditorPage from '../src/view/ui-editor'; import { useUiEditorSession } from '../src/view/ui-editor/useUiEditorPage'; @@ -92,11 +93,24 @@ function node(id: string, children: UiNode[] = []): UiNode { source: 'System', }, component: null, - children_display_mode: undefined, + children_display_mode: 'Stack', children, + // 真实 Node 现在要求 `offset`,但本 fixture 是「编辑器载入前」的历史形态: + // 运行期必须保持缺省,PreviewWorkspace 才会按旧口径跳过布局;这里只做类型收敛。 + offset: undefined as unknown as UiNode['offset'], }; } +/** + * `insertNode` 返回判别联合 `{ ok: true; value } | { ok: false; reason }`,失败分支没有 + * `value`。测试只取成功分支的节点 id,与原来的 `?.value` 在运行期等价。 + */ +function insertedNodeId( + result: UiEditorOperationResult | undefined, +): string | undefined { + return result?.ok ? result.value : undefined; +} + function stateWithPages(pageIds: string[]): State { return { ui_design_images: Object.fromEntries( @@ -224,11 +238,13 @@ describe('UiEditorPage', () => { expect.objectContaining({ scopeId: expect.any(String) }), ), ); - const firstScopeId = vi - .mocked(invoke) - .mock.calls.find( - ([command]) => command === 'read_local_project_image_preview', - )?.[1]?.scopeId; + const firstScopeId = ( + vi + .mocked(invoke) + .mock.calls.find( + ([command]) => command === 'read_local_project_image_preview', + )?.[1] as Record | undefined + )?.scopeId; hook.rerender({ resourceId: 'ui-resource-b' }); await waitFor(() => @@ -246,12 +262,14 @@ describe('UiEditorPage', () => { ), ).toHaveLength(4), ); - const secondScopeId = vi - .mocked(invoke) - .mock.calls.filter( - ([command]) => command === 'read_local_project_image_preview', - ) - .at(-1)?.[1]?.scopeId; + const secondScopeId = ( + vi + .mocked(invoke) + .mock.calls.filter( + ([command]) => command === 'read_local_project_image_preview', + ) + .at(-1)?.[1] as Record | undefined + )?.scopeId; hook.unmount(); @@ -461,10 +479,8 @@ describe('UiEditorPage', () => { expect(play).toHaveBeenCalledTimes(4); expect(scrollSpy).toHaveBeenCalledTimes(4); } finally { - delete (Element.prototype as Element & { getAnimations?: unknown }) - .getAnimations; - delete (Element.prototype as Element & { scrollIntoView?: unknown }) - .scrollIntoView; + delete (Element.prototype as { getAnimations?: unknown }).getAnimations; + delete (Element.prototype as { scrollIntoView?: unknown }).scrollIntoView; } }); @@ -511,8 +527,7 @@ describe('UiEditorPage', () => { }); } } finally { - delete (Element.prototype as Element & { scrollIntoView?: unknown }) - .scrollIntoView; + delete (Element.prototype as { scrollIntoView?: unknown }).scrollIntoView; } }); @@ -626,8 +641,12 @@ describe('UiEditorPage', () => { let parentId: string | undefined; let childId: string | undefined; act(() => { - parentId = result.current.input.insertNode(rootId, 'page')?.value; - childId = result.current.input.insertNode(parentId!, 'page')?.value; + parentId = insertedNodeId( + result.current.input.insertNode(rootId, 'page'), + ); + childId = insertedNodeId( + result.current.input.insertNode(parentId!, 'page'), + ); result.current.input.selectNode(childId!); }); @@ -706,7 +725,9 @@ describe('UiEditorPage', () => { const rootId = 'page-root'; let otherNodeId: string | undefined; act(() => { - otherNodeId = result.current.input.insertNode(rootId, 'page')?.value; + otherNodeId = insertedNodeId( + result.current.input.insertNode(rootId, 'page'), + ); result.current.input.selectNode(rootId); result.current.input.highlightStatusField(rootId, 'layout_status'); }); @@ -745,7 +766,9 @@ describe('UiEditorPage', () => { const rootId = 'page-root'; let otherNodeId: string | undefined; act(() => { - otherNodeId = result.current.input.insertNode(rootId, 'page')?.value; + otherNodeId = insertedNodeId( + result.current.input.insertNode(rootId, 'page'), + ); result.current.input.focusNode('page', rootId); result.current.input.highlightStatusField(rootId, 'layout_status'); }); @@ -774,13 +797,18 @@ describe('UiEditorPage', () => { let childBId: string | undefined; let childBDescendantId: string | undefined; act(() => { - parentId = result.current.input.insertNode(rootId, 'page')?.value; - childAId = result.current.input.insertNode(parentId!, 'page')?.value; - childBId = result.current.input.insertNode(parentId!, 'page')?.value; - childBDescendantId = result.current.input.insertNode( - childBId!, - 'page', - )?.value; + parentId = insertedNodeId( + result.current.input.insertNode(rootId, 'page'), + ); + childAId = insertedNodeId( + result.current.input.insertNode(parentId!, 'page'), + ); + childBId = insertedNodeId( + result.current.input.insertNode(parentId!, 'page'), + ); + childBDescendantId = insertedNodeId( + result.current.input.insertNode(childBId!, 'page'), + ); }); act(() => result.current.input.selectNode(parentId!)); act(() => result.current.inspector.setNodeChildrenDisplayMode('Exclusive')); @@ -1068,7 +1096,7 @@ describe('UiEditorPage', () => { ); expect(calls).toHaveLength(count); if (count) - expect(calls[0][1]).toMatchObject({ + expect(calls[0]?.[1]).toMatchObject({ projectPath: '/tmp/ui-editor', saveSource, changed: status === 'saved', diff --git a/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx b/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx index 585e308ba..1fbbd8067 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx +++ b/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx @@ -41,6 +41,7 @@ function node(id: string, children: UiNode[] = []): UiNode { component: null, children_display_mode: 'Stack', children, + offset: { min: [0, 0], max: [0, 0] }, }; } @@ -61,20 +62,21 @@ const resources = { }; function renderTree( - renderMode: 'editor-overlay' | 'final-preview', hiddenNodeIds: ReadonlySet, options: { showFrame?: boolean; + showComponent?: boolean; selectedNodeId?: string | null; onSelectNode?: ReturnType; onNodeContextMenu?: ReturnType; + onSelectExclusiveChild?: (nodeId: string) => void; } = {}, ) { return render( , ); @@ -95,7 +98,7 @@ function renderTree( describe('UI tree preview visibility', () => { it('disables native text selection on preview node layers', () => { - const rendered = renderTree('editor-overlay', new Set()); + const rendered = renderTree(new Set()); expect( rendered.container @@ -106,7 +109,7 @@ describe('UI tree preview visibility', () => { it('does not use click alone to select directly draggable nodes', () => { const onSelectNode = vi.fn(); - const rendered = renderTree('editor-overlay', new Set(), { onSelectNode }); + const rendered = renderTree(new Set(), { onSelectNode }); fireEvent.click( rendered.container.querySelector('[data-node-id="child"]')!, ); @@ -187,8 +190,8 @@ describe('UI tree preview visibility', () => { const rendered = render( { onNodeResizePointerMove={vi.fn()} onNodeResizePointerUp={vi.fn()} onNodeResizePointerCancel={vi.fn()} + onSelectExclusiveChild={vi.fn()} viewportScale={1} />, ); @@ -219,7 +223,7 @@ describe('UI tree preview visibility', () => { }); it('shows final-preview node frames and resize handles only when requested', () => { - const withoutFrames = renderTree('final-preview', new Set()); + const withoutFrames = renderTree(new Set()); const plainChild = withoutFrames.container.querySelector( '[data-node-id="child"]', ) as HTMLDivElement; @@ -227,7 +231,7 @@ describe('UI tree preview visibility', () => { expect(screen.queryByLabelText('nw 调整控制点')).toBeNull(); withoutFrames.unmount(); - const withFrames = renderTree('final-preview', new Set(), { + const withFrames = renderTree(new Set(), { showFrame: true, selectedNodeId: 'child', }); @@ -240,7 +244,7 @@ describe('UI tree preview visibility', () => { // root 是整树容器:它自己即使被选中也不能出现 resize 控制点,子节点仍按上面那 8 个。 withFrames.unmount(); - const withRootSelected = renderTree('final-preview', new Set(), { + const withRootSelected = renderTree(new Set(), { showFrame: true, selectedNodeId: 'root', }); @@ -251,7 +255,7 @@ describe('UI tree preview visibility', () => { }); it('hides a node subtree in editor overlay and final preview', () => { - const editorOverlay = renderTree('editor-overlay', new Set(['child'])); + const editorOverlay = renderTree(new Set(['child'])); expect( editorOverlay.container.querySelector('[data-node-id="root"]'), ).toBeTruthy(); @@ -262,7 +266,7 @@ describe('UI tree preview visibility', () => { editorOverlay.container.querySelector('[data-node-id="grandchild"]'), ).toBeNull(); - const finalPreview = renderTree('final-preview', new Set(['root'])); + const finalPreview = renderTree(new Set(['root'])); expect(finalPreview.container.querySelector('[data-node-id]')).toBeNull(); }); @@ -275,8 +279,8 @@ describe('UI tree preview visibility', () => { render( { onNodeResizePointerMove={vi.fn()} onNodeResizePointerUp={vi.fn()} onNodeResizePointerCancel={vi.fn()} + onSelectExclusiveChild={vi.fn()} viewportScale={1} />, ); @@ -349,8 +354,8 @@ describe('UI tree preview visibility', () => { render( { const editorOverlay = render( { onNodeResizePointerMove={vi.fn()} onNodeResizePointerUp={vi.fn()} onNodeResizePointerCancel={vi.fn()} + onSelectExclusiveChild={vi.fn()} viewportScale={1} />, ); @@ -414,7 +420,7 @@ describe('UI tree preview visibility', () => { ); editorOverlay.unmount(); - const finalPreview = renderTree('final-preview', new Set(), { + const finalPreview = renderTree(new Set(), { onNodeContextMenu, }); fireEvent.contextMenu( diff --git a/apps/ai-game-creator-shell/tests/uiEditorState.test.ts b/apps/ai-game-creator-shell/tests/uiEditorState.test.ts index e7e5d1c85..d4b5ec699 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorState.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorState.test.ts @@ -84,7 +84,9 @@ function nodeWithSprite(id: string): Node { image_type: { Simple: { preserve_aspect: false } }, }, }, + children_display_mode: 'Stack', children: [], + offset: { min: [0, 0], max: [0, 0] }, }; } @@ -116,6 +118,7 @@ function pageRoot(id: string, children: Node[] = []): Node { component: null, children_display_mode: 'Stack', children, + offset: { min: [0, 0], max: [0, 0] }, }; } @@ -465,6 +468,7 @@ describe('useUiEditorState', () => { component: null, children_display_mode: 'Stack', children: [nodeWithSprite('panel')], + offset: { min: [0, 0], max: [0, 0] }, }, }, ], @@ -566,13 +570,18 @@ describe('useUiEditorState', () => { }; const { result } = renderHook(() => useUiEditorState(initial)); + // 夹具根节点就是文本组件;先收窄联合类型,后面才能展开出完整 TextComponent。 + const rootComponent = initial.ui_trees[0]!.root.component; + if (!rootComponent || !('Text' in rootComponent)) { + throw new Error('夹具根节点必须是文本组件'); + } + const existingText = rootComponent.Text; + act(() => { expect( result.current.setNodeComponent('page', 'text-root', { Text: { - ...('Text' in initial.ui_trees[0]!.root.component! - ? initial.ui_trees[0]!.root.component!.Text - : {}), + ...existingText, font: { Bound: 'body' }, }, }), diff --git a/apps/ai-game-creator-shell/tests/uiTreeNodeCycle.test.ts b/apps/ai-game-creator-shell/tests/uiTreeNodeCycle.test.ts index fe73af837..18e53eea4 100644 --- a/apps/ai-game-creator-shell/tests/uiTreeNodeCycle.test.ts +++ b/apps/ai-game-creator-shell/tests/uiTreeNodeCycle.test.ts @@ -40,7 +40,7 @@ function node( source: 'System', }, component: null, - children_display_mode: undefined, + children_display_mode: 'Stack', offset: { min: [0, 0], max: [0, 0] }, children: [], }; diff --git a/apps/ai-game-creator-shell/tests/uiTreeUtils.test.ts b/apps/ai-game-creator-shell/tests/uiTreeUtils.test.ts index 2795e71f1..cae393966 100644 --- a/apps/ai-game-creator-shell/tests/uiTreeUtils.test.ts +++ b/apps/ai-game-creator-shell/tests/uiTreeUtils.test.ts @@ -14,6 +14,7 @@ function node(id: string, children: Node[] = []): Node { component: null, children_display_mode: 'Stack', children, + offset: {} as Node['offset'], }; } diff --git a/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.test.ts b/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.test.ts index 94287a7cb..b7812e2cd 100644 --- a/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.test.ts +++ b/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.test.ts @@ -12,7 +12,7 @@ import { installUnhandledRejectionBridge } from './unhandledRejectionBridge'; const installed: Array<() => void> = []; const processListeners: Array<(reason: unknown, promise: unknown) => void> = []; const windowListeners: Array<(event: Event) => void> = []; -let parkedListeners: Array<(reason: unknown, promise: unknown) => void> = []; +let parkedListeners: NodeJS.UnhandledRejectionListener[] = []; afterEach(() => { while (installed.length > 0) installed.pop()?.(); diff --git a/apps/ai-game-creator-shell/tests/unityProjectOpen.test.tsx b/apps/ai-game-creator-shell/tests/unityProjectOpen.test.tsx index 82b5d950c..713ccee5a 100644 --- a/apps/ai-game-creator-shell/tests/unityProjectOpen.test.tsx +++ b/apps/ai-game-creator-shell/tests/unityProjectOpen.test.tsx @@ -5,9 +5,10 @@ import { afterEach, expect, test, vi } from 'vitest'; import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; import { buildRecentProjectRows } from '../src/features/app-shell/model'; import { useHomeProjectCreation } from '../src/features/app-shell/useHomeProjectCreation'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; afterEach(() => { - delete window.__TAURI__; + resetTauriRuntime(); }); test.each([false, true])( @@ -42,13 +43,13 @@ test.each([false, true])( return { activeRuntime: 'game' }; throw new Error(`意外调用:${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const setLauncherView = vi.fn(); const { result } = renderHook(() => useHomeProjectCreation({ setStatus: vi.fn(), + showTransientNotice: vi.fn(), setLauncherView, - setAgentChatProjectPath: vi.fn(), rememberRecentWorkspace: vi.fn(), }), ); diff --git a/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx b/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx index f135a1db6..77fa628e0 100644 --- a/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx +++ b/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { act, renderHook } from '@testing-library/react'; +import type { PointerEvent as ReactPointerEvent } from 'react'; import { describe, expect, it, vi } from 'vitest'; import type { Node as UiNode } from '../src/features/ui-editor/types/Node'; @@ -34,7 +35,9 @@ function node(id: string, children: UiNode[] = []): UiNode { source: 'System', }, component: null, + children_display_mode: 'Stack', children, + offset: { min: [0, 0], max: [0, 0] }, }; } @@ -82,7 +85,8 @@ function pointerEvent( preventDefault: vi.fn(), shiftKey: false, stopPropagation: vi.fn(), - } as never; + // 测试只造出 hook 用到的少数字段,与完整的 React 合成事件结构上不可兼容,故用 as unknown as 收敛。 + } as unknown as ReactPointerEvent; } function canvas() { diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts index 4f8f84402..bbd90923d 100644 --- a/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts @@ -21,6 +21,7 @@ import { inspectProjectResourceCanvasLayoutRead, useProjectResourceCanvasLayout, } from '../src/view/project-development/useProjectResourceCanvasLayout'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; const projectId = 'layout-hook-project'; const projectPath = '/tmp/layout-hook-project'; @@ -78,9 +79,22 @@ function automaticPosition( }; } +/** + * 测试装配用的 Tauri invoke 桩入口。 + * + * `TauriInvoke` 是泛型函数签名,而 `vi.fn` 返回的是带具体返回类型的 Mock, + * 具体签名无法赋给泛型签名;这里集中做一次桥接,避免每个用例重复断言。 + */ +function installTauriInvoke( + invoke: (command: string, args?: Record) => Promise, +): void { + // 泛型签名与具体 Mock 结构上不可兼容,这里显式转换以完成测试装配。 + installTauriRuntime({ core: { invoke } }); +} + afterEach(() => { cleanup(); - window.__TAURI__ = undefined; + resetTauriRuntime(); vi.restoreAllMocks(); }); @@ -151,7 +165,7 @@ describe('useProjectResourceCanvasLayout', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const shallowResource = resource('resource-a'); const deepResource = { ...shallowResource, dependencyDepth: 2 }; const { result, rerender } = renderHook( @@ -189,7 +203,7 @@ describe('useProjectResourceCanvasLayout', () => { it('can render an unpersisted fallback after graph failure without becoming settled', () => { const invoke = vi.fn(); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result } = renderHook(() => useProjectResourceCanvasLayout({ @@ -234,7 +248,7 @@ describe('useProjectResourceCanvasLayout', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); renderHook(() => useProjectResourceCanvasLayout({ @@ -300,7 +314,7 @@ describe('useProjectResourceCanvasLayout', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result } = renderHook(() => useProjectResourceCanvasLayout({ @@ -363,7 +377,7 @@ describe('useProjectResourceCanvasLayout', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { rerender } = renderHook( ({ topology }) => useProjectResourceCanvasLayout({ @@ -416,7 +430,7 @@ describe('useProjectResourceCanvasLayout', () => { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result } = renderHook(() => useProjectResourceCanvasLayout({ @@ -462,7 +476,7 @@ describe('useProjectResourceCanvasLayout', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result } = renderHook(() => useProjectResourceCanvasLayout({ projectPath, @@ -518,7 +532,7 @@ describe('useProjectResourceCanvasLayout', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result, rerender } = renderHook( ({ resources }: { resources: ResourceCanvasItem[] }) => useProjectResourceCanvasLayout({ @@ -600,7 +614,7 @@ describe('useProjectResourceCanvasLayout', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result, rerender } = renderHook( ({ resources }: { resources: ResourceCanvasItem[] }) => useProjectResourceCanvasLayout({ @@ -696,7 +710,7 @@ describe('useProjectResourceCanvasLayout', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result } = renderHook(() => useProjectResourceCanvasLayout({ projectPath, @@ -760,7 +774,7 @@ describe('useProjectResourceCanvasLayout', () => { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result } = renderHook(() => useProjectResourceCanvasLayout({ projectPath, @@ -840,7 +854,7 @@ describe('useProjectResourceCanvasLayout', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const setTimeoutSpy = vi.spyOn(window, 'setTimeout'); const { result } = renderHook(() => useProjectResourceCanvasLayout({ @@ -927,7 +941,7 @@ describe('useProjectResourceCanvasLayout', () => { }); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result, rerender } = renderHook( ({ mode }: { mode: ProjectResourceCanvasLayoutMode }) => useProjectResourceCanvasLayout({ @@ -936,7 +950,9 @@ describe('useProjectResourceCanvasLayout', () => { mode, resources: [resourceA], }), - { initialProps: { mode: 'dependency' as const } }, + { + initialProps: { mode: 'dependency' as ProjectResourceCanvasLayoutMode }, + }, ); await waitFor(() => expect(readResolvers.has('dependency')).toBe(true)); rerender({ mode: 'type' }); @@ -998,7 +1014,7 @@ describe('useProjectResourceCanvasLayout', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result, rerender } = renderHook( ({ mode }: { mode: ProjectResourceCanvasLayoutMode }) => useProjectResourceCanvasLayout({ @@ -1007,7 +1023,9 @@ describe('useProjectResourceCanvasLayout', () => { mode, resources: [resourceA], }), - { initialProps: { mode: 'dependency' as const } }, + { + initialProps: { mode: 'dependency' as ProjectResourceCanvasLayoutMode }, + }, ); await waitFor(() => expect(result.current.layout.positions[0]?.x).toBe(10)); @@ -1061,7 +1079,7 @@ describe('useProjectResourceCanvasLayout', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result } = renderHook(() => useProjectResourceCanvasLayout({ projectPath, @@ -1120,7 +1138,7 @@ describe('useProjectResourceCanvasLayout', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result } = renderHook(() => useProjectResourceCanvasLayout({ projectPath, @@ -1225,7 +1243,7 @@ describe('useProjectResourceCanvasLayout', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result } = renderHook(() => useProjectResourceCanvasLayout({ projectPath, @@ -1290,7 +1308,7 @@ describe('useProjectResourceCanvasLayout', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result } = renderHook(() => useProjectResourceCanvasLayout({ projectPath, @@ -1326,7 +1344,7 @@ describe('useProjectResourceCanvasLayout', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result } = renderHook(() => useProjectResourceCanvasLayout({ projectPath, @@ -1397,7 +1415,14 @@ describe('resource canvas layout read report', () => { const counts = inspectProjectResourceCanvasLayoutRead( layoutWith([ { resourceId: 'a', section: 'code', x: 1, y: 2, manuallyPlaced: true }, - { resourceId: 'b', section: 'nope', x: 3, y: 4, manuallyPlaced: true }, + { + resourceId: 'b', + // 用例刻意喂入无法识别的分区取值,类型层收口到真实联合但不改运行时串。 + section: 'nope' as ProjectResourceCanvasPosition['section'], + x: 3, + y: 4, + manuallyPlaced: true, + }, ]), [ { ...resource('a'), category: 'document' }, @@ -1480,7 +1505,7 @@ describe('布局落盘 / 读盘失败的原因不再被吞掉', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result } = renderHook(() => useProjectResourceCanvasLayout({ @@ -1520,7 +1545,7 @@ describe('布局落盘 / 读盘失败的原因不再被吞掉', () => { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result } = renderHook(() => useProjectResourceCanvasLayout({ @@ -1581,7 +1606,7 @@ describe('资源画布整理与批量坐标写入', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); return { invoke, updates }; } @@ -1631,7 +1656,7 @@ describe('资源画布整理与批量坐标写入', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); return { invoke, updates, @@ -2176,7 +2201,7 @@ describe('资源画布整理与批量坐标写入', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result, rerender } = renderHook( ({ mode }: { mode: ProjectResourceCanvasLayoutMode }) => useProjectResourceCanvasLayout({ @@ -2185,7 +2210,7 @@ describe('资源画布整理与批量坐标写入', () => { mode, resources: [documentA, documentB], }), - { initialProps: { mode: 'type' as const } }, + { initialProps: { mode: 'type' as ProjectResourceCanvasLayoutMode } }, ); await waitFor(() => expect(result.current.settled).toBe(true)); typeWrites.length = 0; @@ -2524,7 +2549,7 @@ describe('资源画布整理与批量坐标写入', () => { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result } = renderHook(() => useProjectResourceCanvasLayout({ diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts index 6db7c7e3a..cc4472fd5 100644 --- a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts @@ -8,7 +8,15 @@ import { waitFor, } from '@testing-library/react'; import React from 'react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + type Mock, + vi, +} from 'vitest'; import { isProjectResourcePreviewCancellation } from '../src/services/projectResourcePreviewTransport'; import { createResourceCanvasPrefetchScopeKey } from '../src/view/project-development/resourceCanvasController'; @@ -23,6 +31,7 @@ import { nextPreviewJob, useProjectResourceCardPreviews as useProjectResourceCardPreviewsUnderTest, } from '../src/view/project-development/useProjectResourceCardPreviews'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; type PreviewHookInput = Parameters< typeof useProjectResourceCardPreviewsUnderTest @@ -126,7 +135,7 @@ beforeEach(() => { afterEach(() => { cleanup(); - window.__TAURI__ = undefined; + resetTauriRuntime(); vi.restoreAllMocks(); if (originalCreateObjectUrl) { Object.defineProperty(URL, 'createObjectURL', originalCreateObjectUrl); @@ -158,7 +167,7 @@ describe('useProjectResourceCardPreviews', () => { : {}), }), ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const resources = [json]; const canvasRef = { current: document.createElement('div') }; const { result, rerender } = renderHook( @@ -209,14 +218,14 @@ describe('useProjectResourceCardPreviews', () => { content: 'const answer = 42;', }), ); - window.__TAURI__ = { + installTauriRuntime({ core: { invoke: async ( command: string, args?: Record, ) => (await invoke(command, args)) as Result, }, - }; + }); const { result } = renderHook(() => useProjectResourceCardPreviews({ projectPath: '/tmp/document-preview', @@ -260,7 +269,7 @@ describe('useProjectResourceCardPreviews', () => { async (_command: string, args?: Record) => preview(String(args?.relativePath ?? art.path)), ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const canvasRef = { current: document.createElement('div') }; const { result } = renderHook(() => @@ -294,7 +303,7 @@ describe('useProjectResourceCardPreviews', () => { mediaType: 'image/gif', }); const invoke = vi.fn(async () => preview(art.path, 'image/gif')); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const canvasRef = { current: document.createElement('div') }; const { result } = renderHook(() => @@ -335,8 +344,12 @@ describe('useProjectResourceCardPreviews', () => { // 6 类资产分类轴落地后的画布栏目取值,与线上读取分支是两个轴。 category: 'unclassified', }); - const invoke = vi.fn(async () => preview(art.path, 'image/gif')); - window.__TAURI__ = { core: { invoke } }; + // 补上 IPC 参数签名:下面要按「命令 + 入参」读回调用,空元组会取不到 index 1。 + const invoke = vi.fn( + async (_command: string, _args?: Record) => + preview(art.path, 'image/gif'), + ); + installTauriRuntime({ core: { invoke } }); const canvasRef = { current: document.createElement('div') }; const { result } = renderHook(() => @@ -371,7 +384,7 @@ describe('useProjectResourceCardPreviews', () => { category: 'audio', }); const invoke = vi.fn(async () => preview(audio.path, 'audio/mpeg')); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const canvasRef = { current: document.createElement('div') }; const { result } = renderHook(() => @@ -479,7 +492,7 @@ describe('useProjectResourceCardPreviews', () => { new Error('读取项目媒体资源失败:temporarily busy'), ) .mockResolvedValueOnce(preview(art.path)); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const canvasRef = { current: document.createElement('div') }; const { result } = renderHook(() => useProjectResourceCardPreviews({ @@ -520,7 +533,7 @@ describe('useProjectResourceCardPreviews', () => { .mockRejectedValue( new Error('image.inspect 图片结构无效:assets/broken.png'), ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const canvasRef = { current: document.createElement('div') }; const { result } = renderHook(() => useProjectResourceCardPreviews({ @@ -564,11 +577,18 @@ describe('useProjectResourceCardPreviews', () => { const secondRead = deferred>(); const thirdRead = deferred>(); const blockedReads = [firstRead, secondRead, thirdRead]; - const invoke = vi.fn((_: string, args?: Record) => { + // 显式标注类型:mock 内部要读 `invoke.mock.calls`,有注解后不再形成自引用推断环。 + const invoke: Mock< + [command: string, args?: Record], + Promise> + > = vi.fn((_: string, args?: Record) => { const blocked = blockedReads[invoke.mock.calls.length - 1]; - return blocked?.promise ?? new Promise(() => undefined); + return ( + blocked?.promise ?? + new Promise>(() => undefined) + ); }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const canvasRef = { current: document.createElement('div') }; const { result } = renderHook(() => useProjectResourceCardPreviews({ @@ -636,7 +656,7 @@ describe('useProjectResourceCardPreviews', () => { const invoke = vi.fn(async (_: string, args?: Record) => preview(String(args?.relativePath ?? ''), 'video/mp4'), ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const canvasRef = { current: document.createElement('div') }; const { result, unmount } = renderHook(() => useProjectResourceCardPreviews({ @@ -676,7 +696,7 @@ describe('useProjectResourceCardPreviews', () => { const invoke = vi.fn(async (_: string, args?: Record) => preview(String(args?.relativePath ?? '')), ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const canvasRef = { current: document.createElement('div') }; const { result, rerender, unmount } = renderHook( (resources: ProjectResource[]) => @@ -766,7 +786,7 @@ describe('useProjectResourceCardPreviews', () => { pending.push({ path: String(args?.relativePath ?? ''), read }); return read.promise; }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const canvasRef = { current: document.createElement('div') }; const { result, rerender } = renderHook( (props: { @@ -858,13 +878,15 @@ describe('useProjectResourceCardPreviews', () => { it('preserves the preview identity, dimensions, and native scope for a mode-only change', async () => { const art = resource('mode-switch-art'); - const invoke = vi.fn(async (command: string) => { - if (command === 'cancel_local_project_resource_preview_scope') { - return undefined; - } - return preview(art.path); - }); - window.__TAURI__ = { core: { invoke } }; + const invoke = vi.fn( + async (command: string, _args?: Record) => { + if (command === 'cancel_local_project_resource_preview_scope') { + return undefined; + } + return preview(art.path); + }, + ); + installTauriRuntime({ core: { invoke } }); const canvasRef = { current: document.createElement('div') }; const { result, rerender, unmount } = renderHook( (mode: 'dependency' | 'type') => @@ -876,7 +898,8 @@ describe('useProjectResourceCardPreviews', () => { canvasRef, eagerPreviewLimit: 1, }), - { initialProps: 'dependency' as const }, + // 标注为 union:`as const` 会把 renderHook 的 Props 收窄成单个字面量,rerender('type') 报错。 + { initialProps: 'dependency' as 'dependency' | 'type' }, ); const firstIdentity = result.current.identityByResourceId.get(art.id)!; await waitFor(() => @@ -920,7 +943,7 @@ describe('useProjectResourceCardPreviews', () => { async (_command: string, args?: Record) => preview(String(args?.relativePath ?? art.path)), ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const canvasRef = { current: document.createElement('div') }; const { result, rerender } = renderHook( (previewVersion: string) => @@ -967,7 +990,7 @@ describe('useProjectResourceCardPreviews', () => { it('rejects an A-B-A late result even when scope and identity match again', async () => { const art = resource('aba-art'); const pending: DeferredPreview[] = []; - const invoke = vi.fn((command: string) => { + const invoke = vi.fn((command: string, _args?: Record) => { if (command === 'cancel_local_project_resource_preview_scope') { return Promise.resolve(); } @@ -975,7 +998,7 @@ describe('useProjectResourceCardPreviews', () => { pending.push(read); return read.promise; }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const canvasRef = { current: document.createElement('div') }; const { result, rerender } = renderHook( (props: { projectPath: string; projectId: string }) => @@ -1050,7 +1073,7 @@ describe('useProjectResourceCardPreviews', () => { async (_command: string, args?: Record) => preview(String(args?.relativePath ?? art.path)), ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const canvasRef = { current: document.createElement('div') }; const intersectionRoot = document.createElement('div'); const callbacks: IntersectionObserverCallback[] = []; @@ -1124,7 +1147,11 @@ describe('useProjectResourceCardPreviews', () => { target: card, isIntersecting: true, intersectionRatio: 1, - } as IntersectionObserverEntry, + boundingClientRect: card.getBoundingClientRect(), + intersectionRect: card.getBoundingClientRect(), + rootBounds: null, + time: 0, + }, ], {} as IntersectionObserver, ); @@ -1241,7 +1268,7 @@ describe('useProjectResourceCardPreviews', () => { async (_command: string, args?: Record) => preview(String(args?.relativePath ?? art.path)), ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); // root 在首次渲染时还没有挂上(等价于画本容器尚未就绪)。 const canvasRef: { current: HTMLDivElement | null } = { current: null }; const harness = installIntersectionObserver(); @@ -1306,7 +1333,7 @@ describe('useProjectResourceCardPreviews', () => { async (_command: string, args?: Record) => preview(String(args?.relativePath ?? '')), ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const canvasRef: { current: HTMLDivElement | null } = { current: null }; const harness = installIntersectionObserver(); @@ -1369,7 +1396,7 @@ describe('useProjectResourceCardPreviews', () => { async (_command: string, args?: Record) => preview(String(args?.relativePath ?? '')), ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const canvasRef = { current: document.createElement('div') }; const callbacks: IntersectionObserverCallback[] = []; @@ -1513,7 +1540,7 @@ describe('useProjectResourceCardPreviews', () => { async (_command: string, args?: Record) => preview(String(args?.relativePath ?? art.path)), ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const canvasRef: { current: HTMLDivElement | null } = { current: null }; const harness = installIntersectionObserver(); @@ -1556,7 +1583,7 @@ describe('useProjectResourceCardPreviews', () => { async (_command: string, args?: Record) => preview(String(args?.relativePath ?? art.path)), ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const canvasRef = { current: document.createElement('div') }; const harness = installIntersectionObserver(); @@ -1664,7 +1691,7 @@ describe('useProjectResourceCardPreviews', () => { const held = deferred>(); const invoke = vi.fn(async () => held.promise); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const canvasRef = { current: document.createElement('div') }; const { result, rerender } = renderHook( @@ -1744,7 +1771,7 @@ describe('useProjectResourceCardPreviews', () => { }); const held = deferred>(); const invoke = vi.fn(async () => held.promise); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const canvasRef = { current: document.createElement('div') }; const { result, rerender } = renderHook( @@ -1822,7 +1849,7 @@ describe('预览队列上限吞掉按需请求时的可观测性', () => { ); // 所有读取都挂住:队列只进不出,才能在用例里稳定压满。 const invoke = vi.fn(() => new Promise(() => undefined)); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const canvasRef = { current: document.createElement('div') }; const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); const { result } = renderHook(() => @@ -2090,7 +2117,7 @@ describe('冷启动首屏的放行范围与放行顺序', () => { async (_command: string, args?: Record) => preview(String(args?.relativePath ?? '')), ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(PreviewBandHarness, { @@ -2130,7 +2157,7 @@ describe('冷启动首屏的放行范围与放行顺序', () => { // 登记顺序故意先余量圈、后视口内:单趟入队会把这个顺序原样写进队列。 const resources = [...marginOnly, ...inViewport]; const invoke = vi.fn(() => new Promise(() => undefined)); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const harness = installRecordingIntersectionObserver(); const controllerRef: { current: PreviewController | null } = { current: null, @@ -2203,7 +2230,7 @@ describe('预览缓存驱逐必须避开可见卡', () => { async (_command: string, args?: Record) => preview(String(args?.relativePath ?? '')), ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const canvasRef = { current: document.createElement('div') }; const { result } = renderHook(() => @@ -2283,7 +2310,7 @@ describe('预览缓存驱逐必须避开可见卡', () => { const invoke = vi.fn(async (_: string, args?: Record) => preview(String(args?.relativePath ?? '')), ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const canvasRef = { current: document.createElement('div') }; const { result } = renderHook(() => useProjectResourceCardPreviews({ diff --git a/apps/ai-game-creator-shell/tests/useTemplateLibrary.test.tsx b/apps/ai-game-creator-shell/tests/useTemplateLibrary.test.tsx index 9cbda5a08..f1a941123 100644 --- a/apps/ai-game-creator-shell/tests/useTemplateLibrary.test.tsx +++ b/apps/ai-game-creator-shell/tests/useTemplateLibrary.test.tsx @@ -272,7 +272,7 @@ describe('模板库灰度会话', () => { setLauncherView, rememberRecentWorkspace, setStatus: vi.fn(), - setAgentChatProjectPath: vi.fn(), + showTransientNotice: vi.fn(), }); const library = useTemplateLibrary({ userId: 'user-a', diff --git a/apps/ai-game-creator-shell/tests/workbenchThemeContrast.test.ts b/apps/ai-game-creator-shell/tests/workbenchThemeContrast.test.ts index 8cde18820..d634040ef 100644 --- a/apps/ai-game-creator-shell/tests/workbenchThemeContrast.test.ts +++ b/apps/ai-game-creator-shell/tests/workbenchThemeContrast.test.ts @@ -32,17 +32,18 @@ function getCssBlock(source: string, selector: string) { function getCssVariable(block: string, variableName: string) { const match = block.match(new RegExp(`${variableName}:\\s*([^;]+);`)); expect(match, `${variableName} should exist`).not.toBeNull(); - return match![1].trim(); + return (match?.[1] ?? '').trim(); } function parseCssColor(source: string): Rgba { const value = source.trim(); const hex = value.match(/^#([\da-f]{6})$/i); if (hex) { + const hexValue = hex[1] ?? ''; return [ - Number.parseInt(hex[1].slice(0, 2), 16), - Number.parseInt(hex[1].slice(2, 4), 16), - Number.parseInt(hex[1].slice(4, 6), 16), + Number.parseInt(hexValue.slice(0, 2), 16), + Number.parseInt(hexValue.slice(2, 4), 16), + Number.parseInt(hexValue.slice(4, 6), 16), 1, ]; } @@ -51,7 +52,7 @@ function parseCssColor(source: string): Rgba { if (!functional) { throw new Error(`unsupported color: ${value}`); } - const parts = functional[1] + const parts = (functional[1] ?? '') .replace('/', ' ') .split(/[,\s]+/) .filter(Boolean); @@ -87,7 +88,11 @@ function contrastRatio(first: Rgba, second: Rgba) { ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; }); - return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722; + return ( + (channels[0] ?? 0) * 0.2126 + + (channels[1] ?? 0) * 0.7152 + + (channels[2] ?? 0) * 0.0722 + ); }; const firstLuminance = luminance(first); diff --git a/apps/ai-game-creator-shell/tests/workspaceLauncherManifestMerge.test.tsx b/apps/ai-game-creator-shell/tests/workspaceLauncherManifestMerge.test.tsx index ea48aa7f5..c703729f1 100644 --- a/apps/ai-game-creator-shell/tests/workspaceLauncherManifestMerge.test.tsx +++ b/apps/ai-game-creator-shell/tests/workspaceLauncherManifestMerge.test.tsx @@ -18,6 +18,7 @@ import { } from '../../../packages/shared/src/contracts/gameCreationApp'; import type { ProjectChatComponentProps } from '../src/features/app-shell/model'; import { WorkspaceLauncherShell } from '../src/features/app-shell/WorkspaceLauncher'; +import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; const PROJECT_PATH = '/tmp/manifest-merge-notice-project'; const PROJECT_ID = 'local-project-draft'; @@ -68,7 +69,7 @@ function manifestWithAsset(assetId: string | null): GameCreationAppManifest { value.assets = [ { id: assetId, - kind: 'upload', + kind: 'image', mediaType: 'image/png', localPath: `assets/uploads/${assetId}.png`, source: { kind: 'uploaded' }, @@ -83,7 +84,10 @@ let diskManifest = manifestWithAsset(null); function installInvokeMock() { const invoke = vi.fn( - async (command: string, args?: Record) => { + async ( + command: string, + args?: Record, + ): Promise => { switch (command) { case 'read_game_creator_app_config': return { config: { selectedModelId: 'quality' } }; @@ -123,7 +127,7 @@ function installInvokeMock() { } }, ); - window.__TAURI__ = { core: { invoke: invoke as never } }; + installTauriRuntime({ core: { invoke } }); return invoke; } @@ -231,7 +235,7 @@ describe('清单快照被拒收时的用户可见性与恢复', () => { afterEach(() => { cleanup(); - window.__TAURI__ = undefined; + resetTauriRuntime(); vi.restoreAllMocks(); }); diff --git a/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx b/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx index 49e76f169..208ceeecc 100644 --- a/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx +++ b/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx @@ -18,6 +18,7 @@ import { render, testAuthUser, } from './appSurface/harness'; +import { resetTauriRuntime } from './tauriRuntimeFake'; const homeProjectOverride = vi.hoisted(() => ({ openProject: null as @@ -78,7 +79,7 @@ it('真实窗口与工作台状态同步收敛,回调读取最新处理器且 ); - delete window.__TAURI__; + resetTauriRuntime(); const rendered = render(view()); try { await act(async () => { diff --git a/apps/ai-game-creator-shell/tsconfig.tests.json b/apps/ai-game-creator-shell/tsconfig.tests.json new file mode 100644 index 000000000..9b4e22b48 --- /dev/null +++ b/apps/ai-game-creator-shell/tsconfig.tests.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "allowImportingTsExtensions": true, + "allowJs": true, + "types": ["vite/client", "vitest/globals"] + }, + "include": ["src", "tests", "vite.config.ts"] +} diff --git a/deploy/container/nginx.conf b/deploy/container/nginx.conf index 0b8d4c958..f9dd352eb 100644 --- a/deploy/container/nginx.conf +++ b/deploy/container/nginx.conf @@ -157,7 +157,12 @@ http { try_files /index.html =404; } - location ~* "^/(?:creation|editor/canvas|profile|project|components|design-system|games|games/detail|games/mine|games/play|games/publish)/?$" { + location ~* "^/(?:creation|editor/canvas|pay|profile|profile/payment|project|components|design-system|games|games/detail|games/mine|games/play|games/publish)/?$" { + try_files $uri /index.html =404; + } + + # 收银台深链 `/pay/`:只放行裸前缀会让真实收银台链接落到默认 location 变 404。 + location ~* "^/pay/[^/]+/?$" { try_files $uri /index.html =404; } # END GENARRATIVE MAIN SPA ROUTES diff --git a/deploy/nginx/README.md b/deploy/nginx/README.md index c931fc687..e022e295c 100644 --- a/deploy/nginx/README.md +++ b/deploy/nginx/README.md @@ -107,4 +107,4 @@ curl -sSI -H 'Accept-Encoding: br' \ - 发行入口不使用 Cookie:边缘转发前设置 `proxy_set_header Cookie ""`;`api-server` 发行网关也会拒绝带 Cookie 的请求。响应头(`X-Content-Type-Options`、CORP、无凭据 CORS、HTML CSP、内容类型白名单与 `Cache-Control: public, max-age=60, must-revalidate`)由 `api-server` 发行网关设置,边缘不覆盖。 - 隔离靠 iframe 沙箱而不是独立来源:游戏文档跑在 `sandbox="allow-scripts"` 的不透明来源里,读不到主站 Cookie、storage 与 DOM,离开页面即随 iframe 卸载。 - 审核通过时 `api-server` 按 gameId 派生同源路径 `/games//` 作为 `entryUrl` 写入公开投影,部署侧不再需要配置发行域名。换版本或下架只改变后端公开投影,边缘不需要改配置。 -- 门禁:`npm run check:nginx-spa-routes` 校验三份模板的 SPA allowlist(含 `/games`、`/games/detail`、`/games/play`、`/games/mine`、`/games/publish`)。历史上的独立来源模板与专属门禁已随同源方案上线删除。 +- 门禁:`npm run check:nginx-spa-routes` 校验三份模板的 SPA allowlist(含 `/games`、`/games/detail`、`/games/play`、`/games/mine`、`/games/publish`、`/pay`、`/profile/payment`)与收银台深链前缀路由 `location ~* "^/pay/[^/]+/?$"`(`/pay/` 只放行「前缀 + 恰好一个路径段」;只放行裸前缀会让真实收银台链接落到默认 location 变 404),脚本自带正/反用例。历史上的独立来源模板与专属门禁已随同源方案上线删除。 diff --git a/deploy/nginx/genarrative-dev-http.conf b/deploy/nginx/genarrative-dev-http.conf index 49b76e619..e2879dff2 100644 --- a/deploy/nginx/genarrative-dev-http.conf +++ b/deploy/nginx/genarrative-dev-http.conf @@ -206,7 +206,19 @@ server { try_files /index.html =404; } - location ~* "^/(?:creation|editor/canvas|profile|project|components|design-system|games|games/detail|games/mine|games/play|games/publish)/?$" { + location ~* "^/(?:creation|editor/canvas|pay|profile|profile/payment|project|components|design-system|games|games/detail|games/mine|games/play|games/publish)/?$" { + error_page 503 /maintenance.html; + + if ($genarrative_maintenance) { + return 503; + } + + try_files $uri /index.html =404; + } + + # 收银台深链 `/pay/`:token 由前端从最后一个路径段读取(payment.rs 生成该链接), + # 只放行裸前缀会让真实收银台链接落到默认 location 变 404;这里只放行「/pay/ + 恰好一个路径段」。 + location ~* "^/pay/[^/]+/?$" { error_page 503 /maintenance.html; if ($genarrative_maintenance) { diff --git a/deploy/nginx/genarrative.conf b/deploy/nginx/genarrative.conf index ebf5d4520..15d8c4d04 100644 --- a/deploy/nginx/genarrative.conf +++ b/deploy/nginx/genarrative.conf @@ -234,7 +234,19 @@ server { try_files /index.html =404; } - location ~* "^/(?:creation|editor/canvas|profile|project|components|design-system|games|games/detail|games/mine|games/play|games/publish)/?$" { + location ~* "^/(?:creation|editor/canvas|pay|profile|profile/payment|project|components|design-system|games|games/detail|games/mine|games/play|games/publish)/?$" { + error_page 503 /maintenance.html; + + if ($genarrative_maintenance) { + return 503; + } + + try_files $uri /index.html =404; + } + + # 收银台深链 `/pay/`:token 由前端从最后一个路径段读取(payment.rs 生成该链接), + # 只放行裸前缀会让真实收银台链接落到默认 location 变 404;这里只放行「/pay/ + 恰好一个路径段」。 + location ~* "^/pay/[^/]+/?$" { error_page 503 /maintenance.html; if ($genarrative_maintenance) { diff --git a/deploy/pingora/nginx-route-parity.matrix.json b/deploy/pingora/nginx-route-parity.matrix.json index 1d3f8b993..7df7ff384 100644 --- a/deploy/pingora/nginx-route-parity.matrix.json +++ b/deploy/pingora/nginx-route-parity.matrix.json @@ -311,6 +311,20 @@ }, "docs": ["主站 SPA allowlist", "失败回退 `/index.html`"] }, + { + "id": "pay_checkout_spa_fallback", + "samplePath": "/pay/checkout-token", + "expect": { + "kind": "static", + "root": "web", + "mode": "spa_fallback" + }, + "nginx": { + "production": ["location ~* \"^/pay/[^/]+/?$\""], + "development": ["location ~* \"^/pay/[^/]+/?$\""] + }, + "docs": ["收银台深链 `/pay/`", "前缀 + 恰好一个路径段"] + }, { "id": "games_spa_fallback", "samplePath": "/games/detail", diff --git a/docs/README.md b/docs/README.md index 5857a7069..c27e5d9a5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -129,6 +129,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 b73503203..57c52c190 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -19,6 +19,22 @@ - 权威文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md` 的 `game_distribution_game` 节,以及 `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md` 的「游玩计数(已实现)」节。 - 验证:`cargo check -p api-server` 与 `cargo test -p api-server game_play_counter`(9 passed)通过;前端定向 vitest(点击上报断言 + clientId 稳定性)与 `eslint --max-warnings 0` 通过;`npm run check:server-rs-ddd`、`npm run check:generated-bindings`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check` 通过。 +## 2026-10-03 AGC 画布引用统一走「活跃聊天输入区」注册表(Issue 602) + +- 背景:画布的「引用」按钮与「拖拽批量引用」只派发 window 事件,消费者只有 `App.tsx` 一处,而它插的是绑在 `PlanningChatView` 上的 `chatComposerRef`;2026-09-22 DirectProject 拆分后普通项目走 `directProjectMode` 提前 return,渲染不到策划面 → ref 恒为 `null`,可选链静默吞掉点击(画布上是死按钮)。同一批合并冲突还丢了 `RESOURCE_REFERENCE_INSERT_MANY_EVENT` 的监听,批量引用连消费者都没有。 +- 决策:新增 `features/project-workspace/activeChatComposer.ts`,模块级只保存**当前挂载的那一个**输入区句柄(`registerActiveChatComposer` 返回带身份校验的注销函数,并检测到第二个输入区注册时留一条 dev 告警——不改运行时语义;`insertChatReferences` 在空批次 / 无输入区 / 句柄报「这一批没插进去」三种情况返回 `false`)。`DirectProjectComposer` 用 `useImperativeHandle` 暴露 `DirectProjectComposerHandle`(按 ref 转发、由它回答插入是否真的递到输入区),`DirectProjectChatView` 与 `PlanningChatView` 挂载期间各自注册**按 ref 转发**的句柄(注册时不读输入区是否就位,因此不依赖父子 effect 顺序)(两条链路互斥渲染,同一时刻只有一个句柄)。`App.tsx` 收敛为一处监听,单条 + 批量两个事件都走 `insertChatReferences`(空批次直接返回:没有要插的东西,不能报成「没有可用的输入区」);返回 `false` 时 dev 下 `console.warn`。`chatComposerRef` 只保留给策划输入盒自己的 `getDraft` / `clear`。 +- 边界:不采用「给 DirectProjectComposer 单独加 ref 出口 + App 按模式分流」的备选(那会把「哪个 ref 此刻是活的」继续留在检测点上)。插入仍经 `ResourceReferenceInput.insertReferences` + `focus()`(光标落在插入之后,连点两次按顺序追加)。真正根治的形态是画布与聊天的共同宿主用 context 下发插入能力;注册表语义与之一致,将来换实现不必动画布。 +- 影响范围:`apps/ai-game-creator-shell/src/features/project-workspace/activeChatComposer.ts`(新增)、`src/App.tsx`、`src/view/project-development/chat/DirectProjectChatView.tsx`、`.../chat/components/DirectProjectComposer/DirectProjectComposer.tsx`、`.../planning/PlanningChatView.tsx`、`tests/activeChatComposer.test.ts`(新增,钉注册表合同)、`tests/resourceCanvasChatReferenceDrop.test.tsx`、`tests/appSurface/{project-development,design-agent}.suite.ts`、`docs/【功能说明】AGC聊天素材引用-2026-09-08.md`、`pitfalls.md`、本文件。 +- 验证(合并 master 后的最终一轮):`npx vitest run apps/ai-game-creator-shell/tests`(197 passed / 1 skipped 文件,1918 passed / 17 skipped 用例)、`npx vitest run tests/activeChatComposer.test.ts tests/resourceCanvasChatReferenceDrop.test.tsx`(2 files / 12 passed,含注册表合同:空批次、无输入区、句柄报落空、注销身份校验、重复注册告警、乱序注销)、`npx vitest run tests/appSurface.test.ts -t 引用`(4 passed)、`npm run agc:typecheck`(含 `check:tests:types`,exit 0)、`npm run check:encoding`、`git diff --check`、eslint `--max-warnings 0`(改动文件)。反向证伪:去掉注册调用后端到端用例变红;去掉空批次短路 / 重复注册告警后对应新用例各红一处。 + +## 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/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index c5aa747b5..c4e72194e 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -119,3 +119,7 @@ Gitea 缓存部署必须区分网络:runner 的 RPC 走 `gitea-runner-fetch-ga AGC Rust 两条 lane、crates、smoke、Backend 和桌面壳测试使用镜像内可信 sccache 对象快照;Native shell release step 显式清空双 wrapper,前端/repository checks 不启用。仅首次人工 bootstrap 时,维护者通过 `scripts/build-gitea-rust-cache.sh` 从远端 master 在限额、无宿主挂载的临时容器中按实际 cwd/profile/目标预热全部测试组,仅编译、不执行测试/应用;后端 workspace 与 spacetime-module 保持独立,AGC 的三个 cwd 入口之间清理预热 target,防止 fresh 判断漏产缓存键。最终镜像只追加 sccache、对象和来源元数据,不包含源码或 target。容量上限 4 GiB,不替代宿主旧镜像/归档清理。PR 只写当前容器层、不回传,不开放 Docker API/发布权限;继续禁用 incremental。`ci-rust-cache.sh` 在快照缺失、工具链不符或 wrapper 探测失败时直接编译,并隔离远程缓存配置和 daemon。分片日志记录编译耗时,收尾输出命中统计;两个 lane 的测试和前置检查不同,耗时差不是严格 A/B。线上存在活跃 CI 时不得重启 runner 或切换标签;全组启用前须刷新完整快照并逐组验证,详见开发运维文档。 `.gitea/workflows/project-ci.yml` 的客户端门禁拆成 lane 与功能 job,每个 job 只预热自己会构建的那几份依赖:`AI game creator shell Rust lane 1/2`、`lane 2/2` 各自预取一次 AGC 壳 manifest,并顺序运行两片 Rust bin 单测;`AI game creator shell Rust smoke` 同样只预取 AGC 壳 manifest(`agent-run` smoke 会用 `src-tauri/Cargo.toml` spawn `cargo run`),`AI game creator shell Rust crates` 预取 `server-rs/Cargo.toml` 与独立 crate,`Native shell tests` 预取桌面壳与 AGC 壳 manifest,`AI game creator shell web tests` 不触碰 Cargo,不预热。两条 Rust lane 与 smoke job 必须在编译前通过 `scripts/ci-npm-ci-with-retry.sh` 执行根 `npm ci`:AGC 壳的 `build.rs` 会从 `node_modules` 准备 Claude Agent SDK 与目标平台原生运行时,镜像中的 npm 下载缓存不能替代安装。人工 `scripts/build-gitea-rust-cache.sh` bootstrap 同样在首次编译 AGC 壳前安装 npm 依赖;只有不构建壳的 crates job 继续省略 npm 安装。两个被 `server-rs/Cargo.toml` 排除、且没有提交 `Cargo.lock` 的独立 crate(`agent-runtime-core`、`agent-runtime-orchestration`)只能在 `AI game creator shell Rust crates` 里用不带锁标志的 fetch。AGC 壳的 bin target 单测(约 2466 条)由 `apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs` 编译后按 `--list` 名单分 4 片:每次分片调用用 `--shard-index=` 只跑自己那片,片内保持 `--test-threads=1` 并使用独立 `TMPDIR`;两条 lane 之间并发,lane 内顺序运行两片,避免重复依赖预热和同一容器内多进程争抢。不要改回「一个 job 内多进程并行这几片」——同一容器里它们会争抢共享 `HOME`、target 目录与固定临时路径,实测比整套串行还慢。每个分片调用都会自校验「片并集等于全集且互斥」,因此改分片规则不会静默漏跑。Backend host workspace tests 使用 `cargo test --locked --workspace --exclude spacetime-module --no-fail-fast`,避免 `spacetime-module` 的 `spacetime-types` feature 统一污染普通领域 crate 的 host 测试;随后单独执行 `cargo test --locked -p spacetime-module --no-fail-fast`,由 `spacetime-module/src/active.rs` 在 host 测试构建期间提供仅测试期的 SpacetimeDB ABI 链接支持,使该 crate 的纯单元测试也纳入 Backend 门禁。`spacetime-module` 的 reducer / procedure 运行时行为仍必须通过真实 SpacetimeDB runtime/integration harness 验证,host 链接支持不得被当作运行时替身。Backend 另外执行 `cargo check --locked -p spacetime-module` 验证模块源码。AGC 壳检查还会运行 `platform-llm` 与 `shared-contracts` 的 server-rs workspace 测试,这些命令以及 AGC 壳测试必须带 `--locked`,避免在测试阶段重新解析 registry index;锁文件发生变化时应先更新受信任 CI 镜像缓存,再重跑门禁。 + +## AGC 测试类型门禁 + +AGC 测试(`apps/ai-game-creator-shell/tests/`)与生产 `src/` 同受类型门禁。`apps/ai-game-creator-shell/tsconfig.tests.json` 以 `include: ["src", "tests", "vite.config.ts"]`、`types: ["vite/client", "vitest/globals"]`、`allowImportingTsExtensions`、`allowJs` 全量覆盖:新增测试文件自动纳入,不存在基线、豁免名单或 `@ts-nocheck`。入口 `npm --prefix apps/ai-game-creator-shell run check:tests:types`(`tsc -p tsconfig.tests.json --noEmit`)已并入该 app 的 `typecheck`,因此 `npm run agc:typecheck`、`npm run ai-game-creator-shell:check:web` 与 CI 的 `AI game creator shell web tests` 都会执行。修测试只做类型层改动,不改断言与行为;禁止 `@ts-ignore`、`as any`,仅当 mock 泛型函数确实不可兼容时才允许带中文注释的 `as unknown as`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 521c0a27b..8ffa666dc 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2,6 +2,22 @@ 这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。 +## 2026-10-03 AGC 画布「引用」死按钮:window 事件的消费者挂在一个只在另一条链路赋值的 ref 上 + +- **现象**(Issue 602):AGC 资源画布选中一张已登记素材,选中工具条点「引用」(图标 `@`、可见文案与 `title` 都是「引用」)没有任何反应——聊天输入框里不出现 `@素材名` 芯片,也没有任何提示。普通项目(`directProjectMode`)必现,立项策划项目(`planningStartMode`)复现不出来;把素材卡拖到对话栏的批量引用同样没反应。 +- **原因**:画布侧只 `dispatchResourceReferenceInsert` / `dispatchResourceReferenceInsertMany`,而这两个 window 事件的**唯一**消费者是 `App.tsx` 里的 `chatComposerRef.current?.insertReferences(...)`,`chatComposerRef` 又只赋给 `PlanningChatView`;2026-09-22 DirectProject 拆分引入的 `directProjectMode` 提前 return 让普通项目整段跳过后面的策划面渲染 → ref 恒为 `null`,可选链把整次调用静默吞掉。同一批合并冲突还把 2026-09-21 新加的 `RESOURCE_REFERENCE_INSERT_MANY_EVENT` 监听整段丢掉,批量引用连消费者都没有。 +- **处理(现行口径)**:`features/project-workspace/activeChatComposer.ts` 保存当前挂载的**那一个**输入区句柄(`registerActiveChatComposer` 返回带身份校验的注销函数 / `insertChatReferences`),`DirectProjectChatView` 与 `PlanningChatView` 挂载期间各自注册(两条链路互斥,同一时刻只有一个句柄);`App.tsx` 收敛为一处监听,单条与批量都走 `insertChatReferences`,返回 `false`(空批次 / 此刻没有输入区)时 dev 下 `console.warn`;`chatComposerRef` 只留给策划输入盒自己的 `getDraft` / `clear`。 +- **判据/取证**:`npm run test -- apps/ai-game-creator-shell/tests` 里的 `resourceCanvasChatReferenceDrop.test.tsx`、`appSurface/project-development.suite.ts`(工具条「引用」)、`appSurface/design-agent.suite.ts`(策划链路)都断言**真实输入盒草稿**里出现 `[data-resource-reference-id=""]`,不再是「事件被派发」;临时去掉注册调用后这三条会红,证明用例钉的是真链路。详见 [`【功能说明】AGC聊天素材引用-2026-09-08`](../../【功能说明】AGC聊天素材引用-2026-09-08.md) 文首一节。 +- **边界**:只要还保留「window 事件 + 模块外 ref 约定」这种形态,新增聊天面就必须一起进注册表;更彻底的形态是画布与聊天的共同宿主(`ProjectDevelopmentView`)用 context 下发插入能力,注册表语义与它一致,将来换实现不必动画布。 + +## 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 终态失败视觉等价。 @@ -4145,7 +4161,7 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 现象:target 注册了 SIGTERM 清理逻辑,但 `command.terminate` 只偶尔出现 stopped marker;耗时 300-500ms 的清理经常被提前截断。 - 原因:如果先向 wrapper/bwrap/trampoline/target 共用的外层进程组发送 SIGTERM,wrapper 会先退出,bwrap 的 die-with-parent 随即收走 namespace;名义上的 800ms 宽限并没有真正留给 target。 - 处理:process-session target 在 child pre-exec 内暂时屏蔽 SIGTTOU,完成 setpgid + PTY slave tcsetpgrp 并恢复信号掩码后才 exec;不能先 spawn 到后台组再由 parent 设前台,否则 target 可能已经因 immediate read 收到 SIGTTIN。Runtime 通过两级私有控制通道请求 trampoline 只向 target group 发 SIGTERM。direct leader 退出后 trampoline 继续检查同组后代,外层 wrapper/bwrap 在最多 800ms 宽限期保持存活,超时才强杀 containment group。reader 发现未换行输出超过上限时必须先原子投影 `output-limit-exceeded` 并唤醒 poll,再异步发送终止控制,不能让高负载下的 supervisor 调度延迟把已越界进程继续暴露为 `running`。 -- 验证:使用直接 bash target 启动同组后台子进程;leader 在输出 READY 后自然退出,仍存活的子进程收到 TERM 后由 trap 延迟 400ms 写 marker 并退出,terminate 返回前 marker 必须存在。正式 `command.exec` 测试夹具仍必须走允许的 `npm run` 等程序,不能为了构造 stdin race 绕过白名单直接解析 `bash -lc`。另跑 immediate stdin/EOF、Runner owner SIGKILL 和后代隔离用例,确认前台切组没有破坏交互或 fail-closed 回收;测试互斥锁在前序 panic 后应恢复 guard 继续报告后续独立结果,不能用 `PoisonError` 掩盖真实失败范围。 +- 验证:使用直接 bash target 启动同组后台子进程;leader 打印 READY 后用 `wait` 保持存活直到 terminate 真正到达(leader 若自己先退出,客户端调度就被拖进 800ms 宽限窗口,见 2026-10-04「graceful terminate 断言」条),仍存活的子进程收到 TERM 后由 trap 延迟 400ms 写 marker 并退出,terminate 返回前 marker 必须存在。正式 `command.exec` 测试夹具仍必须走允许的 `npm run` 等程序,不能为了构造 stdin race 绕过白名单直接解析 `bash -lc`。另跑 immediate stdin/EOF、Runner owner SIGKILL 和后代隔离用例,确认前台切组没有破坏交互或 fail-closed 回收;测试互斥锁在前序 panic 后应恢复 guard 继续报告后续独立结果,不能用 `PoisonError` 掩盖真实失败范围。 - 关联:`apps/ai-game-creator-shell/src-tauri/src/process_session.rs`、`process_session_bridge.rs`、`command_sandbox_trampoline.rs`。 ## 启动记录必须封闭状态组合,child 不能自行猜 durable commit 超时 @@ -6280,7 +6296,47 @@ 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 都显示成「执行通道未能建立或已断开」。 +- **根因 7(内部 Claude MCP 桥误用外部只读模式)**:Claude Code sidecar 使用的 loopback MCP 原本调用 `start_external_client_tool_bridge(..., false)`,桥状态 `direct_turn_execution=false` 且没有 `begin_user_turn()` 授权;`agc_register_delivery_contract`、`agc_delivery_status`、`agc_update_plan` 每次都会返回 `ToolRequiresDirectTurn`,模型收到错误后又重复注册计划,最终陷入反馈死循环直到超时。 +- **根因 8(MCP 全局单槽位)**:外部 MCP 注册表原本只有一个 `Option`,不同项目的 Claude 回合会互相 abort;一个回合结束时的全局 stop 还可能误停另一个项目的桥。 +- **现行口径**: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 分别复用上游、超时、空响应和反序列化语义;上游状态摘要与重试建议按状态码给出。内部 Claude Direct MCP 必须使用 `direct_turn_execution=true` 的工具桥并持有 `begin_user_turn()` guard;用户手动启动的外部 MCP 仍保持非 Direct 模式。MCP 注册表按 canonical project root 分桶,停止操作再核对 server token,迟到的旧回合不能误停同项目的新桥。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`。 + +## 2026-10-03 AGC 测试不在任何 tsconfig 里时,`satisfies` / `vi.fn<(...)>` 这类类型断言是空写 + +- **现象**:`apps/ai-game-creator-shell/tests/` 从未进过任何 tsconfig,vitest 0.34 用 esbuild 转译不做类型检查,`satisfies TurnFailure`、`vi.fn<(input: X) => void>()` 全部静默通过。把 tests 挂进 `tsconfig.tests.json` 后一次暴露 683 条既有错误(99 个文件、约 47% 的测试文件),单个文件最多 73 条。 +- **根因 1(配置缺口)**:app `tsconfig.json` 的 `include` 只有 `["src","vite.config.ts"]`;根 `typecheck` 只跑 `tsconfig.typecheck-guardrails.json` 的根 `src` 白名单;CI `agc-web` lane 只跑 vitest。三处都不覆盖 app tests。 +- **根因 2(vitest 0.34 口径)**:本仓库固定 `vitest@0.34.6`,其 `fn` 收的是**参数元组**;写成 vitest 1.x 习惯的 `vi.fn<(input: X) => void>()` 会把函数类型当作 `TArgs`,报 `TS2344`(constraint `any[]`)并连带 `TS7053`/`TS2493`。正确口径是 `vi.fn<[X], R>()` 或直接 `vi.fn(impl)`;`.mock.calls[0]` 在 `noUncheckedIndexedAccess` 下需要非空断言。 +- **根因 3(脚本 `.mjs`)**:测试 import `../scripts/*.mjs`,`allowJs: false` 报 `TS7016`;打开 `allowJs` 后 TS 按 JS 默认值推导出过窄签名(回调参数、`spawn`/`spawnSync` 重载、`never[]`、`null`),需要在测试里按运行时真实契约声明局部签名。 +- **根因 4(过期桩)**:62 条 `TS2339/TS2353/TS2739/TS2741` 是测试桩与当前真实类型脱节(字段被删除/改名/新增必填),例如 `setAgentChatProjectPath`、`conversationKey`、`runId`、`supervisor`→`chat`。 +- **现行口径**:见 `development-workflow.md` 的「AGC 测试类型门禁」;tests 必须 0 error,不引入基线或豁免,只改类型层。 +- **环境提示**:Node 24+ 默认启用实验性 Web Storage,全局 `localStorage` 未配置即 `undefined`,会顶掉 vitest 0.34 jsdom 环境里的 Storage,`recentProjectsHook.test.tsx`、`gameDistributionPublish.test.ts` 在 Node 26 上失败(HEAD 即如此)。项目按 `@types/node ^22.14` 面向 Node 22,本机用 fnm 装 v22.23.3 并设为 default(`~/.configure/profile.d/fnm.sh` 在 shell 启动时 `eval "$(fnm env)"`),Node 22 不暴露该全局、jsdom 的 localStorage 正常,仓库无需任何改动。不要用 `--localstorage-file=…` 绕:那只是把 Node 自己的文件型 Storage 顶上来,多个用例文件共享同一份状态。 +- **关联**:`apps/ai-game-creator-shell/tsconfig.tests.json`、`apps/ai-game-creator-shell/package.json`、`apps/ai-game-creator-shell/tests/`。 + +## 2026-10-04 tracing 的 callsite interest 是进程级缓存:并发测试会把 span 调用点缓存成 never,span 看起来"根本没产生" + +- **现象**:`app::tests::http_tracing::unavailable_router_rejection_keeps_generated_context_and_headers` 偶发 `each rejected request should have one HTTP span left: 0 / right: 1`(`server-rs/crates/api-server/src/app.rs`),同一族断言在 `server-rs/crates/platform-llm/src/observability_tests.rs` 偶发 `provider_spans.len() == 1` 失败。同一批代码时而绿时而红,且失败用例都是最早跑的一批。 +- **原因**:`span!`/`info_span!` 宏在调用点缓存 interest 为 `never` 时**静默返回空 span**,连 `new_span` 都不会调用(tracing 0.1.44 `macros.rs` 的 `span!` 分支)。而 `DefaultCallsite` 的 interest **只在调用点首次被命中时算一次**,且计算时用 `DISPATCHERS.rebuilder()`——进程里只注册过一个 dispatcher 时它会退化成 `dispatcher::get_default()`,即**命中线程自己的 dispatcher**(tracing-core 0.1.36 `callsite.rs` 的 `Rebuilder::JustOne`)。libtest 默认并发跑同一二进制里的上千个用例,没有 subscriber 的测试线程一旦抢到 `http.request` / `llm.request` 调用点的首次注册,就会把它永久缓存成 `never`。`with_subscriber` 只在**每次 poll** 设线程本地 dispatcher,纠正不了这个进程级缓存,于是"span 没产生"。 +- **处理(现行口径)**:测试采集不要依赖 `with_subscriber`。改为在整个被测流程期间持有 scoped default(`tracing::subscriber::set_default`,其内部 `Dispatch::new` 会触发 tracing 重建 interest 缓存),并用同一调用点预热探测到连续两轮采集成功为止;测试 subscriber 显式实现 `register_callsite`(目标 span 恒 `always`、其余 `sometimes`),避免自己的重建把其它调用点永久标记成 `never`。**不要**把断言改成"允许 0 个 span",**不要** sleep 赌时序。 +- **验证**:`cargo test --locked -p api-server --bin api-server app::tests::http_tracing`(默认并发与 `--test-threads=1` 各连跑 20 次)、`cargo test -p platform-llm observability_tests`;更接近 CI 并发的是整段 `app::tests::`(91 用例同进程)与 `--skip bgfilter_worker --skip wallet_refund_outbox` 的全量 bin(1133 用例)连跑。 +- **关联**:`server-rs/crates/api-server/src/app.rs`、`server-rs/crates/platform-llm/src/observability_tests.rs`。 + +## 2026-10-04 AGC 通知计数与 graceful terminate 的断言偶发都来自"跨线程 / 跨用例串台" + +- **现象**:`agent::thread_manager::tests::active_turn_changes_publish_one_notification_per_real_change` 偶发 `left: 8 / right: 7`(进度内容变化必须通知一次);`process_session::tests::process_session_graceful_terminate_keeps_wrapper_alive_for_target_cleanup` 偶发 `left: "exited" / right: "terminated"`;两者都在 `AI game creator shell Rust lane 2/2` 分片里红。 +- **原因 1(通知计数串台)**:测试计数器 `DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT` 在 *2026-10-01 已按线程作用域隔离*(`thread_local! Cell`),但 2026-10-02 退役 `runtime_driver` 把这段接缝搬进 `agent/direct_events.rs` 时**降级回进程级 `static AtomicU64`**。`--test-threads=1` 只串行测试线程,宿主 `tauri::async_runtime` 的后台回合仍在自己的工作线程上广播「运行中的项目」变了,于是断言取到别的回合的广播。 +- **原因 2(terminate 竞速)**:测试命令里 leader 打印 READY 后立刻 `exit 0`,同组后代仍存活,trampoline 从 leader 被回收那一刻开始 `PROCESS_SESSION_TARGET_TERMINATE_GRACE_MS=800ms` 宽限;客户端只要在 leader 退出后 >800ms 才发出 terminate(CI 高负载下要跨 durable record 写盘、registry 注册、线程 spawn),会话已按 `exited` 收口,terminate 只能读到既成事实——不是产品缺陷,是测试赌了客户端调度。 +- **处理(现行口径)**:①测试专用的通知计数必须留在测试线程作用域(`thread_local! Cell`),不要用进程级 Atomic;②graceful terminate 用例的 leader 打印 READY 后要用 `wait` 等后台子进程,让 terminate 必然落在会话仍 running 时(断言、trap、`sleep 0.4`、marker 名字都不改)。 +- **验证**:①修复前把计数器临时改回 Atomic 时同一并行口径 42/50 红;修复后并行 50 次 0 红、`--test-threads=1` 200 次 0 红、CI 现场等价块(145 用例)3 次 0 红;②该用例是 `#[cfg(target_os = "linux")]`,Windows 本机跑不到,用真实 Linux 内核(WSL Alpine)验证命令形状:leader 活到 TERM、同组后代完成 400ms 延迟清理(marker=done,real 0.41s)、清理后组内零残留;CI 侧仍应跑 `node apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs --shards=4 --shard-index=4` 复核。 +- **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/direct_events.rs`、`apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs`、`command_sandbox_trampoline.rs`。 + +## 2026-10-04 SPA 深链的前缀路由(`/pay/`)必须进 allowlist,裸前缀不够 + +- **现象**:只把 `/pay`、`/profile/payment` 加进 Nginx SPA allowlist 让门禁变绿,并不代表真实收银台链接能打开。`payment.rs` 生成的 `checkoutUrl` 是 `/pay/`,三份模板原先只有 `location ~* "^/(?:…|pay|profile|profile/payment|…)/?$"` 这条精确 location,深链落回默认 `location /` 的 `try_files $uri $uri/ =404` → **404**。 +- **原因/代价**:前端 `resolveSelectionStageFromPath` 用 `startsWith('/pay/')` 判定并取最后一个路径段当 token,Nginx / Pingora 侧却只放行裸前缀(2026-10-03 的支付接入 commit 只改了前端路由源)。同一批漂移里还有一条被掩盖的失败:`check:nginx-spa-routes` 在 `npm run lint` 链里先跑,它红的时候看不到后面的 `check:pingora-route-parity` 也红(Pingora `MAIN_SPA_PATHS` 缺 `/pay`、`/profile/payment`)——修一条门禁时要把整条链跑到底,不要只看第一个红。 +- **处理(现行口径)**:前缀路由的真相源是 `src/routing/activeAppPageRoutes.ts` 的 `APP_PREFIX_ROUTE_ENTRIES`。`scripts/check-nginx-spa-routes.mjs` 据此要求三份模板都写锚定前缀 location(`location ~* "^/pay/[^/]+/?$"`,只放行「前缀 + 恰好一个路径段」,裸前缀仍由精确 location 负责,并要求镜像精确 location 的维护闸);`check:pingora-route-parity` 要求 Rust 的 `MAIN_SPA_PREFIX_PATHS` 与 `is_main_spa_prefix_path` 同口径(大小写不敏感、多段与 `/payment/x` 这类同名邻居不收)。 +- **别踩**:不要写成裸前缀正则(`^/pay`)——它会吞掉 `/payment/x`、`/paycheckout/x` 这类同名邻居;也不要把深链塞进精确 allowlist 的 alternatives 里(`pay` 的 alternatives 只匹配 `/pay`)。 +- **判据/取证**:`node --test scripts/check-nginx-spa-routes.test.mjs`(正/反用例,含「写回精确匹配即红」)、`npm run check:nginx-spa-routes`、`npm run check:pingora-route-parity`、`cargo test -p pingora-gateway -- pay_checkout_deep_link matches_nginx_route_parity_matrix`;线上复验 `curl -s -o /dev/null -w '%{http_code}' https://<平台域名>/pay/` → 200 且正文与 `/` 同一份 `index.html`。 +- **关联**:`scripts/check-nginx-spa-routes.mjs`、`deploy/pingora/nginx-route-parity.matrix.json`、`server-rs/crates/pingora-gateway/src/main.rs`、`server-rs/crates/api-server/src/payment.rs`、`deploy/nginx/genarrative.conf`。 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/【开发运维】Pingora独立网关试点-2026-06-11.md b/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md index a29e55722..e56f73911 100644 --- a/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md +++ b/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md @@ -63,7 +63,7 @@ npm run check:pingora-release-readiness `check:pingora-gateway-smoke` 会临时启动 mock `api-server`、mock SpacetimeDB、mock Gitea 和 `pingora-gateway`,覆盖精确主站 SPA fallback、大小写与尾部斜杠兼容、同前缀未知路径真实 404、后台静态路由、HTML / 普通静态资源 `no-cache`、Vite 指纹静态资源 immutable 缓存、静态 `ETag` / `Last-Modified` 与 `304` 协商缓存、静态 `HEAD` 响应、静态 Range、静态 access log method/path/status 对账、gzip 最小长度、小响应不压缩、图片资源不压缩、大响应压缩、ACME、TLS 直连、HTTP/2 ALPN、HTTP 到 HTTPS 重定向、内部路由拒绝、shadow probe、API 代理头(`Host` / `X-Forwarded-Host` / `X-Forwarded-Proto` / `X-Real-IP` / `X-Forwarded-For`)、Gitea Host 整站转发、请求体上限、429 接流保护、上游断连 / 超时 JSON 错误、维护模式、维护模式不拦截 Gitea Host 和 SpacetimeDB WebSocket Upgrade,并复用 `check-pingora-direct-live.mjs` 对临时 HTTPS / HTTP redirect / WSS subscribe 入口做 live smoke。该本地 fixture 会让首页同时引用普通静态资源和 Vite 指纹静态资源,direct live JSON 必须确认指纹资源 GET / HEAD / `Range: bytes=0-0` 以及 access log method/path/status 证据,避免正式直连前只证明普通静态读取。排查失败时可追加 `-- --verbose` 输出网关 stderr / stdout;已确认二进制无需重编时可追加 `-- --skip-build`。 -`check:nginx-spa-routes` 从 `appPageRoutes.ts` 的 `STAGE_ROUTE_ENTRIES` / `APP_RUNTIME_ROUTES`、`appRoutes.tsx` 的精确路由判断和兼容恢复路径 `/creation/rpg/agent` 提取当前主站 SPA allowlist,确认生产、开发和容器三套 Nginx 模板集合一致,并验证大小写、尾部斜杠和 `/creation/not-exist`、`/runtime/not-exist`、`/puzzle/not-exist` 等未知反例。 +`check:nginx-spa-routes` 从 `appPageRoutes.ts` 的 `STAGE_ROUTE_ENTRIES` 与 `APP_PREFIX_ROUTE_ENTRIES`、`appRoutes.tsx` 的精确路由判断和兼容恢复路径 `/creation/rpg/agent` 提取当前主站 SPA allowlist,确认生产、开发和容器三套 Nginx 模板集合一致,并验证大小写、尾部斜杠、前缀路由的「前缀 + 恰好一个路径段」锚定形状(收银台深链 `/pay/` 必须整体回退 `index.html`,只放行裸前缀会让真实链接落到默认 location 变 404)和 `/creation/not-exist`、`/runtime/not-exist`、`/puzzle/not-exist` 等未知反例。该脚本自带正/反用例(`node --test scripts/check-nginx-spa-routes.test.mjs`,由 `npm run check:nginx-spa-routes` 一起执行),防止有人把前缀路由改回精确匹配。 `check:pingora-route-parity` 会先执行同一 Nginx SPA 路由门禁,再读取 `deploy/pingora/nginx-route-parity.matrix.json`,静态确认生产 / 开发 Nginx 模板、Pingora Rust 路由 allowlist / 单测和本文档都覆盖同一组核心路由,并做**反向覆盖**(模板里的每条 `location` 都必须被矩阵声明)。`cargo test -p pingora-gateway --manifest-path server-rs/Cargo.toml matches_nginx_route_parity_matrix` 会读取同一份矩阵,逐条断言 `classify_path` 的路由结果、body limit 和接流保护分组。`check:nginx-spa-routes` 与 `check:pingora-route-parity` 已串进 `npm run lint`(因此 `check:repository-ci`、CI 与 pre-push 都会执行),接线本身由 `check:production-ops` 的 guardrail 锁定。 @@ -535,7 +535,8 @@ dev 根盘空间在安装后曾接近满盘;2026-06-17 进入 canary 前已清 | `/v1/database/{db}/subscribe`、`/v1/identity*` | 转发到 SpacetimeDB,保留 WebSocket Upgrade 头。 | | `/__genarrative_pingora/healthz` | 仅在携带 `X-Genarrative-Pingora-Probe` 且匹配配置 token 时返回 shadow JSON,否则 404。 | | `/v1/*`、`/generated-*`、`/healthz*`、`/readyz*` | 返回 404,保持生产公网不暴露口径。 | -| 主站 SPA allowlist | 只对 `/`、`/components`、`/creation`、`/design-system`、`/editor/canvas`、`/games`、`/games/detail`、`/games/mine`、`/games/play`、`/games/publish`、`/profile`、`/project` 失败回退 `/index.html`(集合与前端路由源、Nginx 三份模板逐条一致,由 `npm run check:pingora-route-parity` 与 `cargo test -p pingora-gateway matches_nginx_route_parity_matrix` 比对);匹配大小写不敏感并允许一个尾部斜杠,HTML 默认 `no-cache`。`/games/game_<32 位十六进制 id>/…` 是发行网关路由,不在 SPA allowlist 内。 | +| 主站 SPA allowlist | 只对 `/`、`/components`、`/creation`、`/design-system`、`/editor/canvas`、`/games`、`/games/detail`、`/games/mine`、`/games/play`、`/games/publish`、`/pay`、`/profile`、`/profile/payment`、`/project` 失败回退 `/index.html`(集合与前端路由源、Nginx 三份模板逐条一致,由 `npm run check:pingora-route-parity` 与 `cargo test -p pingora-gateway matches_nginx_route_parity_matrix` 比对);匹配大小写不敏感并允许一个尾部斜杠,HTML 默认 `no-cache`。`/games/game_<32 位十六进制 id>/…` 是发行网关路由,不在 SPA allowlist 内。 | +| 主站 SPA 前缀路由 | 收银台深链 `/pay/` 走 `MAIN_SPA_PREFIX_PATHS`:只放行「前缀 + 恰好一个路径段」(大小写不敏感),裸前缀由上面的精确集合负责,多段路径与 `/payment/x` 这类前缀同名邻居都不进 SPA fallback;Nginx 三份模板同口径写成 `location ~* "^/pay/[^/]+/?$"`,由矩阵的 `pay_checkout_spa_fallback` 用例固定。 | | 其它 Web 路径 | 只读取真实静态文件或目录 index,缺失时返回真实 404;`/creation/not-exist`、`/runtime/not-exist`、`/puzzle/not-exist` 不进入 SPA fallback。 | SPA allowlist 里属于游戏分发入口的深链(游戏目录 / 详情 / 游玩 / 我的 / 发布深链:`/games`、`/games/detail`、`/games/play`、`/games/mine`、`/games/publish`)与 Nginx 三份模板同口径;Pingora 侧由路由对照矩阵的 `games_spa_fallback` 用例与 `cargo test -p pingora-gateway matches_nginx_route_parity_matrix` 逐条断言。根路径 `/` 精确回退 `/index.html`(Nginx 在 `location = /` 里用 `try_files /index.html =404;`,不带 `$uri`),由矩阵的 `web_root_spa` 用例固定。发行网关路径 `/games/game_<32 位十六进制 id>/…` 不走 SPA,见下一节的对照说明。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 4faeabece..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,13 +188,15 @@ 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,不能把宿主凭据目录交给模型。 - 第一轮先明确本次必需玩法、素材和验收项。同批独立读取尽量合并,必需图片一次规划;已有且可用的资产复用。已有目标全部通过后给出交付结果,非阻塞的新点子列为后续工作,不在收尾时主动开启新的生产链。 ### 分层验证与预算 - 复用客户端浏览器与现有固定玩法场景。视觉检查采集双端画面/布局/资源/诊断;玩法检查分别在 desktop/mobile 执行明确的固定场景和真实输入,按视口保存结果。旧报告缺少移动端玩法结果时保持未知,不补通过。报告必须声明检查层级;视觉通过不能宣称玩法通过,固定场景通过也不能宣称覆盖未执行的完整关卡。 +- 浏览器工具回执在 `mode=gameplay` 时新增 `gameplayResults` 双端有界投影,每个视口包含 `viewport`、`passed`、`diagnostics`、`assertions` 及 `initialPhase` / `initialSequence` / `initialLevel`、`finalPhase` / `finalSequence` / `finalLevel`。`assertions` 仅列当前固定场景的断言名称和通过状态;每端最多公开 4 条诊断,每条最多 512 字符。缺少某端结果时仍返回该视口 `passed=false`,诊断说明证据缺失,断言为空且六个状态字段为 `null`;断言的 `passed=false` 可包含未执行,不能据此断言该断言已独立执行且失败。`mode=visual` 的 `gameplayResults` 为空,并在摘要中明确玩法未执行。摘要从同一投影给出每端首条诊断和首个未通过断言,不能与结构化结果相矛盾。工具回执不得展开完整宿主报告;`reportPath` 只作宿主证据引用。宿主报告结构、验收门禁、持久化预算和私有路径保护保持原合同,旧回执不回填新字段。 +- 回执验收覆盖 start 禁用、点击后 phase 不符合和玩法成功;逐视口核对持久报告与摘要中的状态、首条诊断和首个未通过断言,并检查成功、失败两条最终 MCP 回包路径。 - 输入或碰撞改变先做定点玩法检查;纯图像/颜色变化做视觉检查;首次交付和影响闭环的修改做所需玩法验证。新增失败或相关代码变化才重跑对应层,不因改说明文字重复完整验证。 - 内置试玩和客户端托管的外部 Node/npm 验证共用当前 clientTurnId 的持久化预算。客户端分配递增执行序号,模型提供的旧 attempt 仅作兼容输入,不能减少计数或重置预算;同一轮错误反馈、工具切换和进程重启均不能刷新已消费次数。 - 新增本地 validation.maxRuns(默认 3,正整数)独立于 llm.maxRetries;显式配置原样使用,不按角色或运行模式改写。超限直接返回已用/上限和最近证据,停止新的验证。预检与正常构建不计作重复试玩。 @@ -329,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/【功能说明】AGC聊天素材引用-2026-09-08.md b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md index 2591f9d63..6718d6e2c 100644 --- a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md +++ b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md @@ -1,9 +1,31 @@ # AGC 聊天素材引用 -更新时间:2026-09-23 +更新时间:2026-10-03 AGC 聊天输入框支持以结构化引用标记当前项目已登记素材,并提供 Codex 风格的 Skill 提及。输入 `@` 会按素材名称、资源 ID 和类型过滤候选项;输入 `$` 会按当前 DirectProject 可用 Skill 名称过滤候选项;也可以点击输入框右侧的 `@` 按钮打开素材选择面板。 +## 画布引用落到哪份输入区:活跃聊天输入区注册表(2026-10-03) + +资源画布的「引用」按钮与「拖拽批量引用」都只做一件事:派发 window 自定义事件(`RESOURCE_REFERENCE_INSERT_EVENT` / `RESOURCE_REFERENCE_INSERT_MANY_EVENT`)。**消费者只有 `App.tsx` 一处**,事件本身不携带「插到哪个输入盒」——那由注册表回答: + +- `apps/ai-game-creator-shell/src/features/project-workspace/activeChatComposer.ts` 用模块级变量保存**当前挂载的那一个**输入区句柄(`{ insertReferences(refs), focus() }`)。 +- `DirectProjectChatView`(普通项目)与 `PlanningChatView`(立项策划)挂载期间各自注册、卸载注销;两条链路互斥渲染,所以同一时刻只有一个句柄。注册的是**按 ref 转发**的句柄、且不在注册时读输入区是否就位,所以挂载顺序与子组件重挂载都不会让注册表漏挂或指向死句柄。 +- `App.tsx` 的单条与批量两个监听都调 `insertChatReferences(refs)`;返回 `false` 的三种情况(空批次、没有挂载中的输入区、注册表里的句柄报「这一批没插进去」)都不静默,dev 下 `console.warn` 留一行线索;只有真的插进去了才把焦点交给输入区。 +- `chatComposerRef` 只留给策划输入盒自己的提交(`getDraft` / `clear`),不再承担跨面板插入。 + +这是 2026-09-22 DirectProject 拆分后的回归修复(issue #602):当时 `composerRef={chatComposerRef}` 只剩策划面一处,而 `directProjectMode` 的提前 return 让普通项目永远走不到那条赋值,`chatComposerRef.current?.insertReferences(...)` 的可选链把整次调用静默丢掉;同一批合并冲突还把 2026-09-21 新加的批量监听整段丢了,拖拽批量引用连监听者都没有。两条现在都由上面这一处收口。 + +这条链路由以下用例守住(都渲染真实聊天面,断言草稿 DOM 而不是「事件被派发」): + +| 契约 | 用例 | +| --- | --- | +| 工具条「引用」(键盘 + 鼠标两条通路)落进 DirectProject 草稿,光标留在插入之后 | `apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts` 的「工具条里的「引用」把素材 @ 进真实聊天草稿」 | +| 拖拽批量引用整批一次落进草稿、顺序 = 拖动集合顺序、零坐标写入 | `apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx` | +| 未登记素材不出「引用」按钮、拖到对话栏只给原因 | `tests/resourceCanvasChatReferenceDrop.test.tsx` 的「未登记素材」用例、`tests/resourceCardReferenceDropModel.test.ts` | +| 注册表自身合同:空批次 / 无输入区 / 句柄报落空 / 注销身份校验 / 重复注册留线索 | `apps/ai-game-creator-shell/tests/activeChatComposer.test.ts` | +| 空批次事件不误报成「没有可用的聊天输入区」 | `apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx` 的「空批次引用事件」用例 | +| 策划链路(`PlanningChatView`)不回归 | `apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts` 的「画布派发的「引用」落进策划输入盒草稿」 | + ## 引用来源由宿主注入(2026-09-22) `ResourceReferenceInput` 只接受宿主注入的一组「引用 provider」(`ReferenceProvider`,每种引用一个独立工厂): @@ -60,7 +82,7 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材, - 拖动期间落点会铺一层虚线框与「松手即可 @ 引用 N 项素材」提示;拖回画布内松手仍是原来的排版语义(写手动坐标),两条语义由落点决定。 - 只认**已登记到 manifest** 的素材,引用身份、来源标记 `resource-card` 与「引用」按钮逐字一致,所以同一素材两处进来是同一枚引用(去重键也一样);一条都引用不了时(素材都未登记)用提示条说明原因。 -- 一次松手只派发一次批量事件(`RESOURCE_REFERENCE_INSERT_MANY_EVENT`),草稿只重建一次、插入顺序即拖动集合顺序。 +- 一次松手只派发一次批量事件(`RESOURCE_REFERENCE_INSERT_MANY_EVENT`),由 `App.tsx` 的监听一次性交给当前挂载的输入区(见文首「活跃聊天输入区注册表」),草稿只重建一次、插入顺序即拖动集合顺序。2026-09-22 的合并冲突曾把这条监听整段丢掉(事件无消费者),2026-10-03 修复时补回。 ## 附件进入正文(2026-09-22) @@ -83,7 +105,8 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材, - 支持搜索、类型筛选和多选; - 素材芯片可插入、编辑和删除; - 资源画布支持把资源卡拖到对话栏批量引用(2026-09-21,见上一节); -- 资源画布素材卡的选中工具条提供「引用」入口:图标本身就是 `@`,可见文案与 `title` 都只写「引用」,插入对话里的仍是 `@素材名` 芯片; +- 资源画布素材卡的选中工具条提供「引用」入口:图标本身就是 `@`,可见文案与 `title` 都只写「引用」,插入对话里的仍是 `@素材名` 芯片;未登记素材(`manifestAssetId: null`)不渲染这枚按钮,拖到对话栏时落点提示与提示条给出「还没登记为项目资源」的原因; +- 普通项目(DirectProject)与立项策划两条链路都由 `App.tsx` 那一处监听 + 活跃聊天输入区注册表把引用落进当前挂载的输入盒草稿(2026-10-03 修复,见文首); - 运行画面提供“点选素材”,可选中 HTML 区域并生成 `runtime-region` 引用; - 提交请求携带 canonical user message item; - Rust 按 manifest 二次校验、持久化 canonical item,并生成 Codex wire input; diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 591466c54..9ace24f5a 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-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 9c0acccb8..73b6b7d87 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -748,10 +748,12 @@ Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分 - 门禁: ```bash -# SPA 白名单 + 三份 nginx 模板一致性(含 games 系列路由) +# SPA 白名单 + 三份 nginx 模板一致性(含 games 系列路由与收银台深链前缀路由) npm run check:nginx-spa-routes ``` +线上/预发复验收银台深链时,除了 `npm run check:nginx-spa-routes`,还要用真实请求确认 `/pay/` 返回 SPA 外壳而不是 404(`curl -s -o /dev/null -w '%{http_code}' https://<平台域名>/pay/` 应为 200,正文与 `/` 同一份 `index.html`)。`payment.rs` 生成的 `checkoutUrl` 就是这个路径,只把 `/pay` 加进 allowlist 会让真实链接落到默认 location 变 404。 + 本地想在真实边缘语义下复验时,把 `deploy/nginx/genarrative.conf` 的证书路径与 `/var/log/nginx` 换成临时目录,用 `nginx -c <临时 wrapper>` 起一个临时实例,再用 `curl --resolve <平台域名>:443:127.0.0.1 https://<平台域名>/games//` 验证:入口文档 200 `text/html`、`/games//assets/*` 200、未知 gameId 404,平台 API 与 SPA 路由不受影响。 #### 游戏分发可观测事件 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/package.json b/package.json index a5001d91c..ca24fab4d 100644 --- a/package.json +++ b/package.json @@ -99,7 +99,7 @@ "check:production-api-deploy": "node scripts/check-production-api-deploy.mjs", "check:pingora-gateway-smoke": "node scripts/check-pingora-gateway-smoke.mjs", "check:nginx-pingora-canary": "node scripts/check-nginx-pingora-canary.mjs", - "check:nginx-spa-routes": "node scripts/check-nginx-spa-routes.mjs", + "check:nginx-spa-routes": "node --test scripts/check-nginx-spa-routes.test.mjs && node scripts/check-nginx-spa-routes.mjs", "check:pingora-route-parity": "node scripts/check-pingora-route-parity.mjs", "check:pingora-canary-live": "node scripts/check-pingora-canary-live.mjs", "check:pingora-canary-live-guard": "node scripts/check-pingora-canary-live-guard.mjs", 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/scripts/check-nginx-spa-routes.mjs b/scripts/check-nginx-spa-routes.mjs index 7a1411443..be4868bcb 100644 --- a/scripts/check-nginx-spa-routes.mjs +++ b/scripts/check-nginx-spa-routes.mjs @@ -1,9 +1,13 @@ #!/usr/bin/env node import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; const APP_PAGE_ROUTES_PATH = 'src/routing/activeAppPageRoutes.ts'; const APP_ROUTES_PATH = 'src/routing/activeAppRoutes.tsx'; +const APP_PREFIX_ROUTE_ENTRIES_PATTERN = + /const APP_PREFIX_ROUTE_ENTRIES = \[([\s\S]*?)\] as const/u; const COMPATIBILITY_ROUTES = []; const NGINX_PATHS = [ 'deploy/nginx/genarrative.conf', @@ -86,7 +90,92 @@ function compareRouteSets(actualRoutes, expectedRoutes, label) { } } -function validateNginxRoutes(nginxPath, expectedRoutes) { +/** + * 前缀路由在 Nginx 里的锚定形状:前缀 + 恰好一个路径段 + 一个可省略的尾部斜杠。 + * 裸前缀自身由 SPA allowlist 的精确 location 负责,这里不重复放行,也不放宽到多段路径。 + */ +export function buildPrefixRouteNginxPattern(prefix) { + const escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); + return `^${escapedPrefix}/[^/]+/?$`; +} + +/** 校验前缀路由的放行形状;返回失败原因列表(空数组代表通过)。 */ +export function collectPrefixRoutePatternFailures(pattern, prefix) { + const failures = []; + const expected = buildPrefixRouteNginxPattern(prefix); + if (pattern !== expected) { + failures.push( + `前缀路由 ${prefix} 的 Nginx 放行形状必须锚定为 ${expected}(前缀 + 恰好一个路径段 + 可省略的尾部斜杠),实际为 ${pattern}。`, + ); + return failures; + } + const matcher = new RegExp(pattern, 'iu'); + for (const sample of [ + `${prefix}/checkout-token`, + `${prefix.toUpperCase()}/CheckOutToken/`, + ]) { + if (!matcher.test(sample)) { + failures.push(`前缀路由形状 ${pattern} 未匹配深链: ${sample}`); + } + } + for (const sample of [ + prefix, + `${prefix}/`, + `${prefix}/two/segments`, + `${prefix}suffix/segment`, + ]) { + if (matcher.test(sample)) { + failures.push(`前缀路由形状 ${pattern} 错误接收非深链路径: ${sample}`); + } + } + return failures; +} + +export function collectExpectedPrefixRoutes() { + const appPageRoutes = readFileSync(APP_PAGE_ROUTES_PATH, 'utf8'); + const entries = extractSourceBlock( + appPageRoutes, + APP_PREFIX_ROUTE_ENTRIES_PATTERN, + `${APP_PAGE_ROUTES_PATH} APP_PREFIX_ROUTE_ENTRIES`, + ); + const routes = Array.from( + entries.matchAll(/\[\s*'([^']+)'\s*,\s*'([^']+)'\s*\]/gu), + (match) => ({ path: match[1], stage: match[2] }), + ); + const uniqueRoutes = new Map(routes.map((route) => [route.path, route])); + for (const route of uniqueRoutes.values()) { + if (!/^\/(?:[a-z0-9-]+(?:\/[a-z0-9-]+)*)?$/u.test(route.path)) { + fail(`前端前缀路由源包含门禁暂不支持的路径格式: ${route.path}`); + } + } + return [...uniqueRoutes.values()].sort((left, right) => + left.path.localeCompare(right.path), + ); +} + +function findRegexLocationBody(block, pattern) { + for (const match of block.matchAll(/location\s+~\*\s+"([^"]+)"\s*\{/gu)) { + if (match[1] !== pattern) { + continue; + } + const openBrace = match.index + match[0].length - 1; + let depth = 0; + for (let index = openBrace; index < block.length; index += 1) { + if (block[index] === '{') { + depth += 1; + } else if (block[index] === '}') { + depth -= 1; + if (depth === 0) { + return block.slice(openBrace + 1, index); + } + } + } + return null; + } + return null; +} + +function validateNginxRoutes(nginxPath, expectedRoutes, prefixRoutes) { const source = readFileSync(nginxPath, 'utf8'); const blockStart = source.indexOf(SPA_BLOCK_START); const blockEnd = source.indexOf(SPA_BLOCK_END); @@ -140,6 +229,46 @@ function validateNginxRoutes(nginxPath, expectedRoutes) { } } + // 前缀路由(带动态段)必须有独立的锚定 location:只放行裸前缀会让真实深链落到默认 + // location 变成 404(例如收银台 `/pay/`)。 + const exactLocationBody = findRegexLocationBody(block, nginxPattern); + const maintenanceGuard = 'if ($genarrative_maintenance) { return 503; }'; + for (const { path: prefix } of prefixRoutes) { + if (!expectedRoutes.includes(prefix)) { + fail( + `${nginxPath} 前缀路由 ${prefix} 必须同时是精确路由:裸前缀自身也要能直达。`, + ); + continue; + } + const expectedPattern = buildPrefixRouteNginxPattern(prefix); + const prefixLocationBody = findRegexLocationBody(block, expectedPattern); + if (prefixLocationBody === null) { + fail( + `${nginxPath} 缺少前缀路由 ${prefix} 的锚定 location(期望 location ~* "${expectedPattern}")。`, + ); + continue; + } + for (const failure of collectPrefixRoutePatternFailures( + expectedPattern, + prefix, + )) { + fail(`${nginxPath} ${failure}`); + } + if (!prefixLocationBody.includes('try_files $uri /index.html =404;')) { + fail( + `${nginxPath} 前缀路由 ${prefix} 的 location 没有精确回退 index.html。`, + ); + } + if ( + exactLocationBody?.includes(maintenanceGuard) && + !prefixLocationBody.includes(maintenanceGuard) + ) { + fail( + `${nginxPath} 前缀路由 ${prefix} 的 location 必须与精确 SPA location 一样先判维护状态。`, + ); + } + } + const defaultLocation = source.slice(blockEnd + SPA_BLOCK_END.length); if (!defaultLocation.includes('try_files $uri $uri/ =404;')) { fail( @@ -218,20 +347,27 @@ function validateMaintenanceInternalBypass() { } export const expectedMainSpaRoutes = collectExpectedMainSpaRoutes(); +export const expectedPrefixRoutes = collectExpectedPrefixRoutes(); -for (const nginxPath of NGINX_PATHS) { - validateNginxRoutes(nginxPath, expectedMainSpaRoutes); -} -validateMaintenanceInternalBypass(); +const isMainModule = + process.argv[1] && + pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url; -if (failures.length > 0) { - console.error('[check:nginx-spa-routes] FAILED'); - for (const failure of failures) { - console.error(`- ${failure}`); +if (isMainModule) { + for (const nginxPath of NGINX_PATHS) { + validateNginxRoutes(nginxPath, expectedMainSpaRoutes, expectedPrefixRoutes); } - process.exit(1); -} + validateMaintenanceInternalBypass(); -console.log( - `[check:nginx-spa-routes] OK (${expectedMainSpaRoutes.length} SPA routes, ${NGINX_PATHS.length} Nginx templates)`, -); + if (failures.length > 0) { + console.error('[check:nginx-spa-routes] FAILED'); + for (const failure of failures) { + console.error(`- ${failure}`); + } + process.exit(1); + } + + console.log( + `[check:nginx-spa-routes] OK (${expectedMainSpaRoutes.length} SPA routes, ${expectedPrefixRoutes.length} prefix routes, ${NGINX_PATHS.length} Nginx templates)`, + ); +} diff --git a/scripts/check-nginx-spa-routes.test.mjs b/scripts/check-nginx-spa-routes.test.mjs new file mode 100644 index 000000000..09d492eb0 --- /dev/null +++ b/scripts/check-nginx-spa-routes.test.mjs @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { + buildPrefixRouteNginxPattern, + collectExpectedPrefixRoutes, + collectPrefixRoutePatternFailures, +} from './check-nginx-spa-routes.mjs'; + +test('前缀路由按「前缀 + 恰好一个路径段 + 可省略尾部斜杠」锚定,裸前缀交给精确 location', () => { + const pattern = buildPrefixRouteNginxPattern('/pay'); + assert.equal(pattern, '^/pay/[^/]+/?$'); + assert.deepEqual(collectPrefixRoutePatternFailures(pattern, '/pay'), []); + + const matcher = new RegExp(pattern, 'iu'); + assert.ok(matcher.test('/pay/checkout-token')); + assert.ok(matcher.test('/PAY/CheckOutToken/')); + assert.ok(!matcher.test('/pay')); + assert.ok(!matcher.test('/pay/')); + assert.ok(!matcher.test('/pay/two/segments')); + assert.ok(!matcher.test('/paycheckout/token')); + assert.ok(!matcher.test('/payment/token')); +}); + +test('把前缀路由写回精确匹配(只放行裸前缀)会被门禁拒绝', () => { + const failures = collectPrefixRoutePatternFailures('^/pay/?$', '/pay'); + assert.equal(failures.length, 1); + assert.match(failures[0], /必须锚定为 \^\/pay\/\[\^\/\]\+\/\?\$/u); +}); + +test('过宽的裸前缀形状(会吞掉同名邻居路径)也会被门禁拒绝', () => { + const failures = collectPrefixRoutePatternFailures('^/pay', '/pay'); + assert.equal(failures.length, 1); + assert.match(failures[0], /必须锚定为/u); +}); + +test('前缀路由真相源来自前端路由表且当前包含 /pay', () => { + const prefixRoutes = collectExpectedPrefixRoutes(); + assert.ok( + prefixRoutes.some((route) => route.path === '/pay'), + 'APP_PREFIX_ROUTE_ENTRIES 必须声明 /pay', + ); + for (const route of prefixRoutes) { + assert.match(route.path, /^\//u); + assert.notEqual(route.stage.trim(), ''); + } +}); diff --git a/scripts/check-pingora-route-parity.mjs b/scripts/check-pingora-route-parity.mjs index 1a083564b..49fee68bd 100644 --- a/scripts/check-pingora-route-parity.mjs +++ b/scripts/check-pingora-route-parity.mjs @@ -2,7 +2,10 @@ import { readFileSync } from 'node:fs'; -import { expectedMainSpaRoutes } from './check-nginx-spa-routes.mjs'; +import { + expectedMainSpaRoutes, + expectedPrefixRoutes, +} from './check-nginx-spa-routes.mjs'; const MATRIX_PATH = 'deploy/pingora/nginx-route-parity.matrix.json'; const PRODUCTION_NGINX_PATH = 'deploy/nginx/genarrative.conf'; @@ -289,10 +292,38 @@ function validateRustMainSpaRoutes() { } } +function validateRustMainSpaPrefixPaths() { + const prefixBlock = pingoraGatewaySource.match( + /const MAIN_SPA_PREFIX_PATHS: &\[&str\] = &\[([\s\S]*?)\];/u, + ); + if (!prefixBlock) { + fail('Pingora Rust 缺少 MAIN_SPA_PREFIX_PATHS allowlist。'); + return; + } + const rustPrefixes = Array.from( + prefixBlock[1].matchAll(/"([^"]+)"/gu), + (match) => match[1], + ); + const expected = expectedPrefixRoutes.map((route) => route.path); + const missing = expected.filter((prefix) => !rustPrefixes.includes(prefix)); + const extra = rustPrefixes.filter((prefix) => !expected.includes(prefix)); + if (missing.length > 0) { + fail( + `Pingora MAIN_SPA_PREFIX_PATHS 缺少当前前缀路由: ${missing.join(', ')}`, + ); + } + if (extra.length > 0) { + fail( + `Pingora MAIN_SPA_PREFIX_PATHS 包含非当前前缀路由: ${extra.join(', ')}`, + ); + } +} + validateMatrixShape(); validateRustTestUsesMatrix(); validateNginxLocationsAreCovered(); validateRustMainSpaRoutes(); +validateRustMainSpaPrefixPaths(); if (failures.length > 0) { console.error('[check:pingora-route-parity] FAILED'); 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..732e0d046 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), @@ -505,11 +510,12 @@ mod tests { use tracing::{ Metadata, Subscriber, field::{Field, Visit}, - instrument::WithSubscriber, span::{Attributes, Id, Record}, + subscriber::Interest, }; use super::*; + use crate::app::make_http_request_span; use crate::state::{BackpressureState, HttpRequestPermitPoolKind}; #[derive(Clone, Default)] @@ -529,6 +535,18 @@ mod tests { } impl Subscriber for HttpSpanCapture { + /// tracing 的 callsite interest 是**进程级**缓存,只在调用点首次命中时计算一次。 + /// 这里只为 HTTP span 声明 always:任何以本 subscriber 参与的重建都会让 + /// `http.request` 调用点保持可用;其余调用点返回 sometimes,交给 `enabled` + /// 在真正创建 span 时判断,避免把其它调用点永久标记成 never。 + fn register_callsite(&self, metadata: &Metadata<'_>) -> Interest { + if metadata.is_span() && metadata.name() == "http.request" { + Interest::always() + } else { + Interest::sometimes() + } + } + fn enabled(&self, metadata: &Metadata<'_>) -> bool { metadata.is_span() && metadata.name() == "http.request" } @@ -552,6 +570,41 @@ mod tests { fn exit(&self, _: &Id) {} } + fn http_span_warmup_request() -> Request { + Request::builder() + .uri("/__genarrative_http_span_interest_warmup__") + .body(Body::empty()) + .expect("warmup request should build") + } + + /// 让 `http.request` 调用点在本进程内稳定可用,而不是依赖调用点首次注册时命中线程的 dispatcher。 + /// + /// `DefaultCallsite::register` 只在调用点首次被命中时计算一次 interest,计算时会用 + /// `DISPATCHERS.rebuilder()`;当进程里只有一个 dispatcher 时它会退化成 + /// `dispatcher::get_default()`,也就是**命中线程自己的** dispatcher。并发跑测试时, + /// 没有 subscriber 的线程一旦抢到这次注册,`http.request` 调用点就会被永久缓存成 + /// `never`,之后 `span!` 宏会静默跳过 span 创建,测试便会观察到 0 个 span。 + /// + /// 这里不 sleep 赌时序:每轮都用 `set_default` 在我们的 subscriber 下安装 scoped default + /// (内部 `Dispatch::new` 会触发 tracing 重建 interest 缓存),并直接命中同一个 + /// `http.request` 调用点做探测;连续两轮都探测到 span 才认为缓存已经稳定。 + fn ensure_http_request_span_interest() { + let mut captured_rounds = 0; + for _ in 0..8 { + let probe = HttpSpanCapture::default(); + { + let _guard = tracing::subscriber::set_default(probe.clone()); + let _ = make_http_request_span(&http_span_warmup_request()); + } + let captured = !probe.0.lock().expect("span capture should lock").is_empty(); + captured_rounds = if captured { captured_rounds + 1 } else { 0 }; + if captured_rounds >= 2 { + return; + } + } + panic!("无法在测试 subscriber 下恢复 http.request 调用点的 interest 缓存"); + } + async fn assert_rejection_observed( app: Router, request: Request, @@ -562,11 +615,18 @@ mod tests { let expected_request_id = request.headers().get("x-request-id").cloned(); let path = request.uri().path().to_string(); let capture = HttpSpanCapture::default(); - let response = app - .oneshot(request) - .with_subscriber(capture.clone()) - .await - .expect("rejected request should complete"); + // 先把 http.request 调用点的进程级 interest 缓存校正到当前 subscriber(见上方注释), + // 再在整个请求期间持有 scoped default。注意 `set_default` 是**线程绑定**的:这里成立 + // 是因为本模块用的是默认 `#[tokio::test]`(current-thread 运行时,请求与断言线程一致); + // 一旦改成 multi_thread / spawn 到别的线程,必须换成 `with_current_subscriber()` 或 + // `with_subscriber`,否则请求会在没有 dispatcher 的线程上跑。 + ensure_http_request_span_interest(); + let response = { + let _guard = tracing::subscriber::set_default(capture.clone()); + app.oneshot(request) + .await + .expect("rejected request should complete") + }; assert_eq!(response.status(), expected_status); let request_id = response.headers()["x-request-id"] 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/external_mcp/semantic/tests.rs b/server-rs/crates/api-server/src/external_mcp/semantic/tests.rs index 02f47b42f..1983ff877 100644 --- a/server-rs/crates/api-server/src/external_mcp/semantic/tests.rs +++ b/server-rs/crates/api-server/src/external_mcp/semantic/tests.rs @@ -30,7 +30,10 @@ fn prepare( #[test] fn semantic_catalog_adds_fifteen_tools_without_replacing_legacy_tools() { assert_eq!(TOOLS.len(), 15); - assert_eq!(MCP_OPERATIONS.len(), 30); + // legacy 工具集合直接来自内置 OpenAPI(排除 5 条 x-mcp-excluded 元数据/入口操作)。 + // 支付收银台新增 createExternalPaymentOrder / getExternalPaymentOrder 后, + // 可调用操作由 30 增至 32:语义工具只做增量,不得替换 legacy 工具。 + assert_eq!(MCP_OPERATIONS.len(), 32); let legacy = MCP_OPERATIONS .iter() .map(mcp_operation_tool) @@ -52,7 +55,9 @@ fn semantic_catalog_adds_fifteen_tools_without_replacing_legacy_tools() { .is_some_and(|text| !text.is_empty()) ); } - assert_eq!(names.len(), 45); + // 37 条 OpenAPI 操作里 5 条元数据/入口操作被 x-mcp-excluded 排除,剩下 32 条 legacy 工具 + // 加 15 条语义工具共 47 条;上面的插入断言已保证两组名字互不覆盖,这里固定总数防止漏注册。 + assert_eq!(names.len(), 47); } #[test] diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 6f270f9d0..06dc057a1 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -65,6 +65,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; @@ -696,6 +699,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()); crate::game_play_counter_worker::spawn_game_play_counter_worker(state.clone()); if should_start_profile_recharge_expiration_listener(process_role) { 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/pingora-gateway/src/main.rs b/server-rs/crates/pingora-gateway/src/main.rs index 50e5bf74b..51df31e98 100644 --- a/server-rs/crates/pingora-gateway/src/main.rs +++ b/server-rs/crates/pingora-gateway/src/main.rs @@ -72,10 +72,17 @@ const MAIN_SPA_PATHS: &[&str] = &[ "/games/mine", "/games/play", "/games/publish", + "/pay", "/profile", + "/profile/payment", "/project", ]; +// 带动态段的前缀路由,必须与前端 `APP_PREFIX_ROUTE_ENTRIES` 以及三份 nginx 模板的 +// `location ~* "^/pay/[^/]+/?$"` 同口径:只放行「前缀 + 恰好一个路径段」,裸前缀由 +// `MAIN_SPA_PATHS` 负责;`npm run check:pingora-route-parity` 会逐条比对这份 allowlist。 +const MAIN_SPA_PREFIX_PATHS: &[&str] = &["/pay"]; + #[derive(Clone, Debug)] struct GatewayConfig { listen_addr: String, @@ -1955,6 +1962,21 @@ fn is_main_spa_path(path: &str) -> bool { MAIN_SPA_PATHS .iter() .any(|candidate| normalized.eq_ignore_ascii_case(candidate)) + || is_main_spa_prefix_path(normalized) +} + +/// 前缀路由(带动态段):`<前缀>/<恰好一个路径段>`,大小写不敏感(与 nginx `location ~*` 同口径)。 +/// 裸前缀、多段路径和前缀同名邻居(如 `/payment/x`)都不算命中。 +fn is_main_spa_prefix_path(normalized: &str) -> bool { + let mut segments = normalized.trim_start_matches('/').split('/'); + let (Some(first), Some(second), None) = (segments.next(), segments.next(), segments.next()) + else { + return false; + }; + !second.is_empty() + && MAIN_SPA_PREFIX_PATHS + .iter() + .any(|prefix| prefix.trim_start_matches('/').eq_ignore_ascii_case(first)) } fn is_maintenance_page_asset(path: &str) -> bool { @@ -3310,6 +3332,36 @@ mod tests { } } + #[test] + fn pay_checkout_deep_link_uses_main_spa_prefix_route() { + let spa_fallback = RouteDecision::Local(LocalResponse::Static { + root: StaticRoot::Web, + mode: StaticMode::SpaFallback, + }); + // 裸前缀由 MAIN_SPA_PATHS 精确命中,收银台深链 `/pay/` 由前缀路由命中, + // 两者都与 Nginx 的 `location ~* "^/pay/[^/]+/?$"` 同口径(大小写不敏感)。 + for path in [ + "/pay", + "/pay/", + "/PAY", + "/pay/checkout-token", + "/PAY/CheckOutToken/", + ] { + assert_eq!(classify_path(path), spa_fallback, "path: {path}"); + } + // 多段路径与前缀同名邻居不允许被吞进 SPA fallback。 + for path in ["/pay/two/segments", "/payment/token", "/paycheckout/token"] { + assert_eq!( + classify_path(path), + RouteDecision::Local(LocalResponse::Static { + root: StaticRoot::Web, + mode: StaticMode::Exact, + }), + "path: {path}" + ); + } + } + #[test] fn applies_configured_body_limit_to_generic_api_routes_only() { let mut generic_api = classify_path("/api/assets/history"); diff --git a/server-rs/crates/platform-llm/src/observability_tests.rs b/server-rs/crates/platform-llm/src/observability_tests.rs index 16cb87dfc..a166c9b7b 100644 --- a/server-rs/crates/platform-llm/src/observability_tests.rs +++ b/server-rs/crates/platform-llm/src/observability_tests.rs @@ -11,7 +11,6 @@ use tokio::sync::oneshot; use tracing::{ Instrument, Subscriber, field::{Field, Visit}, - instrument::WithSubscriber, span::{Attributes, Id, Record}, }; use tracing_subscriber::{Layer, layer::Context, prelude::*, registry::LookupSpan}; @@ -222,8 +221,70 @@ fn request() -> LlmRunRequest { .with_model("requested-model") } +/// 在整段流程期间把 capture 固定为当前 dispatcher。 +/// +/// tracing 的 callsite interest 是**进程级**缓存,且只在调用点首次被命中时计算一次 +/// (`DefaultCallsite::register` → `DISPATCHERS.rebuilder()`;进程里只有一个 dispatcher 时会 +/// 退化成命中线程自己的 dispatcher)。并发跑测试时,本文件之外那些没有 subscriber 的测试线程 +/// 只要抢到 `llm.request` 调用点的首次注册,它就会被永久缓存成 `never`,`span!` 宏随后静默 +/// 跳过 span 创建,`provider_span` 便会看到 0 个 span。 +/// +/// 注意:`set_default` 与 `with_subscriber` 在 interest 缓存这件事上**等价**——两者都只是新建一个 +/// `Dispatch` 并触发一次 `rebuild_interest`,只能纠正“已经注册过”的调用点,纠正不了 +/// `Rebuilder::JustOne → get_default() → 缓存 never` 这条首次注册分支;真正把调用点救回来的是 +/// `warm_up_provider_span_callsite` 在自家 subscriber 下命中同一调用点(覆盖两种顺序, +/// 并且把 dispatcher 从“只在每次 poll 生效”变成整段流程生效)。 +async fn run_under_capture(capture: &Capture, future: F) -> F::Output { + // `set_default` 是线程绑定的:本文件用的是默认 `#[tokio::test]`(current-thread 运行时, + // 被测 future 与断言在同一条线程上)。若以后改成 multi_thread 或把流程 spawn 出去, + // 必须换回 `with_current_subscriber()` / `with_subscriber`,否则 span 会在没有 dispatcher 的线程上丢。 + let _guard = + tracing::subscriber::set_default(tracing_subscriber::registry().with(capture.clone())); + future.await +} + +/// 让 `llm.request` 调用点在本进程内稳定可用:用一个必然失败的最小请求(指向刚释放的 +/// loopback 端口,连接直接被拒绝)命中同一个调用点,直到连续两轮都能采集到 span 为止。 +/// 请求失败无妨——只要 `llm.request` span 被创建就说明调用点在我们的 subscriber 下注册成功。 +/// 不 sleep、不放宽断言。 +async fn warm_up_provider_span_callsite() { + let listener = TcpListener::bind("127.0.0.1:0").expect("warmup listener should bind"); + let address = listener + .local_addr() + .expect("warmup address should resolve"); + drop(listener); + let config = LlmConfig::new( + LlmProvider::OpenAiCompatible, + format!("http://{address}"), + PRIVATE_KEY.into(), + "default-model".into(), + 500, + 0, + 1, + ) + .expect("warmup config should build"); + let client = LlmClient::new(config).expect("warmup client should build"); + let mut captured_rounds = 0; + for _ in 0..8 { + let probe = Capture::default(); + { + let _guard = tracing::subscriber::set_default( + tracing_subscriber::registry().with(probe.clone()), + ); + let _ = client.run(request()).await; + } + let captured = !probe.spans.lock().expect("capture should lock").is_empty(); + captured_rounds = if captured { captured_rounds + 1 } else { 0 }; + if captured_rounds >= 2 { + return; + } + } + panic!("无法在测试 subscriber 下恢复 llm.request 调用点的 interest 缓存"); +} + #[tokio::test] async fn provider_span_covers_awaited_execution_and_keeps_parent_without_arguments() { + warm_up_provider_span_callsite().await; let fixture = provider_fixture( "200 OK", "application/json", @@ -231,7 +292,7 @@ async fn provider_span_covers_awaited_execution_and_keeps_parent_without_argumen ); let capture = Capture::default(); let response = - async { + run_under_capture(&capture, async { async { let mut future = Box::pin(fixture.client.run(request())); assert!(capture.spans.lock().unwrap().values().all(|span| span.name != "llm.request")); @@ -245,8 +306,7 @@ async fn provider_span_covers_awaited_execution_and_keeps_parent_without_argumen fixture.release.send(()).unwrap(); future.await.unwrap() }.instrument(tracing::info_span!("test.request")).await - } - .with_subscriber(tracing_subscriber::registry().with(capture.clone())) + }) .await; fixture.server.join().unwrap(); assert_eq!(response.text, "completed"); @@ -255,6 +315,7 @@ async fn provider_span_covers_awaited_execution_and_keeps_parent_without_argumen #[tokio::test] async fn stream_callbacks_inherit_provider_span_and_keep_result() { + warm_up_provider_span_callsite().await; let fixture = provider_fixture( "200 OK", "text/event-stream", @@ -263,7 +324,7 @@ async fn stream_callbacks_inherit_provider_span_and_keep_result() { let capture = Capture::default(); let mut deltas = Vec::new(); let response = - async { + run_under_capture(&capture, async { async { let mut future = Box::pin(fixture.client.stream_run(request(), |delta| { tracing::info!(target: "llm_observability_test_delta", "delta received"); @@ -276,8 +337,7 @@ async fn stream_callbacks_inherit_provider_span_and_keep_result() { fixture.release.send(()).unwrap(); future.await.unwrap() }.instrument(tracing::info_span!("test.request")).await - } - .with_subscriber(tracing_subscriber::registry().with(capture.clone())) + }) .await; fixture.server.join().unwrap(); assert_eq!(response.text, "hello"); @@ -291,21 +351,21 @@ async fn stream_callbacks_inherit_provider_span_and_keep_result() { #[tokio::test] async fn provider_span_preserves_upstream_errors_and_closes_on_cancellation() { + warm_up_provider_span_callsite().await; let fixture = provider_fixture( "401 Unauthorized", "application/json", r#"{"error":{"message":"upstream-rejected"}}"#, ); let capture = Capture::default(); - let error = async { + let error = run_under_capture(&capture, async { async { fixture.release.send(()).unwrap(); fixture.client.run(request()).await.unwrap_err() } .instrument(tracing::info_span!("test.request")) .await - } - .with_subscriber(tracing_subscriber::registry().with(capture.clone())) + }) .await; fixture.server.join().unwrap(); assert!( @@ -315,7 +375,7 @@ async fn provider_span_preserves_upstream_errors_and_closes_on_cancellation() { let fixture = provider_fixture("200 OK", "application/json", "{}"); let capture = Capture::default(); - async { + run_under_capture(&capture, async { async { let mut future = Box::pin(fixture.client.run(request())); tokio::select! { @@ -326,7 +386,8 @@ async fn provider_span_preserves_upstream_errors_and_closes_on_cancellation() { drop(future); fixture.release.send(()).unwrap(); }.instrument(tracing::info_span!("test.request")).await - }.with_subscriber(tracing_subscriber::registry().with(capture.clone())).await; + }) + .await; fixture.server.join().unwrap(); capture.assert_completed("run"); } 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 df8611394..db4068181 100644 --- a/server-rs/crates/spacetime-client/src/active.rs +++ b/server-rs/crates/spacetime-client/src/active.rs @@ -36,6 +36,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 51e1444f5..aa38eeb65 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; @@ -479,6 +486,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; @@ -512,6 +521,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; @@ -531,10 +543,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; @@ -628,6 +676,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; @@ -779,6 +828,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; @@ -800,6 +850,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; @@ -940,6 +991,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; @@ -948,6 +1000,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; @@ -958,6 +1011,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; @@ -975,6 +1029,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; @@ -1143,6 +1200,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; @@ -1285,6 +1343,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; @@ -1318,6 +1378,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; @@ -1337,10 +1400,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; @@ -1434,6 +1533,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; @@ -1585,6 +1685,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; @@ -1606,6 +1707,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; @@ -1778,6 +1880,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, @@ -2009,6 +2115,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)?, ), @@ -2420,6 +2538,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", @@ -2781,6 +2914,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)?), @@ -3055,6 +3200,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)?), @@ -3235,6 +3392,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>, @@ -3532,6 +3693,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, @@ -4427,6 +4604,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); @@ -4516,6 +4697,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..0c32ec9e9 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'], @@ -16,6 +18,16 @@ const STAGE_ROUTE_ENTRIES = [ ['game-publish', '/games/publish'], ] as const satisfies readonly (readonly [SelectionStage, string])[]; +/** + * 带动态段、需要整体回退 index.html 的对外路由前缀 → 归属 stage。 + * 例如收银台深链 `/pay/`(后端 `payment.rs` 用 `format!("/pay/{}", token)` 生成, + * 前端从最后一个路径段读 token)。Nginx 必须按「前缀 + 恰好一个路径段」的锚定形状放行: + * 只放行裸前缀会让真实收银台链接落到默认 location 变成 404,放行过宽又会把未知路径吞掉。 + */ +export const APP_PREFIX_ROUTE_ENTRIES = [ + ['/pay', 'payment-checkout'], +] as const satisfies readonly (readonly [string, SelectionStage])[]; + export const APP_STAGE_ROUTES: Record = Object.fromEntries(STAGE_ROUTE_ENTRIES) as Record; @@ -39,7 +51,13 @@ export function normalizeAppPath(pathname: string) { export function resolveSelectionStageFromPath( pathname: string, ): SelectionStage { - return ROUTE_STAGE_BY_PATH.get(normalizeAppPath(pathname)) ?? 'platform'; + const normalizedPath = normalizeAppPath(pathname); + for (const [prefix, stage] of APP_PREFIX_ROUTE_ENTRIES) { + if (normalizedPath.startsWith(`${prefix}/`)) { + return stage; + } + } + return ROUTE_STAGE_BY_PATH.get(normalizedPath) ?? 'platform'; } export function resolveInitialSelectionStageFromPath( @@ -69,7 +87,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 = {}, ) {