feat(billing): add usage increment API + wire hand/pipeline execution tracking
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)
This commit is contained in:
@@ -56,6 +56,53 @@ pub async fn get_usage(
|
|||||||
Ok(Json(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<AppState>,
|
||||||
|
Extension(ctx): Extension<AuthContext>,
|
||||||
|
Json(req): Json<IncrementUsageRequest>,
|
||||||
|
) -> SaasResult<Json<serde_json::Value>> {
|
||||||
|
// 验证维度白名单
|
||||||
|
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 — 创建支付订单
|
/// POST /api/v1/billing/payments — 创建支付订单
|
||||||
pub async fn create_payment(
|
pub async fn create_payment(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ pub fn routes() -> axum::Router<crate::state::AppState> {
|
|||||||
.route("/api/v1/billing/plans/{id}", get(handlers::get_plan))
|
.route("/api/v1/billing/plans/{id}", get(handlers::get_plan))
|
||||||
.route("/api/v1/billing/subscription", get(handlers::get_subscription))
|
.route("/api/v1/billing/subscription", get(handlers::get_subscription))
|
||||||
.route("/api/v1/billing/usage", get(handlers::get_usage))
|
.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", post(handlers::create_payment))
|
||||||
.route("/api/v1/billing/payments/{id}", get(handlers::get_payment_status))
|
.route("/api/v1/billing/payments/{id}", get(handlers::get_payment_status))
|
||||||
// 支付回调(无需 auth)
|
// 支付回调(无需 auth)
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
formatInputType,
|
formatInputType,
|
||||||
} from '../lib/pipeline-client';
|
} from '../lib/pipeline-client';
|
||||||
import { useToast } from './ui/Toast';
|
import { useToast } from './ui/Toast';
|
||||||
|
import { saasClient } from '../lib/saas-client';
|
||||||
|
|
||||||
// === Category Badge Component ===
|
// === Category Badge Component ===
|
||||||
|
|
||||||
@@ -431,6 +432,13 @@ export function PipelinesPanel() {
|
|||||||
if (result.status === 'completed') {
|
if (result.status === 'completed') {
|
||||||
toast('Pipeline 执行完成', 'success');
|
toast('Pipeline 执行完成', 'success');
|
||||||
setRunResult({ result, pipeline: selectedPipeline! });
|
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 {
|
} else {
|
||||||
toast(`Pipeline 执行失败: ${result.error}`, 'error');
|
toast(`Pipeline 执行失败: ${result.error}`, 'error');
|
||||||
}
|
}
|
||||||
|
|||||||
60
desktop/src/lib/saas-billing.ts
Normal file
60
desktop/src/lib/saas-billing.ts
Normal file
@@ -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<T>(method: string, path: string, body?: unknown): Promise<T> },
|
||||||
|
dimension: string,
|
||||||
|
count: number = 1,
|
||||||
|
): Promise<UsageIncrementResult> {
|
||||||
|
return this.request<UsageIncrementResult>(
|
||||||
|
'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<UsageIncrementResult> },
|
||||||
|
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}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -132,6 +132,7 @@ import { installAdminMethods } from './saas-admin';
|
|||||||
import { installRelayMethods } from './saas-relay';
|
import { installRelayMethods } from './saas-relay';
|
||||||
import { installPromptMethods } from './saas-prompt';
|
import { installPromptMethods } from './saas-prompt';
|
||||||
import { installTelemetryMethods } from './saas-telemetry';
|
import { installTelemetryMethods } from './saas-telemetry';
|
||||||
|
import { installBillingMethods } from './saas-billing';
|
||||||
|
|
||||||
// === Client Implementation ===
|
// === Client Implementation ===
|
||||||
|
|
||||||
@@ -411,6 +412,7 @@ installAdminMethods(SaaSClient);
|
|||||||
installRelayMethods(SaaSClient);
|
installRelayMethods(SaaSClient);
|
||||||
installPromptMethods(SaaSClient);
|
installPromptMethods(SaaSClient);
|
||||||
installTelemetryMethods(SaaSClient);
|
installTelemetryMethods(SaaSClient);
|
||||||
|
installBillingMethods(SaaSClient);
|
||||||
|
|
||||||
// === API Method Type Declarations ===
|
// === API Method Type Declarations ===
|
||||||
// These methods are installed at runtime by installXxxMethods() in saas-*.ts.
|
// These methods are installed at runtime by installXxxMethods() in saas-*.ts.
|
||||||
@@ -516,6 +518,10 @@ export interface SaaSClient {
|
|||||||
timestamp: string;
|
timestamp: string;
|
||||||
}>;
|
}>;
|
||||||
}): Promise<{ accepted: number; total: number }>;
|
}): Promise<{ accepted: number; total: number }>;
|
||||||
|
|
||||||
|
// --- Billing (saas-billing.ts) ---
|
||||||
|
incrementUsageDimension(dimension: string, count?: number): Promise<import('./saas-billing').UsageIncrementResult>;
|
||||||
|
reportUsageFireAndForget(dimension: string, count?: number): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Singleton ===
|
// === Singleton ===
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { create } from 'zustand';
|
|||||||
import type { GatewayClient } from '../lib/gateway-client';
|
import type { GatewayClient } from '../lib/gateway-client';
|
||||||
import { canAutoExecute, getAutonomyManager } from '../lib/autonomy-manager';
|
import { canAutoExecute, getAutonomyManager } from '../lib/autonomy-manager';
|
||||||
import type { AutonomyDecision } from '../lib/autonomy-manager';
|
import type { AutonomyDecision } from '../lib/autonomy-manager';
|
||||||
|
import { saasClient } from '../lib/saas-client';
|
||||||
|
|
||||||
// === Re-exported Types (from gatewayStore for compatibility) ===
|
// === Re-exported Types (from gatewayStore for compatibility) ===
|
||||||
|
|
||||||
@@ -355,6 +356,13 @@ export const useHandStore = create<HandStore>((set, get) => ({
|
|||||||
// Refresh hands to update status
|
// Refresh hands to update status
|
||||||
await get().loadHands();
|
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;
|
return run;
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
set({ error: err instanceof Error ? err.message : String(err) });
|
set({ error: err instanceof Error ? err.message : String(err) });
|
||||||
|
|||||||
Reference in New Issue
Block a user