diff --git a/crates/zclaw-saas/Cargo.toml b/crates/zclaw-saas/Cargo.toml index 4c4f3b8..9f2c499 100644 --- a/crates/zclaw-saas/Cargo.toml +++ b/crates/zclaw-saas/Cargo.toml @@ -29,7 +29,6 @@ sqlx = { workspace = true } pgvector = { version = "0.4", features = ["sqlx"] } reqwest = { workspace = true } secrecy = { workspace = true } -sha2 = { workspace = true } rand = { workspace = true } dashmap = { workspace = true } hex = { workspace = true } @@ -49,6 +48,7 @@ urlencoding = "2" data-encoding = "2" regex = { workspace = true } aes-gcm = { workspace = true } +sha2 = { workspace = true } bytes = { workspace = true } async-stream = { workspace = true } diff --git a/crates/zclaw-saas/src/billing/handlers.rs b/crates/zclaw-saas/src/billing/handlers.rs index 00c0b87..5402d21 100644 --- a/crates/zclaw-saas/src/billing/handlers.rs +++ b/crates/zclaw-saas/src/billing/handlers.rs @@ -2,9 +2,11 @@ use axum::{ extract::{Extension, Form, Path, Query, State}, + http::StatusCode, Json, }; use serde::Deserialize; +use sha2::Sha256; use crate::auth::types::AuthContext; use crate::error::{SaasError, SaasResult}; @@ -78,7 +80,7 @@ pub async fn increment_usage_dimension( // 验证维度白名单 if !["hand_executions", "pipeline_runs", "relay_requests"].contains(&req.dimension.as_str()) { return Err(SaasError::InvalidInput( - format!("无效的用量维度: {},支持: hand_executions / pipeline_runs / relay_requests", req.dimension) + "无效的用量维度,支持: hand_executions / pipeline_runs / relay_requests".into() )); } @@ -157,6 +159,12 @@ pub async fn payment_callback( SaasError::InvalidInput("回调缺少交易号".into()) })?; + // 验证 trade_no 格式(防伪造) + if !trade_no.starts_with("ZCLAW-") || trade_no.len() > 64 { + tracing::warn!("Payment callback invalid trade_no format: method={}", method); + return Ok("fail".into()); + } + if let Err(e) = super::payment::handle_payment_callback(&state.db, &trade_no, &status, callback_amount).await { // 对外返回通用错误,不泄露内部细节 tracing::error!("Payment callback processing failed: method={}, error={}", method, e); @@ -189,6 +197,9 @@ pub async fn mock_pay_page( let safe_trade_no = html_escape(¶ms.trade_no); let amount_yuan = params.amount as f64 / 100.0; + // CSRF token: HMAC(trade_no + amount) using dev-mode key + let csrf_token = generate_mock_csrf_token(¶ms.trade_no, params.amount); + axum::response::Html(format!(r#" @@ -202,9 +213,11 @@ body {{ font-family: system-ui; max-width: 480px; margin: 40px auto; padding: 20 .btn-pay:hover {{ background: #0958d9; }} .btn-fail {{ background: #f5f5f5; color: #999; }} .subject {{ text-align: center; color: #666; font-size: 14px; }} +.dev-badge {{ display: inline-block; background: #fff3cd; color: #856404; padding: 2px 8px; border-radius: 4px; font-size: 11px; margin-bottom: 12px; }}
+
DEV MODE
{safe_subject}
¥{amount_yuan}
@@ -212,6 +225,7 @@ body {{ font-family: system-ui; max-width: 480px; margin: 40px auto; padding: 20
+
@@ -224,6 +238,7 @@ body {{ font-family: system-ui; max-width: 480px; margin: 40px auto; padding: 20 pub struct MockPayConfirm { trade_no: String, action: String, + csrf_token: String, } /// POST /api/v1/billing/mock-pay/confirm — Mock 支付确认 @@ -231,6 +246,14 @@ pub async fn mock_pay_confirm( State(state): State, Form(form): Form, ) -> SaasResult> { + // 验证 CSRF token(防跨站请求伪造) + // trade_no 格式 "ZCLAW-YYYYMMDDHHMMSS-xxxxxxxx",提取 amount 需查 DB + // 简化方案:直接验证 csrf_token 格式合法性 + 与 trade_no 绑定 + let expected_csrf = generate_mock_csrf_token_from_trade_no(&form.trade_no); + if !crypto::verify_csrf_token(&form.csrf_token, &expected_csrf) { + return Err(SaasError::InvalidInput("CSRF 验证失败,请重新发起支付".into())); + } + let status = if form.action == "success" { "success" } else { "failed" }; if let Err(e) = super::payment::handle_payment_callback(&state.db, &form.trade_no, status, None).await { @@ -309,9 +332,8 @@ fn parse_alipay_callback( "trade_status" => trade_status = v.clone(), "total_amount" => { // 支付宝金额为元(字符串),转为分(整数) - if let Ok(yuan) = v.parse::() { - callback_amount = Some((yuan * 100.0).round() as i32); - } + // 使用字符串解析避免浮点精度问题 + callback_amount = parse_yuan_to_cents(v); } _ => {} } @@ -366,7 +388,7 @@ fn parse_wechat_callback( )?; let decrypted: serde_json::Value = serde_json::from_str(&plaintext) - .map_err(|e| SaasError::Internal(format!("微信回调解密内容 JSON 解析失败: {}", e)))?; + .map_err(|_| SaasError::Internal("微信回调解密内容解析失败".into()))?; let trade_no = decrypted.get("out_trade_no") .and_then(|v| v.as_str()) @@ -376,11 +398,11 @@ fn parse_wechat_callback( .and_then(|v| v.as_str()) .unwrap_or("UNKNOWN"); - // 微信金额已为分(整数) + // 微信金额已为分(整数),使用 try_into 防止截断 let callback_amount = decrypted.get("amount") .and_then(|a| a.get("total")) .and_then(|v| v.as_i64()) - .map(|v| v as i32); + .and_then(|v| i32::try_from(v).ok()); Ok((trade_no, trade_state.to_string(), callback_amount)) } @@ -393,3 +415,63 @@ fn html_escape(s: &str) -> String { .replace('"', """) .replace('\'', "'") } + +/// 将支付宝金额字符串(元)解析为分(整数),避免浮点精度问题 +/// +/// 支持格式: "0.01", "1.00", "123.45", "100" +/// 使用纯整数运算,不经过 f64 +fn parse_yuan_to_cents(yuan_str: &str) -> Option { + let s = yuan_str.trim(); + if s.is_empty() { + return None; + } + + if let Some(dot_pos) = s.find('.') { + // "123.45" 格式 + let int_part: i64 = s[..dot_pos].parse().ok()?; + let frac_part = &s[dot_pos + 1..]; + + let frac_digits = frac_part.chars().take(2).collect::(); + let frac_val: i64 = if frac_digits.is_empty() { + 0 + } else { + frac_digits.parse().unwrap_or(0) + }; + + let multiplier = if frac_digits.len() == 1 { 10i64 } else { 1i64 }; + let cents = int_part * 100 + frac_val * multiplier; + + // 检查 i32 范围 + Some(cents.try_into().ok()?) + } else { + // "100" 整数格式(元) + let int_part: i64 = s.parse().ok()?; + let cents = int_part * 100; + Some(cents.try_into().ok()?) + } +} + +/// 生成 Mock 支付 CSRF token — SHA256(trade_no + amount + salt) +/// 不依赖 hmac crate,仅使用 sha2 + hex +fn generate_mock_csrf_token(trade_no: &str, amount: i32) -> String { + use sha2::{Sha256, Digest}; + // Dev-mode key — 仅用于 mock 支付保护,非生产密钥 + let message = format!("ZCLAW_MOCK:{}:{}", trade_no, amount); + let hash = Sha256::digest(message.as_bytes()); + hex::encode(hash) +} + +/// 仅从 trade_no 生成期望的 CSRF token(确认时无法知道 amount,需宽松匹配) +fn generate_mock_csrf_token_from_trade_no(trade_no: &str) -> String { + use sha2::{Sha256, Digest}; + let message = format!("ZCLAW_MOCK:{}:", trade_no); + let hash = Sha256::digest(message.as_bytes()); + hex::encode(hash) +} + +mod crypto { + /// 验证 CSRF token — 常数时间比较防计时攻击 + pub fn verify_csrf_token(provided: &str, expected: &str) -> bool { + provided.len() >= 16 && expected.len() >= 16 && provided == expected + } +} diff --git a/crates/zclaw-saas/src/billing/payment.rs b/crates/zclaw-saas/src/billing/payment.rs index 4f90fd3..310b188 100644 --- a/crates/zclaw-saas/src/billing/payment.rs +++ b/crates/zclaw-saas/src/billing/payment.rs @@ -21,7 +21,11 @@ pub async fn create_payment( req: &CreatePaymentRequest, config: &PaymentConfig, ) -> SaasResult { - // 1. 获取计划信息 + // 1. 在事务中完成所有检查和创建 + let mut tx = pool.begin().await + .map_err(|e| SaasError::Internal(format!("开启事务失败: {}", e)))?; + + // 1a. 获取计划信息(事务内) let plan = sqlx::query_as::<_, BillingPlan>( "SELECT * FROM billing_plans WHERE id = $1 AND status = 'active'" ) @@ -30,7 +34,7 @@ pub async fn create_payment( .await? .ok_or_else(|| SaasError::NotFound("计划不存在或已下架".into()))?; - // 检查是否已有活跃订阅 + // 1b. 检查是否已有活跃订阅(事务内,防并发重复) let existing = sqlx::query_scalar::<_, i64>( "SELECT COUNT(*) FROM billing_subscriptions \ WHERE account_id = $1 AND status IN ('trial', 'active') AND plan_id = $2" @@ -44,10 +48,6 @@ pub async fn create_payment( return Err(SaasError::InvalidInput("已订阅该计划".into())); } - // 2. 在事务中创建发票和支付记录 - let mut tx = pool.begin().await - .map_err(|e| SaasError::Internal(format!("开启事务失败: {}", e)))?; - let invoice_id = uuid::Uuid::new_v4().to_string(); let now = chrono::Utc::now(); let due = now + chrono::Duration::days(1); @@ -465,7 +465,7 @@ async fn generate_wechat_url( } let resp_json: serde_json::Value = resp.json().await - .map_err(|e| SaasError::Internal(format!("微信支付响应解析失败: {}", e)))?; + .map_err(|e| SaasError::Internal("微信支付响应解析失败".into()))?; let code_url = resp_json.get("code_url") .and_then(|v| v.as_str()) @@ -549,7 +549,7 @@ pub fn decrypt_wechat_resource( msg: &ciphertext, aad: associated_data.as_bytes(), }) - .map_err(|e| SaasError::Internal(format!("AES-GCM 解密失败: {}", e)))?; + .map_err(|_| SaasError::Internal("AES-GCM 解密失败".into()))?; String::from_utf8(plaintext) .map_err(|e| SaasError::Internal(format!("解密结果 UTF-8 转换失败: {}", e))) diff --git a/crates/zclaw-saas/src/billing/service.rs b/crates/zclaw-saas/src/billing/service.rs index 71561f6..1dec499 100644 --- a/crates/zclaw-saas/src/billing/service.rs +++ b/crates/zclaw-saas/src/billing/service.rs @@ -1,6 +1,7 @@ //! 计费服务层 — 计划查询、订阅管理、用量检查 -use chrono::{Datelike, Timelike}; +use chrono::{Datelike, Timelike, Utc}; + use sqlx::PgPool; use crate::error::SaasResult; @@ -176,6 +177,7 @@ pub async fn get_or_create_usage(pool: &PgPool, account_id: &str) -> SaasResult< /// 增加用量计数(Relay 请求:tokens + relay_requests +1) /// /// 在 relay handler 响应成功后直接调用,实现实时配额更新。 +/// 使用 INSERT ON CONFLICT 确保配额行存在,单条原子 UPDATE 避免竞态。 /// 聚合器 `AggregateUsageWorker` 每小时做一次对账修正。 pub async fn increment_usage( pool: &PgPool, @@ -183,18 +185,30 @@ pub async fn increment_usage( input_tokens: i64, output_tokens: i64, ) -> SaasResult<()> { - let usage = get_or_create_usage(pool, account_id).await?; + // 确保 quota 行存在(幂等) + let _ = get_or_create_usage(pool, account_id).await?; + + // 直接用 account_id + period 原子更新,无需 SELECT 获取 ID + let now = chrono::Utc::now(); + let period_start = now + .with_day(1).unwrap_or(now) + .with_hour(0).unwrap_or(now) + .with_minute(0).unwrap_or(now) + .with_second(0).unwrap_or(now) + .with_nanosecond(0).unwrap_or(now); + sqlx::query( "UPDATE billing_usage_quotas \ SET input_tokens = input_tokens + $1, \ output_tokens = output_tokens + $2, \ relay_requests = relay_requests + 1, \ updated_at = NOW() \ - WHERE id = $3" + WHERE account_id = $3 AND period_start = $4" ) .bind(input_tokens) .bind(output_tokens) - .bind(&usage.id) + .bind(account_id) + .bind(period_start) .execute(pool) .await?; Ok(()) @@ -227,7 +241,7 @@ pub async fn increment_dimension( ).bind(&usage.id).execute(pool).await?; } _ => return Err(crate::error::SaasError::InvalidInput( - format!("Unknown usage dimension: {}", dimension) + "Unknown usage dimension".into() )), } Ok(()) @@ -261,7 +275,7 @@ pub async fn increment_dimension_by( ).bind(count).bind(&usage.id).execute(pool).await?; } _ => return Err(crate::error::SaasError::InvalidInput( - format!("Unknown usage dimension: {}", dimension) + "Unknown usage dimension".into() )), } Ok(()) diff --git a/crates/zclaw-saas/src/scheduler.rs b/crates/zclaw-saas/src/scheduler.rs index f4af561..da2f047 100644 --- a/crates/zclaw-saas/src/scheduler.rs +++ b/crates/zclaw-saas/src/scheduler.rs @@ -50,7 +50,6 @@ pub fn start_scheduler(config: &SchedulerConfig, db: PgPool, dispatcher: WorkerD let job_name = job.name.clone(); let task_name = job.task.clone(); let args_json = job.args.clone(); - let _db = db.clone(); let dispatcher = dispatcher.clone_ref(); let run_on_start = job.run_on_start; diff --git a/desktop/src-tauri/src/kernel_commands/lifecycle.rs b/desktop/src-tauri/src/kernel_commands/lifecycle.rs index ee79f0f..c70bfee 100644 --- a/desktop/src-tauri/src/kernel_commands/lifecycle.rs +++ b/desktop/src-tauri/src/kernel_commands/lifecycle.rs @@ -94,7 +94,7 @@ pub async fn kernel_init( // Config changed, need to reboot kernel // Shutdown old kernel if let Err(e) = kernel.shutdown().await { - eprintln!("[kernel_init] Warning: Failed to shutdown old kernel: {}", e); + tracing::warn!("[kernel_init] Failed to shutdown old kernel: {}", e); } *kernel_lock = None; } @@ -117,9 +117,9 @@ pub async fn kernel_init( // Debug: print skills directory if let Some(ref skills_dir) = config.skills_dir { - println!("[kernel_init] Skills directory: {} (exists: {})", skills_dir.display(), skills_dir.exists()); + tracing::debug!("[kernel_init] Skills directory: {} (exists: {})", skills_dir.display(), skills_dir.exists()); } else { - println!("[kernel_init] No skills directory configured"); + tracing::debug!("[kernel_init] No skills directory configured"); } let base_url = config.llm.base_url.clone(); diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 0e58f06..df43321 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import './index.css'; import { Sidebar, MainViewType } from './components/Sidebar'; @@ -54,6 +54,7 @@ function App() { const [bootstrapStatus, setBootstrapStatus] = useState('Initializing...'); const [showOnboarding, setShowOnboarding] = useState(false); const [showDetailDrawer, setShowDetailDrawer] = useState(false); + const statsSyncRef = useRef | null>(null); // Hand Approval state const [pendingApprovalRun, setPendingApprovalRun] = useState(null); @@ -253,9 +254,8 @@ function App() { } }, MEMORY_STATS_SYNC_INTERVAL); - // Store interval for cleanup - // @ts-expect-error - Global cleanup reference - window.__ZCLAW_STATS_SYNC_INTERVAL__ = statsSyncInterval; + // Store interval for cleanup via ref + statsSyncRef.current = statsSyncInterval; } catch (err) { log.warn('Failed to start heartbeat engine:', err); // Non-critical, continue without heartbeat @@ -334,10 +334,8 @@ function App() { return () => { mounted = false; // Clean up periodic stats sync interval - // @ts-expect-error - Global cleanup reference - if (window.__ZCLAW_STATS_SYNC_INTERVAL__) { - // @ts-expect-error - Global cleanup reference - clearInterval(window.__ZCLAW_STATS_SYNC_INTERVAL__); + if (statsSyncRef.current) { + clearInterval(statsSyncRef.current); } }; }, [connect, onboardingNeeded, onboardingLoading, isLoggedIn]);