fix(billing): resolve all audit findings — CSRF, float precision, TOCTOU, error sanitization
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled

- Add CSRF token protection for mock payment (SHA256 + constant-time verify)
- Replace f64 currency conversion with pure integer string parsing (parse_yuan_to_cents)
- Move subscription check inside transaction to prevent TOCTOU race
- Rewrite increment_usage to use atomic SQL (account_id+period_start WHERE)
- Add trade_no format validation in payment callback
- Sanitize error messages to prevent sensitive data leakage
- Use i32::try_from for WeChat amount conversion (prevent truncation)
- Replace window.__ZCLAW_STATS_SYNC_INTERVAL__ with useRef pattern
- Replace eprintln/println with tracing macros in lifecycle
- Remove unused variable in scheduler
- Remove duplicate sha2 and unused hmac from Cargo.toml
This commit is contained in:
iven
2026-04-02 20:04:43 +08:00
parent 8898bb399e
commit da438ad868
7 changed files with 127 additions and 34 deletions

View File

@@ -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(())