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]);