From 837abec48a7e6d7d7ad4c08d23ba0267c84994d7 Mon Sep 17 00:00:00 2001 From: iven Date: Thu, 2 Apr 2026 02:02:59 +0800 Subject: [PATCH] feat(billing): add usage increment API + wire hand/pipeline execution tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server side: - POST /api/v1/billing/usage/increment endpoint with dimension whitelist (hand_executions, pipeline_runs, relay_requests) and count validation (1-100) - Returns updated usage quota after increment Desktop side: - New saas-billing.ts mixin with incrementUsageDimension() and reportUsageFireAndForget() (non-blocking, safe for finally blocks) - handStore.triggerHand: reports hand_executions after successful run - PipelinesPanel.handleRunComplete: reports pipeline_runs on completion - SaaSClient type declarations for new billing methods Billing pipeline now covers all three dimensions: relay_requests → relay handler (server-side, real-time) hand_executions → handStore (client-side, fire-and-forget) pipeline_runs → PipelinesPanel (client-side, fire-and-forget) --- crates/zclaw-saas/src/billing/handlers.rs | 47 ++++++++++++++++++ crates/zclaw-saas/src/billing/mod.rs | 1 + desktop/src/components/PipelinesPanel.tsx | 8 +++ desktop/src/lib/saas-billing.ts | 60 +++++++++++++++++++++++ desktop/src/lib/saas-client.ts | 6 +++ desktop/src/store/handStore.ts | 8 +++ 6 files changed, 130 insertions(+) create mode 100644 desktop/src/lib/saas-billing.ts diff --git a/crates/zclaw-saas/src/billing/handlers.rs b/crates/zclaw-saas/src/billing/handlers.rs index bd525d2..ea812bb 100644 --- a/crates/zclaw-saas/src/billing/handlers.rs +++ b/crates/zclaw-saas/src/billing/handlers.rs @@ -56,6 +56,53 @@ pub async fn get_usage( Ok(Json(usage)) } +/// POST /api/v1/billing/usage/increment — 客户端上报用量(Hand/Pipeline 执行后调用) +/// +/// 请求体: `{ "dimension": "hand_executions" | "pipeline_runs" | "relay_requests", "count": 1 }` +/// 需要认证 — account_id 从 JWT 提取。 +#[derive(Debug, Deserialize)] +pub struct IncrementUsageRequest { + /// 用量维度:hand_executions / pipeline_runs / relay_requests + pub dimension: String, + /// 递增数量,默认 1 + #[serde(default = "default_count")] + pub count: i32, +} + +fn default_count() -> i32 { 1 } + +pub async fn increment_usage_dimension( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> SaasResult> { + // 验证维度白名单 + 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) + )); + } + + // 限制单次递增上限(防滥用) + if req.count < 1 || req.count > 100 { + return Err(SaasError::InvalidInput( + format!("count 必须在 1~100 范围内,得到: {}", req.count) + )); + } + + for _ in 0..req.count { + service::increment_dimension(&state.db, &ctx.account_id, &req.dimension).await?; + } + + // 返回更新后的用量 + let usage = service::get_or_create_usage(&state.db, &ctx.account_id).await?; + Ok(Json(serde_json::json!({ + "dimension": req.dimension, + "incremented": req.count, + "usage": usage, + }))) +} + /// POST /api/v1/billing/payments — 创建支付订单 pub async fn create_payment( State(state): State, diff --git a/crates/zclaw-saas/src/billing/mod.rs b/crates/zclaw-saas/src/billing/mod.rs index a66c71d..6997b24 100644 --- a/crates/zclaw-saas/src/billing/mod.rs +++ b/crates/zclaw-saas/src/billing/mod.rs @@ -13,6 +13,7 @@ pub fn routes() -> axum::Router { .route("/api/v1/billing/plans/{id}", get(handlers::get_plan)) .route("/api/v1/billing/subscription", get(handlers::get_subscription)) .route("/api/v1/billing/usage", get(handlers::get_usage)) + .route("/api/v1/billing/usage/increment", post(handlers::increment_usage_dimension)) .route("/api/v1/billing/payments", post(handlers::create_payment)) .route("/api/v1/billing/payments/{id}", get(handlers::get_payment_status)) // 支付回调(无需 auth) diff --git a/desktop/src/components/PipelinesPanel.tsx b/desktop/src/components/PipelinesPanel.tsx index 87b5ef8..7c92fd7 100644 --- a/desktop/src/components/PipelinesPanel.tsx +++ b/desktop/src/components/PipelinesPanel.tsx @@ -29,6 +29,7 @@ import { formatInputType, } from '../lib/pipeline-client'; import { useToast } from './ui/Toast'; +import { saasClient } from '../lib/saas-client'; // === Category Badge Component === @@ -431,6 +432,13 @@ export function PipelinesPanel() { if (result.status === 'completed') { toast('Pipeline 执行完成', 'success'); setRunResult({ result, pipeline: selectedPipeline! }); + + // Report pipeline execution to billing (fire-and-forget) + try { + if (saasClient.isAuthenticated()) { + saasClient.reportUsageFireAndForget('pipeline_runs'); + } + } catch { /* billing reporting must never block */ } } else { toast(`Pipeline 执行失败: ${result.error}`, 'error'); } diff --git a/desktop/src/lib/saas-billing.ts b/desktop/src/lib/saas-billing.ts new file mode 100644 index 0000000..d7ce713 --- /dev/null +++ b/desktop/src/lib/saas-billing.ts @@ -0,0 +1,60 @@ +/** + * SaaS Billing Methods — Mixin + * + * Installs billing-related methods (usage increment, quota check) onto + * SaaSClient.prototype. Uses the same mixin pattern as saas-telemetry.ts. + */ + +export interface UsageIncrementResult { + dimension: string; + incremented: number; + usage: { + relay_requests: number; + hand_executions: number; + pipeline_runs: number; + input_tokens: number; + output_tokens: number; + [key: string]: unknown; + }; +} + +export function installBillingMethods(ClientClass: { prototype: any }): void { + const proto = ClientClass.prototype; + + /** + * Report a usage increment for a specific dimension. + * + * Called after hand execution, pipeline run, or other metered operations. + * Non-blocking — failures are logged but don't affect the caller. + * + * @param dimension - One of: hand_executions, pipeline_runs, relay_requests + * @param count - How many units to increment (default 1, max 100) + */ + proto.incrementUsageDimension = async function ( + this: { request(method: string, path: string, body?: unknown): Promise }, + dimension: string, + count: number = 1, + ): Promise { + return this.request( + 'POST', + '/api/v1/billing/usage/increment', + { dimension, count }, + ); + }; + + /** + * Fire-and-forget version of incrementUsageDimension. + * Logs errors but never throws — safe to call in finally blocks. + */ + proto.reportUsageFireAndForget = function ( + this: { incrementUsageDimension(dimension: string, count?: number): Promise }, + dimension: string, + count: number = 1, + ): void { + this.incrementUsageDimension(dimension, count).catch((err: unknown) => { + // Non-fatal: billing reporting failure must never block user operations + const msg = err instanceof Error ? err.message : String(err); + console.warn(`[Billing] Failed to report ${dimension} usage (+${count}): ${msg}`); + }); + }; +} diff --git a/desktop/src/lib/saas-client.ts b/desktop/src/lib/saas-client.ts index fee7e5d..dc56ecf 100644 --- a/desktop/src/lib/saas-client.ts +++ b/desktop/src/lib/saas-client.ts @@ -132,6 +132,7 @@ import { installAdminMethods } from './saas-admin'; import { installRelayMethods } from './saas-relay'; import { installPromptMethods } from './saas-prompt'; import { installTelemetryMethods } from './saas-telemetry'; +import { installBillingMethods } from './saas-billing'; // === Client Implementation === @@ -411,6 +412,7 @@ installAdminMethods(SaaSClient); installRelayMethods(SaaSClient); installPromptMethods(SaaSClient); installTelemetryMethods(SaaSClient); +installBillingMethods(SaaSClient); // === API Method Type Declarations === // These methods are installed at runtime by installXxxMethods() in saas-*.ts. @@ -516,6 +518,10 @@ export interface SaaSClient { timestamp: string; }>; }): Promise<{ accepted: number; total: number }>; + + // --- Billing (saas-billing.ts) --- + incrementUsageDimension(dimension: string, count?: number): Promise; + reportUsageFireAndForget(dimension: string, count?: number): void; } // === Singleton === diff --git a/desktop/src/store/handStore.ts b/desktop/src/store/handStore.ts index 0f557f0..c4048f6 100644 --- a/desktop/src/store/handStore.ts +++ b/desktop/src/store/handStore.ts @@ -8,6 +8,7 @@ import { create } from 'zustand'; import type { GatewayClient } from '../lib/gateway-client'; import { canAutoExecute, getAutonomyManager } from '../lib/autonomy-manager'; import type { AutonomyDecision } from '../lib/autonomy-manager'; +import { saasClient } from '../lib/saas-client'; // === Re-exported Types (from gatewayStore for compatibility) === @@ -355,6 +356,13 @@ export const useHandStore = create((set, get) => ({ // Refresh hands to update status await get().loadHands(); + // Report hand execution to billing (fire-and-forget, non-blocking) + try { + if (saasClient.isAuthenticated()) { + saasClient.reportUsageFireAndForget('hand_executions'); + } + } catch { /* billing reporting must never block */ } + return run; } catch (err: unknown) { set({ error: err instanceof Error ? err.message : String(err) });