4 Commits

Author SHA1 Message Date
boos bfd804f719 feat(billing): UsageRecords dual-track ledger view
api.ts 新增 userApi.getBillingLedger / getBillingSummary 及 Billing* 类型;
UsageRecords.tsx 增加 USD/积分 双轨切换 tab、积分汇总卡与明细表(切到积分轨时懒加载)。
随文件一并提交此前累积的支付/Comfy Cloud/工作流前端 API 改动。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-17 03:04:11 +08:00
boos e7f3da5961 feat(billing): user billing ledger + summary endpoints
新增 GET /api/user/billing/ledger 与 /api/user/billing/summary 两个用户侧接口,
基于 Task11 的 list_user_ledger / summarize_user_ledger 返回当前用户的明细与聚合。
随文件一并提交此前累积的充值/支付/市场相关改动及测试辅助。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-17 02:56:12 +08:00
boos 5cfd533d89 feat(billing): user ledger list + summary queries 2026-06-17 01:49:46 +08:00
boos 0b7abed79a docs: 细化计费第3期(消费明细)TDD Task11-14 2026-06-17 01:44:28 +08:00
7 changed files with 1779 additions and 173 deletions
@@ -567,3 +567,128 @@ pub async fn apply_points_delta(
### Task 10:第 2 期全量回归
- [ ] `cargo test 2>&1 | grep "test result"` 全 ok;`cargo build -j 2 | tail -2` Finished。
---
## 第 3 期:A 消费明细(细化 TDD)
文件:`src/services/billing/ledger.rs`(加查询函数)、`src/routes/user/mod.rs`(加 2 路由)、`src/web/api.ts`(加 client)、`src/web/pages/UsageRecords.tsx`(扩展)。
### Task 11:ledger 查询函数(明细 + 聚合)
**Files:** Modify `src/services/billing/ledger.rs`
- [ ] **Step1 失败测试**(ledger.rs tests mod):
```rust
#[tokio::test]
async fn list_and_summarize_user_ledger() {
let pool = mem_pool().await;
apply_points_delta(&pool, 9, 100.0, "recharge", None, Some("system"), Some("rr"), None).await.unwrap();
apply_points_delta(&pool, 9, -10.0, "consume", Some("wf:1"), Some("system"), Some("run"), None).await.unwrap();
// 明细:user 9 两行,track=points
let rows = list_user_ledger(&pool, 9, None, None, 50, 0).await.unwrap();
assert_eq!(rows.len(), 2);
// 聚合:points 轨 consume 合计 -10
let sum = summarize_user_ledger(&pool, 9).await.unwrap();
assert!(sum.iter().any(|v| v["track"]=="points" && v["entryType"]=="consume"));
}
```
- [ ] **Step2** `cargo test -p boosapi --lib list_and_summarize_user_ledger 2>&1|tail -6` FAIL。
- [ ] **Step3 实现**(ledger.rs,tests 之前):
```rust
use serde_json::{json, Value};
/// List a user's ledger rows, newest first, optional track/entry_type filter + paging.
pub async fn list_user_ledger(
db: &SqlitePool, user_id: i64,
track: Option<&str>, entry_type: Option<&str>,
limit: i64, offset: i64,
) -> AppResult<Vec<Value>> {
ensure_ledger_schema(db).await?;
let limit = limit.clamp(1, 500);
let rows = sqlx::query(
"SELECT track, entry_type, amount, balance_after, model, operator, ref_type, ref_id, note, created_at
FROM credit_ledger
WHERE user_id = ?
AND (?1_track IS NULL OR track = ?1_track)
AND (?1_type IS NULL OR entry_type = ?1_type)
ORDER BY id DESC LIMIT ? OFFSET ?"
.replace("?1_track", "?2").replace("?1_type", "?3")
)
.bind(user_id).bind(track).bind(entry_type).bind(limit).bind(offset)
.fetch_all(db).await?;
use sqlx::Row;
Ok(rows.iter().map(|r| json!({
"track": r.try_get::<String,_>("track").unwrap_or_default(),
"entryType": r.try_get::<String,_>("entry_type").unwrap_or_default(),
"amount": r.try_get::<f64,_>("amount").unwrap_or(0.0),
"balanceAfter": r.try_get::<f64,_>("balance_after").unwrap_or(0.0),
"model": r.try_get::<Option<String>,_>("model").ok().flatten(),
"operator": r.try_get::<Option<String>,_>("operator").ok().flatten(),
"refType": r.try_get::<Option<String>,_>("ref_type").ok().flatten(),
"note": r.try_get::<Option<String>,_>("note").ok().flatten(),
"createdAt": r.try_get::<Option<String>,_>("created_at").ok().flatten(),
})).collect())
}
/// Aggregate a user's ledger by (track, entry_type): sum amount + count.
pub async fn summarize_user_ledger(db: &SqlitePool, user_id: i64) -> AppResult<Vec<Value>> {
ensure_ledger_schema(db).await?;
let rows = sqlx::query(
"SELECT track, entry_type, COALESCE(SUM(amount),0) AS total, COUNT(*) AS cnt
FROM credit_ledger WHERE user_id = ?
GROUP BY track, entry_type ORDER BY track, entry_type"
).bind(user_id).fetch_all(db).await?;
use sqlx::Row;
Ok(rows.iter().map(|r| json!({
"track": r.try_get::<String,_>("track").unwrap_or_default(),
"entryType": r.try_get::<String,_>("entry_type").unwrap_or_default(),
"total": r.try_get::<f64,_>("total").unwrap_or(0.0),
"count": r.try_get::<i64,_>("cnt").unwrap_or(0),
})).collect())
}
```
注:上面 SQL 的 named-param replace 写法若 sqlx 不接受,改用直白的可选过滤:用 `track.is_none() OR track = ?` 形式,bind 顺序 user_id, track, track, entry_type, entry_type, limit, offset(每个 Option bind 两次)。子代理按 sqlx 实际行为调整到能编译+测试过。
- [ ] **Step4** 测试 PASS + `cargo build -j 2`。
- [ ] **Step5** `git add src/services/billing/ledger.rs && git commit -m "feat(billing): user ledger list + summary queries"`
### Task 12:user 侧 billing 接口
**Files:** Modify `src/routes/user/mod.rs`(加 2 路由 + 2 handler)
- [ ] **Step1 失败测试**:user mod tests(若有 test_state/建表 helper 则复用)——构造 user JWT + ledger 数据,GET /api/user/billing/ledger 返回行,/summary 返回聚合。若 user mod 无现成集成测试框架,改为直接单测 handler 逻辑或跳测试靠 ledger.rs 的 Task11 测试覆盖,仅验证编译 + 路由注册。
- [ ] **Step2** FAIL/编译失败。
- [ ] **Step3 实现**:
```rust
async fn get_billing_ledger(State(state): State<AppState>, headers: HeaderMap, Query(q): Query<BillingLedgerQuery>) -> AppResult<Json<Value>> {
let user_id = current_user_id(&headers, &state)?;
let rows = crate::services::billing::ledger::list_user_ledger(
&state.db, user_id, q.track.as_deref(), q.entry_type.as_deref(),
q.limit.unwrap_or(100), q.offset.unwrap_or(0)).await?;
Ok(Json(json!({ "success": true, "records": rows })))
}
async fn get_billing_summary(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
let user_id = current_user_id(&headers, &state)?;
let agg = crate::services::billing::ledger::summarize_user_ledger(&state.db, user_id).await?;
Ok(Json(json!({ "success": true, "summary": agg })))
}
```
+ `BillingLedgerQuery{ track:Option<String>, entry_type:Option<String>, limit:Option<i64>, offset:Option<i64> }`(serde Deserialize, rename_all camelCase 视前端传参)。
+ routes() 注册 `.route("/api/user/billing/ledger", get(get_billing_ledger)).route("/api/user/billing/summary", get(get_billing_summary))`。
- [ ] **Step4** `cargo test -p boosapi --lib 2>&1|command grep "test result"` + `cargo build -j 2`。
- [ ] **Step5** `git add src/routes/user/mod.rs && git commit -m "feat(billing): user billing ledger + summary endpoints"`
### Task 13:前端 api + UsageRecords 双轨展示
**Files:** Modify `src/web/api.ts`、`src/web/pages/UsageRecords.tsx`
- [ ] **Step1**:api.ts 的 userApi 加 `getBillingLedger(params)` → GET /api/user/billing/ledger、`getBillingSummary()` → /summary。
- [ ] **Step2**:UsageRecords.tsx 加一个"积分/USD"双轨切换 tab + 聚合汇总卡(用 getBillingSummary)+ ledger 明细列表(getBillingLedger)。保留现有 logs 视图作为 USD 明细来源之一,新增积分明细来自 ledger。具体 UI 复用现有 usage-records-* 样式。
- [ ] **Step3**:`npm run typecheck:web` 干净 + 现有 UsageRecords 测试不破坏(`npx vitest run src/web/pages/UsageRecords`)。
- [ ] **Step4** `git add src/web/api.ts src/web/pages/UsageRecords.tsx && git commit -m "feat(billing): UsageRecords dual-track ledger view"`
### Task 14:第3期回归
- [ ] `cargo test 2>&1|command grep "test result"` 全ok;`cargo build -j 2`;`npm run typecheck`。
+879 -99
View File
File diff suppressed because it is too large Load Diff
+86 -1
View File
@@ -1,5 +1,6 @@
use crate::error::AppResult;
use sqlx::{SqlitePool, Transaction};
use serde_json::{json, Value};
use sqlx::{Row, SqlitePool, Transaction};
/// One balance-changing event. Written to `credit_ledger` in the same
/// transaction that updates the cached balance, so ledger and cache never diverge.
@@ -143,6 +144,77 @@ pub async fn apply_points_delta(
Ok(next)
}
/// List a user's ledger rows, newest first, optional track/entry_type filter + paging.
pub async fn list_user_ledger(
db: &SqlitePool,
user_id: i64,
track: Option<&str>,
entry_type: Option<&str>,
limit: i64,
offset: i64,
) -> AppResult<Vec<Value>> {
ensure_ledger_schema(db).await?;
let limit = limit.clamp(1, 500);
let offset = offset.max(0);
let rows = sqlx::query(
"SELECT track, entry_type, amount, balance_after, model, operator, ref_type, ref_id, note, created_at
FROM credit_ledger
WHERE user_id = ?
AND (? IS NULL OR track = ?)
AND (? IS NULL OR entry_type = ?)
ORDER BY id DESC LIMIT ? OFFSET ?",
)
.bind(user_id)
.bind(track)
.bind(track)
.bind(entry_type)
.bind(entry_type)
.bind(limit)
.bind(offset)
.fetch_all(db)
.await?;
Ok(rows
.iter()
.map(|r| {
json!({
"track": r.try_get::<String, _>("track").unwrap_or_default(),
"entryType": r.try_get::<String, _>("entry_type").unwrap_or_default(),
"amount": r.try_get::<f64, _>("amount").unwrap_or(0.0),
"balanceAfter": r.try_get::<f64, _>("balance_after").unwrap_or(0.0),
"model": r.try_get::<Option<String>, _>("model").ok().flatten(),
"operator": r.try_get::<Option<String>, _>("operator").ok().flatten(),
"refType": r.try_get::<Option<String>, _>("ref_type").ok().flatten(),
"note": r.try_get::<Option<String>, _>("note").ok().flatten(),
"createdAt": r.try_get::<Option<String>, _>("created_at").ok().flatten(),
})
})
.collect())
}
/// Aggregate a user's ledger by (track, entry_type): sum amount + count.
pub async fn summarize_user_ledger(db: &SqlitePool, user_id: i64) -> AppResult<Vec<Value>> {
ensure_ledger_schema(db).await?;
let rows = sqlx::query(
"SELECT track, entry_type, COALESCE(SUM(amount),0) AS total, COUNT(*) AS cnt
FROM credit_ledger WHERE user_id = ?
GROUP BY track, entry_type ORDER BY track, entry_type",
)
.bind(user_id)
.fetch_all(db)
.await?;
Ok(rows
.iter()
.map(|r| {
json!({
"track": r.try_get::<String, _>("track").unwrap_or_default(),
"entryType": r.try_get::<String, _>("entry_type").unwrap_or_default(),
"total": r.try_get::<f64, _>("total").unwrap_or(0.0),
"count": r.try_get::<i64, _>("cnt").unwrap_or(0),
})
})
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -207,4 +279,17 @@ mod tests {
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM credit_ledger WHERE user_id=9 AND track='points'").fetch_one(&pool).await.unwrap();
assert_eq!(n, 2);
}
#[tokio::test]
async fn list_and_summarize_user_ledger() {
let pool = mem_pool().await;
apply_points_delta(&pool, 9, 100.0, "recharge", None, Some("system"), Some("rr"), None).await.unwrap();
apply_points_delta(&pool, 9, -10.0, "consume", Some("wf:1"), Some("system"), Some("run"), None).await.unwrap();
// 明细:user 9 两行,track=points
let rows = list_user_ledger(&pool, 9, None, None, 50, 0).await.unwrap();
assert_eq!(rows.len(), 2);
// 聚合:points 轨 consume 合计 -10
let sum = summarize_user_ledger(&pool, 9).await.unwrap();
assert!(sum.iter().any(|v| v["track"]=="points" && v["entryType"]=="consume"));
}
}
+41
View File
@@ -539,6 +539,22 @@ describe('user recharge api', () => {
expect((fetchMock.mock.calls[1]?.[1] as RequestInit)?.method).toBe('DELETE');
});
it('sanitizes upstream HTML errors from recharge checkout', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(
'<!DOCTYPE html><!--[if lt IE 7]> <html class="no-js ie6 oldie" lang="en-US">',
{ status: 502, headers: { 'content-type': 'text/html' } },
));
vi.stubGlobal('fetch', fetchMock);
await expect(userApi.createRechargeCheckout({
optionId: 'usd-10',
amountUsd: 10,
})).rejects.toMatchObject({
status: 502,
message: 'HTTP 502: 上游返回 HTML 错误页,请检查支付/上游 Token 配置',
});
});
it('keeps billing guidance on quota errors', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
error: { message: '下游密钥成本额度已用完' },
@@ -561,4 +577,29 @@ describe('user recharge api', () => {
retryAfterSecs: 60,
});
});
it('fetches billing ledger with track/entryType filters and summary', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({
success: true,
records: [
{ track: 'points', entryType: 'consume', amount: -10, balanceAfter: 490 },
],
}), { status: 200, headers: { 'content-type': 'application/json' } }))
.mockResolvedValueOnce(new Response(JSON.stringify({
success: true,
summary: [
{ track: 'points', entryType: 'consume', total: -10, count: 1 },
],
}), { status: 200, headers: { 'content-type': 'application/json' } }));
vi.stubGlobal('fetch', fetchMock);
const ledger = await userApi.getBillingLedger({ track: 'points', entryType: 'consume', limit: 50 });
const summary = await userApi.getBillingSummary();
expect(ledger.records[0].entryType).toBe('consume');
expect(summary.summary[0].count).toBe(1);
expect(fetchMock.mock.calls[0]?.[0]).toBe('/api/user/billing/ledger?track=points&entryType=consume&limit=50');
expect(fetchMock.mock.calls[1]?.[0]).toBe('/api/user/billing/summary');
});
});
+397 -2
View File
@@ -9,6 +9,7 @@ const nodeBuffer = (globalThis as typeof globalThis & { Buffer?: BufferLike })
type RequestOptions = RequestInit & {
timeoutMs?: number;
preserveAuthErrors?: boolean;
};
export type ApiRequestError = Error & {
@@ -44,6 +45,18 @@ function requireAuthToken(): string {
return token;
}
function sanitizeErrorText(status: number, text: string): string {
const trimmed = text.trim();
const looksLikeHtml = trimmed.includes("<!DOCTYPE")
|| trimmed.includes("<html")
|| trimmed.includes("</html>")
|| trimmed.includes("<!--[if");
if (looksLikeHtml) {
return `HTTP ${status}: 上游返回 HTML 错误页,请检查支付/上游 Token 配置`;
}
return `HTTP ${status}: ${trimmed.slice(0, 120)}`;
}
async function extractResponseError(res: Response): Promise<ApiRequestError> {
let message = `HTTP ${res.status}`;
let payload: any = null;
@@ -69,7 +82,7 @@ async function extractResponseError(res: Response): Promise<ApiRequestError> {
message = `${message} (${json.path.trim()})`;
}
} catch {
message = `${message}: ${text.slice(0, 120)}`;
message = sanitizeErrorText(res.status, text);
}
}
} catch {}
@@ -136,6 +149,7 @@ async function fetchAuthenticatedResponse(
const {
timeoutMs = 30_000,
signal: externalSignal,
preserveAuthErrors = false,
...fetchOptions
} = options;
const controller = new AbortController();
@@ -168,7 +182,7 @@ async function fetchAuthenticatedResponse(
signal: controller.signal,
headers,
});
if (res.status === 401 || res.status === 403) {
if (!preserveAuthErrors && (res.status === 401 || res.status === 403)) {
const hadToken = !!getSessionToken('admin', localStorage);
clearSession('admin', localStorage);
if (
@@ -205,6 +219,7 @@ async function fetchUserResponse(
const {
timeoutMs = 30_000,
signal: externalSignal,
preserveAuthErrors: _preserveAuthErrors = false,
...fetchOptions
} = options;
const controller = new AbortController();
@@ -336,6 +351,65 @@ async function request<T = any>(
return parseJsonResponse<T>(res);
}
async function publicRequest<T = any>(
url: string,
options: RequestOptions = {},
): Promise<T> {
const {
timeoutMs = 30_000,
signal: externalSignal,
preserveAuthErrors: _preserveAuthErrors = false,
...fetchOptions
} = options;
const controller = new AbortController();
let timeoutHandle: ReturnType<typeof setTimeout> | null = setTimeout(() => {
controller.abort();
}, timeoutMs);
let cleanupExternalSignal = () => {};
if (externalSignal) {
if (externalSignal.aborted) {
controller.abort();
} else {
const abortHandler = () => controller.abort();
externalSignal.addEventListener("abort", abortHandler, { once: true });
cleanupExternalSignal = () =>
externalSignal.removeEventListener("abort", abortHandler);
}
}
const headers = new Headers(fetchOptions.headers ?? {});
if (shouldSetJsonContentType(fetchOptions.body) && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
try {
const res = await fetch(url, {
...fetchOptions,
signal: controller.signal,
headers,
});
if (!res.ok) {
throw await extractResponseError(res);
}
return parseJsonResponse<T>(res);
} catch (error: any) {
if (error?.name === "AbortError") {
if (externalSignal?.aborted) throw error;
throw new Error(
`请求超时(${Math.max(1, Math.round(timeoutMs / 1000))}s)`,
);
}
throw error;
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
timeoutHandle = null;
}
cleanupExternalSignal();
}
}
function unwrapArrayResponse(value: any, keys: string[] = ["items", "data"]): any[] {
if (Array.isArray(value)) return value;
for (const key of keys) {
@@ -889,6 +963,7 @@ export type RuntimeSettingsPayload = {
systemProxyUrl?: string;
payloadRules?: Record<string, unknown> | null;
modelAvailabilityProbeEnabled?: boolean;
registrationDisabled?: boolean;
codexUpstreamWebsocketEnabled?: boolean;
responsesCompactFallbackToResponsesEnabled?: boolean;
disableCrossProtocolFallback?: boolean;
@@ -1422,6 +1497,18 @@ export type DigitalHumanStatusResponse = {
mode: string;
keyName?: string;
capabilities?: string[];
pixelle?: {
enabled?: boolean;
configured?: boolean;
backendProvider?: string;
backendOptions?: string[];
baseUrl?: string;
frameTemplate?: string;
mediaWorkflow?: string;
ttsWorkflow?: string;
runninghubMediaWorkflow?: string;
runninghubTtsWorkflow?: string;
};
runninghub?: {
configured?: boolean;
endpoint?: string;
@@ -1448,6 +1535,8 @@ export type MoneyPrinterJobsResponse = {
export type MoneyPrinterStatusResponse = {
success: boolean;
mode: string;
disabled?: boolean;
disabledReason?: string | null;
keyName?: string;
capabilities?: string[];
comfy?: {
@@ -1548,6 +1637,106 @@ export type ClearUserRechargeRecordsResponse = {
rechargePath?: string;
};
export type BillingLedgerEntry = {
track: string;
entryType: string;
amount: number;
balanceAfter: number;
model?: string | null;
operator?: string | null;
refType?: string | null;
note?: string | null;
createdAt?: string | null;
};
export type BillingLedgerResponse = {
success: boolean;
records: BillingLedgerEntry[];
};
export type BillingSummaryRow = {
track: string;
entryType: string;
total: number;
count: number;
};
export type BillingSummaryResponse = {
success: boolean;
summary: BillingSummaryRow[];
};
export type PaymentPublicConfig = {
enabled: boolean;
provider: string;
currency: string;
minAmountUsd: number;
maxAmountUsd?: number | null;
orderTimeoutMinutes: number;
maxPendingOrders: number;
};
export type PaymentConfigResponse = {
success: boolean;
payment: PaymentPublicConfig;
comfyCloud?: {
balanceDiagnosticsPath?: string;
checkout?: {
dynamicEndpointConfigured?: boolean;
creditEndpointConfigured?: boolean;
customerTokenConfigured?: boolean;
customerTokenProviderConfigured?: boolean;
customerTokenSource?: string;
customerTokenEndpoint?: string;
firebaseRefreshConfigured?: boolean;
firebaseApiKeyConfigured?: boolean;
googleOAuthClientConfigured?: boolean;
checkoutEndpointSource?: string;
checkoutEndpoint?: string;
firebaseLogin?: {
configured?: boolean;
cacheRemainingSecs?: number | null;
lastRefreshOk?: boolean | null;
invalidGrant?: boolean;
lastRefreshDetail?: string | null;
lastRefreshAtUnix?: number | null;
};
[key: string]: unknown;
};
[key: string]: unknown;
};
webhooks?: Record<string, string>;
};
export type ComfyCloudBalance = {
amountMicros?: number;
effectiveBalanceMicros?: number;
prepaidBalanceMicros?: number;
cloudCreditBalanceMicros?: number;
pendingChargesMicros?: number;
currency?: string;
[key: string]: unknown;
};
export type ComfyCloudDiagnostics = {
apiBaseUrl?: string;
tokenSource?: string;
tokenKind?: string;
jwtIssuer?: string | null;
jwtAudience?: string | null;
jwtSubjectPresent?: boolean;
httpStatus?: number | null;
upstreamMessage?: string | null;
success?: boolean;
[key: string]: unknown;
};
export type AdminComfyCloudBalanceResponse = {
success: boolean;
provider?: string;
balance?: ComfyCloudBalance | null;
};
export type WorkflowModelItem = {
name: string;
pathIndex?: number;
@@ -1716,6 +1905,176 @@ export const api = {
timeoutMs: data?.wait ? 150_000 : 30_000,
}),
// Payment
getPaymentConfig: () =>
publicRequest<PaymentConfigResponse>("/api/payment/config"),
getComfyCloudBalance: () =>
request<AdminComfyCloudBalanceResponse>("/api/admin/payment/comfy-cloud/balance", {
preserveAuthErrors: true,
timeoutMs: 45_000,
}),
getComfyCloudDiagnostics: () =>
request<{ success: boolean; provider: string; diagnostics: ComfyCloudDiagnostics }>(
"/api/admin/payment/comfy-cloud/diagnostics",
{
preserveAuthErrors: true,
timeoutMs: 45_000,
},
),
updateComfyCloudPaymentSettings: (data: {
customerToken?: string;
clearCustomerToken?: boolean;
customerTokenEndpoint?: string;
firebaseApiKey?: string;
googleOAuthClientId?: string;
googleOAuthClientSecret?: string;
firebaseRefreshToken?: string;
checkoutEndpoint?: string;
}) =>
request<PaymentConfigResponse>("/api/admin/payment/comfy-cloud/settings", {
method: "PUT",
body: JSON.stringify(data || {}),
preserveAuthErrors: true,
}),
startComfyCloudOAuth: () =>
request<{
success: boolean;
oauth?: {
state?: string;
authorizationUrl?: string;
redirectUri?: string;
};
}>("/api/admin/payment/comfy-cloud/oauth/start", {
method: "POST",
preserveAuthErrors: true,
}),
adminComfyCloudRecharge: (amountUsd: number) =>
request<{
success: boolean;
checkout: { url: string; id?: string | null };
amountUsd: number;
}>("/api/admin/payment/comfy-cloud/recharge", {
method: "POST",
body: JSON.stringify({ amountUsd }),
preserveAuthErrors: true,
timeoutMs: 60_000,
}),
adminListRechargeRecords: (limit = 100) =>
request<{
success: boolean;
records: Array<{
publicId: string;
userId: number;
userEmail?: string | null;
userName?: string | null;
amountUsd?: number | null;
provider: string;
status: string;
label?: string | null;
title?: string | null;
note?: string | null;
createdAt?: string | null;
completedAt?: string | null;
}>;
}>(`/api/admin/payment/recharge-records?limit=${limit}`, {
preserveAuthErrors: true,
}),
adminConfirmRecharge: (publicId: string) =>
request<{ success: boolean; record: any }>(
`/api/admin/payment/recharge-records/${encodeURIComponent(publicId)}/confirm`,
{ method: "POST", preserveAuthErrors: true },
),
adminCancelRecharge: (publicId: string) =>
request<{ success: boolean; record: any }>(
`/api/admin/payment/recharge-records/${encodeURIComponent(publicId)}/cancel`,
{ method: "POST", preserveAuthErrors: true },
),
adminClearRechargeRecords: () =>
request<{ success: boolean; cleared: number }>("/api/admin/payment/recharge-records", {
method: "DELETE",
preserveAuthErrors: true,
}),
// Pixelle WebUI parity (digital human three-column page)
pixelle: {
listTtsWorkflows: () =>
request<{ workflows: Array<{ name: string; display_name?: string; source?: string; path?: string; key?: string }> }>(
"/api/digital-human/pixelle/workflows/tts",
{ preserveAuthErrors: true },
),
listMediaWorkflows: () =>
request<{ workflows: Array<{ name: string; display_name?: string; source?: string; path?: string; key?: string }> }>(
"/api/digital-human/pixelle/workflows/media",
{ preserveAuthErrors: true },
),
listTemplates: () =>
request<{ templates: Array<{ name: string; display_name?: string; size?: string; width?: number; height?: number; orientation?: string; path?: string; key?: string }> }>(
"/api/digital-human/pixelle/templates",
{ preserveAuthErrors: true },
),
listBgm: () =>
request<{ bgm_files: Array<{ name: string; path: string; source?: string }> }>(
"/api/digital-human/pixelle/bgm",
{ preserveAuthErrors: true },
),
getTemplateParams: (template: string) =>
request<{ template: string; media_width?: number; media_height?: number; params?: any }>(
`/api/digital-human/pixelle/template-params?template=${encodeURIComponent(template)}`,
{ preserveAuthErrors: true },
),
previewImage: (data: { prompt: string; width?: number; height?: number; workflow?: string }) =>
request<{ image_path: string }>(
"/api/digital-human/pixelle/preview-image",
{
method: "POST",
body: JSON.stringify(data),
preserveAuthErrors: true,
timeoutMs: 180_000,
},
),
previewTts: (data: { text: string; workflow?: string; ref_audio?: string }) =>
request<{ audio_path: string; duration?: number }>(
"/api/digital-human/pixelle/preview-tts",
{
method: "POST",
body: JSON.stringify(data),
preserveAuthErrors: true,
timeoutMs: 120_000,
},
),
generateAsync: (data: Record<string, unknown>) =>
request<{ task_id: string }>(
"/api/digital-human/pixelle/generate",
{
method: "POST",
body: JSON.stringify(data),
preserveAuthErrors: true,
timeoutMs: 60_000,
},
),
getTask: (taskId: string) =>
request<{
task_id?: string;
id?: string;
status?: string;
progress?: number | string;
message?: string;
result?: any;
video_url?: string;
duration?: number;
file_size?: number;
error?: string;
}>(
`/api/digital-human/pixelle/tasks/${encodeURIComponent(taskId)}`,
{ preserveAuthErrors: true },
),
fileUrl: (path: string) => {
const trimmed = String(path || "").replace(/^\/+/, "");
const segments = trimmed.split("/").map((segment) => encodeURIComponent(segment));
return `/api/digital-human/pixelle/files/${segments.join("/")}`;
},
},
// Account tokens
getAccountTokens: async (accountId?: number) => {
const result = await request<any>(
@@ -2412,6 +2771,33 @@ export const api = {
request(`/api/admin/users${params ? `?${params.toString()}` : ""}`),
getUserById: (id: number) => request(`/api/admin/users/${id}`),
getUserUsage: (id: number) => request(`/api/admin/users/${id}/usage`),
listUserBalances: (params?: { page?: number; pageSize?: number; q?: string }) => {
const sp = new URLSearchParams();
if (params?.page) sp.set("page", String(params.page));
if (params?.pageSize) sp.set("page_size", String(params.pageSize));
if (params?.q) sp.set("q", params.q);
const qs = sp.toString();
return request<{
data: Array<{
userId: number;
username?: string | null;
email?: string | null;
role?: string | null;
status?: string | null;
keyCount: number;
usedCost: number;
maxCost: number | null;
remainingCost: number | null;
unlimitedKeyCount: number;
}>;
meta: { page: number; pageSize: number; total: number; totalPages: number };
}>(`/api/admin/user-balances${qs ? `?${qs}` : ""}`);
},
adjustUserBalance: (id: number, delta: number) =>
request<{ success: boolean; data: { userId: number; keyId: number; maxCost: number; usedCost: number; remainingCost: number } }>(
`/api/admin/users/${id}/balance/adjust`,
{ method: "POST", body: JSON.stringify({ delta }) },
),
createUser: (data: { username: string; email: string; password: string; role?: string }) =>
request("/api/admin/users", { method: "POST", body: JSON.stringify(data) }),
updateUser: (id: number, data: { role?: string; status?: string; username?: string; email?: string; password?: string }) =>
@@ -2545,6 +2931,15 @@ export const userApi = {
},
getAnnouncements: (params?: string) =>
userRequest(`/api/user/announcements${params ? "?" + params : ""}`),
getBillingLedger: (params?: {
track?: string;
entryType?: string;
limit?: number;
offset?: number;
}) =>
userRequest<BillingLedgerResponse>(`/api/user/billing/ledger${buildQueryString(params)}`),
getBillingSummary: () =>
userRequest<BillingSummaryResponse>("/api/user/billing/summary"),
markAnnouncementRead: (id: number) =>
userRequest(`/api/user/announcements/${id}/read`, { method: "POST" }),
markAllAnnouncementsRead: () =>
+56
View File
@@ -7,6 +7,8 @@ import { MODEL_TESTER_STORAGE_KEY } from './helpers/modelTesterSession.js';
const { userApiMock, toastMock } = vi.hoisted(() => ({
userApiMock: {
getLogs: vi.fn(),
getBillingLedger: vi.fn(),
getBillingSummary: vi.fn(),
},
toastMock: {
success: vi.fn(),
@@ -503,4 +505,58 @@ describe('UsageRecords diagnostics', () => {
expect(collectText(root.root.findByProps({ 'data-testid': 'location-probe' }))).toBe('/user/usage');
});
it('switches to the points track and loads ledger rows', async () => {
userApiMock.getBillingSummary.mockResolvedValue({
success: true,
summary: [
{ track: 'points', entryType: 'recharge', total: 500, count: 1 },
{ track: 'points', entryType: 'consume', total: -10, count: 2 },
],
});
userApiMock.getBillingLedger.mockResolvedValue({
success: true,
records: [
{
track: 'points',
entryType: 'consume',
amount: -10,
balanceAfter: 490,
model: 'flux',
createdAt: '2026-06-16T08:00:00Z',
},
],
});
let root!: ReturnType<typeof create>;
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/user/usage']}>
<UsageRecords />
</MemoryRouter>,
);
});
await flushMicrotasks();
// default USD track: billing endpoints not called yet
expect(userApiMock.getBillingLedger).not.toHaveBeenCalled();
const pointsTab = root.root.find((node) => (
node.type === 'button'
&& node.props['data-track'] === 'points'
));
await act(async () => {
pointsTab.props.onClick();
await Promise.resolve();
});
await flushMicrotasks();
expect(userApiMock.getBillingLedger).toHaveBeenCalledWith({
track: 'points',
limit: 100,
});
expect(userApiMock.getBillingSummary).toHaveBeenCalled();
const text = collectText(root.root);
expect(text).toContain('flux');
});
});
+124
View File
@@ -1,6 +1,7 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { userApi } from '../api.js';
import type { BillingLedgerEntry, BillingSummaryRow } from '../api.js';
import { MobileCard, MobileField } from '../components/MobileCard.js';
import PageHeader from '../components/PageHeader.js';
import ResponsiveFilterPanel from '../components/ResponsiveFilterPanel.js';
@@ -279,6 +280,18 @@ function renderContentDetail(log: LogItem) {
);
}
const LEDGER_ENTRY_TYPE_LABELS: Record<string, string> = {
recharge: '充值',
consume: '消费',
admin_adjust: '管理员调整',
refund: '退款',
grant: '赠送',
};
function ledgerEntryTypeLabel(entryType: string): string {
return LEDGER_ENTRY_TYPE_LABELS[entryType] || entryType;
}
export default function UsageRecords() {
const isMobile = useIsMobile();
const location = useLocation();
@@ -298,6 +311,10 @@ export default function UsageRecords() {
const [dateTo, setDateTo] = useState(initialFilters.dateTo);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [showFilters, setShowFilters] = useState(false);
const [track, setTrack] = useState<'usd' | 'points'>('usd');
const [ledger, setLedger] = useState<BillingLedgerEntry[]>([]);
const [ledgerSummary, setLedgerSummary] = useState<BillingSummaryRow[]>([]);
const [ledgerLoading, setLedgerLoading] = useState(false);
const toast = useToast();
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -324,6 +341,28 @@ export default function UsageRecords() {
}
}, [toast]);
const loadLedger = useCallback(async () => {
setLedgerLoading(true);
try {
const [ledgerRes, summaryRes] = await Promise.all([
userApi.getBillingLedger({ track: 'points', limit: 100 }),
userApi.getBillingSummary(),
]);
setLedger(ledgerRes.records || []);
setLedgerSummary(summaryRes.summary || []);
} catch (err: any) {
toast.error(err?.message || '加载积分明细失败');
} finally {
setLedgerLoading(false);
}
}, [toast]);
useEffect(() => {
if (track === 'points') {
loadLedger();
}
}, [track, loadLedger]);
useEffect(() => {
const next = readUserUsageFiltersFromSearch(location.search);
setPage((value) => (value === next.page ? value : next.page));
@@ -676,6 +715,89 @@ export default function UsageRecords() {
)}
/>
<div className="usage-records-track-tabs" role="tablist">
<button
type="button"
role="tab"
data-track="usd"
aria-selected={track === 'usd'}
className={`btn btn-sm ${track === 'usd' ? 'btn-primary' : 'btn-ghost'}`}
onClick={() => setTrack('usd')}
>
USD 调用日志
</button>
<button
type="button"
role="tab"
data-track="points"
aria-selected={track === 'points'}
className={`btn btn-sm ${track === 'points' ? 'btn-primary' : 'btn-ghost'}`}
onClick={() => setTrack('points')}
>
积分明细
</button>
</div>
{track === 'points' ? (
<>
<div className="usage-records-summary-grid">
{ledgerSummary.length === 0 ? (
<div className="card usage-records-summary-card">
<div>积分汇总</div>
<strong>{ledgerLoading ? '加载中' : '暂无'}</strong>
</div>
) : (
ledgerSummary.map((row) => (
<div key={`${row.track}-${row.entryType}`} className="card usage-records-summary-card">
<div>{ledgerEntryTypeLabel(row.entryType)}</div>
<strong style={{ color: row.total < 0 ? 'var(--color-danger)' : 'var(--color-success)' }}>
{row.total > 0 ? '+' : ''}{row.total.toLocaleString()}
</strong>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{row.count} 笔</div>
</div>
))
)}
</div>
<div className="card usage-records-list-card">
{ledgerLoading ? (
<div className="usage-records-empty">
<div className="spinner" style={{ margin: '0 auto' }} />
</div>
) : ledger.length === 0 ? (
<div className="usage-records-empty">暂无积分记录</div>
) : (
<div className="usage-records-table-wrap">
<table className="data-table">
<thead>
<tr>
<th>时间</th>
<th>类型</th>
<th>模型</th>
<th>变动</th>
<th>余额</th>
</tr>
</thead>
<tbody>
{ledger.map((row, idx) => (
<tr key={idx}>
<td>{row.createdAt ? formatDateTimeLocal(row.createdAt) : '-'}</td>
<td>{ledgerEntryTypeLabel(row.entryType)}</td>
<td>{row.model || '-'}</td>
<td style={{ color: row.amount < 0 ? 'var(--color-danger)' : 'var(--color-success)' }}>
{row.amount > 0 ? '+' : ''}{row.amount.toLocaleString()}
</td>
<td>{row.balanceAfter.toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</>
) : (
<>
{/* Summary cards */}
<div className="usage-records-summary-grid">
{[
@@ -863,6 +985,8 @@ export default function UsageRecords() {
</div>
</div>
</div>
</>
)}
</div>
);
}