Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c3293e007f | |||
| a1b7b60ad4 | |||
| bfd804f719 | |||
| e7f3da5961 | |||
| 5cfd533d89 | |||
| 0b7abed79a | |||
| a89edf6f75 | |||
| 5d42671172 | |||
| ca9e45c59c | |||
| be7538a1da | |||
| cfe2098b73 | |||
| bd464016a8 | |||
| 7e8430934e | |||
| ab2fd271e8 | |||
| 4dd2be4cc3 | |||
| fd94303433 | |||
| 91dcbc5c53 | |||
| 278b0c66b8 |
@@ -0,0 +1,694 @@
|
||||
# 计费闭环 实现计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 为 BoosAPI 建立 ledger-first 的双轨计费闭环(USD 池 + 工作流积分池),所有余额变动写统一流水账 `credit_ledger`。
|
||||
|
||||
**Architecture:** 新建 `credit_ledger`(唯一真相源)+ `user_credits`(积分缓存)+ `recharge_tiers`(充值档位)三张表。USD 轨沿用 `downstream_api_keys.max_cost/used_cost` 作热路径缓存,每笔变动同事务写一条 ledger。积分轨为工作流执行新增扣费/拦截。分 6 期,本计划详写第 1 期(地基),后 5 期为概要待细化。
|
||||
|
||||
**Tech Stack:** Rust (axum + sqlx + SQLite)、React + Vite 前端、in-memory SQLite 测试。
|
||||
|
||||
---
|
||||
|
||||
## 文件结构(第 1 期)
|
||||
|
||||
- 新建 `src/services/billing/ledger.rs` — ledger 写入 + 余额查询的唯一入口(`record_ledger_entry`、`ensure_ledger_schema`)。所有动余额的地方调它,保证"缓存 + ledger 同事务"不变量集中在一处。
|
||||
- 修改 `src/services/billing/mod.rs` — `mod ledger;` 导出;`record_usage` 在写 usage_logs + increment 后,同事务补写 ledger(usd, consume)。
|
||||
- 修改 `src/services/payment.rs` — `credit_user_balance` 加写 ledger(usd, recharge)。
|
||||
- 修改 `src/routes/admin/users.rs` — `adjust_user_balance` 加写 ledger(usd, admin_adjust)。
|
||||
- 测试:`src/services/billing/ledger.rs` 内 `#[cfg(test)] mod tests`(复用 in-memory SQLite 模式)。
|
||||
|
||||
---
|
||||
|
||||
## 第 1 期:地基(credit_ledger + user_credits 表 + USD 轨记账接入)
|
||||
|
||||
### Task 1:ledger 表结构 + 写入入口
|
||||
|
||||
**Files:**
|
||||
- Create: `src/services/billing/ledger.rs`
|
||||
- Modify: `src/services/billing/mod.rs`(顶部加 `pub mod ledger;`)
|
||||
|
||||
- [ ] **Step 1: 写失败测试** — 在 `src/services/billing/ledger.rs` 末尾:
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
|
||||
async fn mem_pool() -> SqlitePool {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
ensure_ledger_schema(&pool).await.unwrap();
|
||||
pool
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn record_ledger_entry_writes_row_with_balance_snapshot() {
|
||||
let pool = mem_pool().await;
|
||||
let mut tx = pool.begin().await.unwrap();
|
||||
record_ledger_entry(
|
||||
&mut tx,
|
||||
LedgerEntry {
|
||||
user_id: 7,
|
||||
track: "usd",
|
||||
entry_type: "recharge",
|
||||
amount: 10.0,
|
||||
balance_after: 10.0,
|
||||
ref_type: Some("stripe"),
|
||||
ref_id: Some("cs_1"),
|
||||
model: None,
|
||||
operator: Some("system"),
|
||||
note: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let row: (i64, String, String, f64, f64) = sqlx::query_as(
|
||||
"SELECT user_id, track, entry_type, amount, balance_after
|
||||
FROM credit_ledger WHERE user_id = 7",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(row, (7, "usd".into(), "recharge".into(), 10.0, 10.0));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行测试确认失败**
|
||||
|
||||
Run: `cargo test -p boosapi --lib services::billing::ledger 2>&1 | tail -5`
|
||||
Expected: 编译失败(`ensure_ledger_schema`/`record_ledger_entry`/`LedgerEntry` 未定义)。
|
||||
|
||||
- [ ] **Step 3: 写最小实现** — 在 `src/services/billing/ledger.rs` 顶部(tests 之前):
|
||||
|
||||
```rust
|
||||
use crate::error::AppResult;
|
||||
use sqlx::{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.
|
||||
pub struct LedgerEntry<'a> {
|
||||
pub user_id: i64,
|
||||
pub track: &'a str, // "usd" | "points"
|
||||
pub entry_type: &'a str, // "recharge" | "admin_adjust" | "consume" | "refund" | "grant"
|
||||
pub amount: f64, // +credit / -debit
|
||||
pub balance_after: f64,
|
||||
pub ref_type: Option<&'a str>,
|
||||
pub ref_id: Option<&'a str>,
|
||||
pub model: Option<&'a str>,
|
||||
pub operator: Option<&'a str>,
|
||||
pub note: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub async fn ensure_ledger_schema(db: &SqlitePool) -> AppResult<()> {
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS credit_ledger (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
track TEXT NOT NULL,
|
||||
entry_type TEXT NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
balance_after REAL NOT NULL,
|
||||
ref_type TEXT,
|
||||
ref_id TEXT,
|
||||
model TEXT,
|
||||
operator TEXT,
|
||||
note TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"CREATE INDEX IF NOT EXISTS credit_ledger_user_created_idx
|
||||
ON credit_ledger (user_id, created_at DESC)",
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"CREATE INDEX IF NOT EXISTS credit_ledger_track_type_idx
|
||||
ON credit_ledger (track, entry_type)",
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS user_credits (
|
||||
user_id INTEGER PRIMARY KEY NOT NULL,
|
||||
points_balance REAL NOT NULL DEFAULT 0,
|
||||
points_total_recharged REAL NOT NULL DEFAULT 0,
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Append one ledger row inside an existing transaction. Caller is responsible
|
||||
/// for also updating the cached balance in the same `tx`.
|
||||
pub async fn record_ledger_entry(
|
||||
tx: &mut Transaction<'_, sqlx::Sqlite>,
|
||||
entry: LedgerEntry<'_>,
|
||||
) -> AppResult<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO credit_ledger
|
||||
(user_id, track, entry_type, amount, balance_after,
|
||||
ref_type, ref_id, model, operator, note)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(entry.user_id)
|
||||
.bind(entry.track)
|
||||
.bind(entry.entry_type)
|
||||
.bind(entry.amount)
|
||||
.bind(entry.balance_after)
|
||||
.bind(entry.ref_type)
|
||||
.bind(entry.ref_id)
|
||||
.bind(entry.model)
|
||||
.bind(entry.operator)
|
||||
.bind(entry.note)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
并在 `src/services/billing/mod.rs` 顶部加:`pub mod ledger;`
|
||||
|
||||
- [ ] **Step 4: 运行测试确认通过**
|
||||
|
||||
Run: `cargo test -p boosapi --lib services::billing::ledger 2>&1 | tail -5`
|
||||
Expected: PASS(1 passed)。
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add src/services/billing/ledger.rs src/services/billing/mod.rs
|
||||
git commit -m "feat(billing): credit_ledger schema + record_ledger_entry"
|
||||
```
|
||||
|
||||
### Task 2:USD 消费接入 ledger(record_usage)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/services/billing/mod.rs`(`record_usage`,行 ~48-77)
|
||||
|
||||
- [ ] **Step 1: 写失败测试** — 在 billing/mod.rs 的 tests mod 加:
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn record_usage_writes_consume_ledger_entry() {
|
||||
let pool = billing_test_pool().await; // 复用现有测试 pool helper;若无则建 users+downstream_api_keys+usage_logs
|
||||
let tracker = UsageTracker::new(std::sync::Arc::new(pool.clone()));
|
||||
crate::services::billing::ledger::ensure_ledger_schema(&pool).await.unwrap();
|
||||
// key_id=7 属于 user 9,max_cost=10 used_cost=2(由 helper 建好)
|
||||
tracker.record_usage(7, "gpt-4o", 100, 50, 0.42).await.unwrap();
|
||||
|
||||
let row: (String, String, f64, Option<String>) = sqlx::query_as(
|
||||
"SELECT track, entry_type, amount, model FROM credit_ledger
|
||||
WHERE user_id = 9 ORDER BY id DESC LIMIT 1",
|
||||
).fetch_one(&pool).await.unwrap();
|
||||
assert_eq!(row.0, "usd");
|
||||
assert_eq!(row.1, "consume");
|
||||
assert_eq!(row.2, -0.42); // 消费为负
|
||||
assert_eq!(row.3.as_deref(), Some("gpt-4o"));
|
||||
}
|
||||
```
|
||||
|
||||
注:若 billing/mod.rs 无现成 `billing_test_pool`,在 tests mod 加一个建 `users`/`downstream_api_keys`(含 user_id=9,key id=7 max_cost=10 used_cost=2)/`usage_logs` 的 helper(参照 payment.rs 的 payment_test_pool)。
|
||||
|
||||
- [ ] **Step 2: 运行确认失败**
|
||||
|
||||
Run: `cargo test -p boosapi --lib record_usage_writes_consume_ledger_entry 2>&1 | tail -5`
|
||||
Expected: FAIL(credit_ledger 无 user 9 行 / 表不存在)。
|
||||
|
||||
- [ ] **Step 3: 实现** — `record_usage` 改为事务化,末尾补写 ledger。替换 record_usage 函数体为:
|
||||
|
||||
```rust
|
||||
pub async fn record_usage(
|
||||
&self,
|
||||
key_id: i64,
|
||||
model: impl Into<String>,
|
||||
prompt_tokens: i64,
|
||||
completion_tokens: i64,
|
||||
cost: f64,
|
||||
) -> AppResult<()> {
|
||||
Self::ensure_schema(&self.db).await?;
|
||||
crate::services::billing::ledger::ensure_ledger_schema(&self.db).await?;
|
||||
let prompt_tokens = prompt_tokens.max(0);
|
||||
let completion_tokens = completion_tokens.max(0);
|
||||
let total_tokens = prompt_tokens + completion_tokens;
|
||||
let cost = cost.max(0.0);
|
||||
let model = model.into();
|
||||
|
||||
let mut tx = self.db.begin().await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO usage_logs
|
||||
(downstream_key_id, model, prompt_tokens, completion_tokens, total_tokens, cost)
|
||||
VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(key_id).bind(&model)
|
||||
.bind(prompt_tokens).bind(completion_tokens).bind(total_tokens).bind(cost)
|
||||
.execute(&mut *tx).await?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE downstream_api_keys
|
||||
SET used_requests = COALESCE(used_requests,0)+1,
|
||||
used_cost = COALESCE(used_cost,0)+?,
|
||||
last_used_at = datetime('now'), updated_at = datetime('now')
|
||||
WHERE id = ?",
|
||||
).bind(cost).bind(key_id).execute(&mut *tx).await?;
|
||||
|
||||
// Resolve owning user + new used_cost for the ledger snapshot.
|
||||
let owner: Option<(Option<i64>, Option<f64>, Option<f64>)> = sqlx::query_as(
|
||||
"SELECT user_id, max_cost, used_cost FROM downstream_api_keys WHERE id = ?",
|
||||
).bind(key_id).fetch_optional(&mut *tx).await?;
|
||||
if let Some((Some(user_id), max_cost, used_cost)) = owner {
|
||||
let used = used_cost.unwrap_or(0.0);
|
||||
let remaining = max_cost.map(|m| (m - used).max(0.0)).unwrap_or(used);
|
||||
crate::services::billing::ledger::record_ledger_entry(
|
||||
&mut tx,
|
||||
crate::services::billing::ledger::LedgerEntry {
|
||||
user_id, track: "usd", entry_type: "consume",
|
||||
amount: -cost, balance_after: remaining,
|
||||
ref_type: Some("usage_log"), ref_id: None,
|
||||
model: Some(&model), operator: Some("system"), note: None,
|
||||
},
|
||||
).await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
(注:原 `increment_key_usage(&self.db, ...)` 调用被事务内 UPDATE 取代,删除该行。)
|
||||
|
||||
- [ ] **Step 4: 运行确认通过**
|
||||
|
||||
Run: `cargo test -p boosapi --lib record_usage 2>&1 | tail -6`
|
||||
Expected: PASS。
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add src/services/billing/mod.rs
|
||||
git commit -m "feat(billing): USD consume writes credit_ledger in one tx"
|
||||
```
|
||||
|
||||
### Task 3:USD 充值接入 ledger(credit_user_balance)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/services/payment.rs`(`credit_user_balance`,行 ~2167-2202)
|
||||
|
||||
- [ ] **Step 1: 写失败测试** — payment.rs tests mod(复用 payment_test_pool,user 9/key 7 max_cost=10):
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn credit_user_balance_writes_recharge_ledger() {
|
||||
let pool = payment_test_pool().await;
|
||||
crate::services::billing::ledger::ensure_ledger_schema(&pool).await.unwrap();
|
||||
let mut tx = pool.begin().await.unwrap();
|
||||
credit_user_balance(&mut tx, 9, 20.0).await.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
let row: (String, String, f64) = sqlx::query_as(
|
||||
"SELECT track, entry_type, amount FROM credit_ledger WHERE user_id = 9 ORDER BY id DESC LIMIT 1",
|
||||
).fetch_one(&pool).await.unwrap();
|
||||
assert_eq!(row, ("usd".into(), "recharge".into(), 20.0));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行确认失败**
|
||||
|
||||
Run: `cargo test -p boosapi --lib credit_user_balance_writes_recharge_ledger 2>&1 | tail -5`
|
||||
Expected: FAIL。
|
||||
|
||||
- [ ] **Step 3: 实现** — `credit_user_balance` 在 UPDATE max_cost 之后、`Ok(Some(key_id))` 之前插入 ledger 写入(已在事务 tx 内):
|
||||
|
||||
```rust
|
||||
// After the UPDATE that sets next_max_cost on the key:
|
||||
crate::services::billing::ledger::record_ledger_entry(
|
||||
tx,
|
||||
crate::services::billing::ledger::LedgerEntry {
|
||||
user_id, track: "usd", entry_type: "recharge",
|
||||
amount: amount_usd.max(0.0),
|
||||
balance_after: (next_max_cost - used_cost).max(0.0),
|
||||
ref_type: Some("recharge_record"), ref_id: None,
|
||||
model: None, operator: Some("system"), note: None,
|
||||
},
|
||||
).await?;
|
||||
```
|
||||
|
||||
(`credit_user_balance` 已是 `tx: &mut Transaction`,直接传 `tx`。确保调用方在调用前已 `ensure_ledger_schema`,或在函数开头加一次幂等建表。)
|
||||
|
||||
- [ ] **Step 4: 运行确认通过**
|
||||
|
||||
Run: `cargo test -p boosapi --lib credit_user_balance 2>&1 | tail -6`
|
||||
Expected: PASS(含已有的 recharge completion 测试)。
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add src/services/payment.rs
|
||||
git commit -m "feat(billing): USD recharge writes credit_ledger"
|
||||
```
|
||||
|
||||
### Task 4:USD 管理员调整接入 ledger(adjust_user_balance)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/routes/admin/users.rs`(`adjust_user_balance`,行 ~820)
|
||||
|
||||
- [ ] **Step 1: 写失败测试** — users.rs tests mod:调 `adjust_user_balance(State, Path(9), Json{delta:5.0})` 后断言 credit_ledger 有一行 `(usd, admin_adjust, 5.0)`。(建表与 user/key 同 Task 3 helper。)
|
||||
|
||||
- [ ] **Step 2: 运行确认失败** — `cargo test -p boosapi --lib adjust_user_balance 2>&1 | tail -5` → FAIL。
|
||||
|
||||
- [ ] **Step 3: 实现** — `adjust_user_balance` 现在直接 `execute(&*state.db)`。改为事务:begin → UPDATE max_cost → `record_ledger_entry(track="usd", entry_type="admin_adjust", amount=delta, balance_after=next_max-used, operator=<管理员,暂用 "admin">)` → commit。函数开头 `ledger::ensure_ledger_schema(&state.db).await?;`。
|
||||
|
||||
- [ ] **Step 4: 运行确认通过** — `cargo test -p boosapi --lib adjust_user_balance 2>&1 | tail -6` → PASS。
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add src/routes/admin/users.rs
|
||||
git commit -m "feat(billing): USD admin adjust writes credit_ledger"
|
||||
```
|
||||
|
||||
### Task 5:全量回归
|
||||
|
||||
- [ ] **Step 1:** `cargo test 2>&1 | grep "test result"` → 全 ok。
|
||||
- [ ] **Step 2:** `cargo build 2>&1 | tail -3` → Finished。
|
||||
- [ ] **Step 3:** 提交(若有遗留)。
|
||||
|
||||
---
|
||||
|
||||
## 后续期次(概要,第 1 期落地后逐期细化为同样的 TDD 任务)
|
||||
|
||||
### 第 2 期:积分轨
|
||||
- `user_credits` 已建表(Task 1)。新增 `adjust_user_credits`(admin 发放/增减,写 ledger points/admin_adjust)。
|
||||
- comfy_cloud 确认到账 `admin_confirm_user_recharge` 改为加 `points_balance` + ledger(points, recharge),受 config 开关 `payment.comfy_recharge_to_points` 控制(默认 true);历史不迁移。
|
||||
- 工作流积分单价 config(默认值 + 按工作流名分级映射)。
|
||||
- `workflow/executor.rs` `run_workflow`:执行**前**查 `points_balance >= 单价`,不足返回 Forbidden「工作流积分不足」;执行**成功后**扣积分 + ledger(points, consume, model=工作流名)。
|
||||
- 测试:拦截、扣费、充值进积分、管理员调整。
|
||||
|
||||
### 第 3 期:A 消费明细
|
||||
- `GET /api/user/billing/ledger`(查本人 credit_ledger,track/type/时间过滤、分页)。
|
||||
- `GET /api/user/billing/summary`(按天/模型聚合,双轨分开)。
|
||||
- 扩展 `UsageRecords.tsx`:双轨切换 + 汇总卡 + 明细列表。
|
||||
|
||||
### 第 4 期:B 充值档位
|
||||
- `recharge_tiers` 表(已在 spec,Task 1 未建,本期建)+ admin CRUD `/api/admin/recharge-tiers`。
|
||||
- 用户充值档位从 DB 读(替代 env `USER_RECHARGE_OPTIONS`)。
|
||||
- admin `RechargeTiers` 配置页 + `UserRecharge.tsx` 接 DB。
|
||||
|
||||
### 第 5 期:C 预警
|
||||
- 余额接口(user_billing_summary 等)加 `lowBalance`(余额 < 阈值,config 默认 0.2)。
|
||||
- Dashboard / UserRecharge / ComfyUIAgent 顶部预警条。
|
||||
|
||||
### 第 6 期:D 审计 + 对账
|
||||
- `GET /api/admin/billing/ledger`(跨用户,过滤分页)+ admin `BillingAudit` 页。
|
||||
- cron:拉 Comfy `/customers/balance` 总余额,与 points consume ledger 比对,偏差超阈值写 log + admin 告警标志(不自动改数据)。
|
||||
|
||||
---
|
||||
|
||||
## 自审
|
||||
|
||||
- **spec 覆盖**:第 1 期覆盖 spec「数据模型」+「USD 轨记账接入」三处(充值/消费/调整);后 5 期概要逐条对应 spec 的积分轨/A/B/C/D + 对账。recharge_tiers 表 spec 列在数据模型,实现挪到第 4 期建(第 1 期用不到),已注明。
|
||||
- **占位扫描**:第 1 期 Task 1-3 含完整代码;Task 4-5 用文字描述但步骤明确(adjust 与 Task 3 同模式,刻意不重复大段代码,执行时参照 Task 3)。后 5 期为概要,执行前细化。
|
||||
- **类型一致**:`LedgerEntry` 字段、`record_ledger_entry`/`ensure_ledger_schema` 签名在 Task 1 定义,Task 2-4 一致引用。`track`/`entry_type` 取值(usd/points、recharge/consume/admin_adjust)全程一致。
|
||||
|
||||
---
|
||||
|
||||
## 第 2 期:积分轨(细化 TDD)
|
||||
|
||||
文件结构:
|
||||
- `src/services/billing/ledger.rs` 加积分轨核心:`points_balance(db,user_id)`(读余额)、`apply_points_delta(db, user_id, delta, entry_type, model, operator, ref_*)`(事务内:UPSERT user_credits 余额 + 写 ledger(points),返回新余额)。所有积分增减走这一个入口。
|
||||
- `src/config.rs`:ComfyUIConfig 加 `workflow_points_cost`(默认单价)。
|
||||
- `src/workflow/executor.rs`:run_workflow_with_progress 前置拦截 + 收尾扣积分。
|
||||
- `src/services/payment.rs`:admin_confirm_user_recharge 改为加积分(受 config 开关)。
|
||||
- `src/routes/admin/users.rs`:新增 `adjust_user_credits` handler + 路由。
|
||||
|
||||
### Task 6:积分轨核心 apply_points_delta + points_balance
|
||||
|
||||
**Files:** Modify `src/services/billing/ledger.rs`
|
||||
|
||||
- [ ] **Step1 失败测试**(ledger.rs tests mod):
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn apply_points_delta_upserts_balance_and_writes_ledger() {
|
||||
let pool = mem_pool().await;
|
||||
// 首次 +100(充值)
|
||||
let bal = apply_points_delta(&pool, 9, 100.0, "recharge", None, Some("system"), Some("recharge_record"), None).await.unwrap();
|
||||
assert_eq!(bal, 100.0);
|
||||
// 再 -30(消费)
|
||||
let bal2 = apply_points_delta(&pool, 9, -30.0, "consume", Some("wf/video"), Some("system"), Some("workflow"), None).await.unwrap();
|
||||
assert_eq!(bal2, 70.0);
|
||||
// user_credits 缓存 = 70
|
||||
let cached: f64 = sqlx::query_scalar("SELECT points_balance FROM user_credits WHERE user_id = 9").fetch_one(&pool).await.unwrap();
|
||||
assert_eq!(cached, 70.0);
|
||||
// ledger 两行
|
||||
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);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step2** `cargo test -p boosapi --lib apply_points_delta_upserts 2>&1 | tail -6` → FAIL。
|
||||
|
||||
- [ ] **Step3 实现**(ledger.rs,tests 之前):
|
||||
|
||||
```rust
|
||||
/// Current points balance for a user (0 if no row yet).
|
||||
pub async fn points_balance(db: &SqlitePool, user_id: i64) -> AppResult<f64> {
|
||||
let bal: Option<f64> = sqlx::query_scalar(
|
||||
"SELECT points_balance FROM user_credits WHERE user_id = ?",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
Ok(bal.unwrap_or(0.0))
|
||||
}
|
||||
|
||||
/// Apply a points delta (+credit / -debit) atomically: upsert the cached
|
||||
/// balance in `user_credits` and append one `credit_ledger(track=points)` row,
|
||||
/// in a single transaction. Returns the new balance.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn apply_points_delta(
|
||||
db: &SqlitePool,
|
||||
user_id: i64,
|
||||
delta: f64,
|
||||
entry_type: &str,
|
||||
model: Option<&str>,
|
||||
operator: Option<&str>,
|
||||
ref_type: Option<&str>,
|
||||
ref_id: Option<&str>,
|
||||
) -> AppResult<f64> {
|
||||
ensure_ledger_schema(db).await?;
|
||||
let mut tx = db.begin().await?;
|
||||
let current: f64 = sqlx::query_scalar(
|
||||
"SELECT points_balance FROM user_credits WHERE user_id = ?",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.unwrap_or(0.0);
|
||||
let next = current + delta;
|
||||
let total_recharged_inc = if delta > 0.0 && entry_type == "recharge" { delta } else { 0.0 };
|
||||
sqlx::query(
|
||||
"INSERT INTO user_credits (user_id, points_balance, points_total_recharged, updated_at)
|
||||
VALUES (?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
points_balance = ?,
|
||||
points_total_recharged = points_total_recharged + ?,
|
||||
updated_at = datetime('now')",
|
||||
)
|
||||
.bind(user_id).bind(next).bind(total_recharged_inc)
|
||||
.bind(next).bind(total_recharged_inc)
|
||||
.execute(&mut *tx).await?;
|
||||
record_ledger_entry(&mut tx, LedgerEntry {
|
||||
user_id, track: "points", entry_type,
|
||||
amount: delta, balance_after: next,
|
||||
ref_type, ref_id, model, operator, note: None,
|
||||
}).await?;
|
||||
tx.commit().await?;
|
||||
Ok(next)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step4** `cargo test -p boosapi --lib apply_points_delta 2>&1 | tail -6` → PASS;`cargo build -j 2 | tail -2`。
|
||||
- [ ] **Step5** `git add src/services/billing/ledger.rs && git commit -m "feat(billing): points track apply_points_delta + balance"`
|
||||
|
||||
### Task 7:工作流积分单价 config
|
||||
|
||||
**Files:** Modify `src/config.rs`
|
||||
|
||||
- [ ] **Step1** 测试:config 默认值测试(若 config.rs 有 default 测试则加断言;否则跳测试,靠编译)。
|
||||
- [ ] **Step2/3 实现**:ComfyUIConfig 加字段 `#[serde(default = "default_workflow_points_cost")] pub workflow_points_cost: f64,`,default fn 返回 `10.0`;Default impl 里加 `workflow_points_cost: 10.0,`。
|
||||
- [ ] **Step4** `cargo build -j 2 2>&1 | tail -3` → Finished(config 字段加完整个 crate 能编译)。
|
||||
- [ ] **Step5** `git add src/config.rs && git commit -m "feat(billing): workflow_points_cost config"`
|
||||
|
||||
### Task 8:工作流执行前置拦截 + 成功扣积分
|
||||
|
||||
**Files:** Modify `src/workflow/executor.rs`
|
||||
|
||||
- [ ] **Step1 失败测试**:executor.rs tests mod(若无则建)。建 in-memory pool + user_credits/credit_ledger,构造一个最简 graph,`ctx.user_id=Some(9)`:
|
||||
- 余额不足(0 积分,单价 10)→ run_workflow_with_progress 返回 Err(Forbidden 含"积分不足")。
|
||||
- 余额充足(100)→ 执行成功后 points_balance 变 90,credit_ledger 出现一行 (points, consume, -10)。
|
||||
(若 executor 难以单测真实节点,则把"拦截"和"扣费"逻辑抽成小函数 `check_workflow_points(state, user_id, cost)` 和 `charge_workflow_points(state, user_id, cost, workflow_name)` 各自单测,run_workflow 调它们。优先抽函数单测。)
|
||||
|
||||
- [ ] **Step2** 确认 FAIL。
|
||||
|
||||
- [ ] **Step3 实现**:
|
||||
- 在 run_workflow_with_progress 的 `validate_and_sort` 之后、`insert_run` 之前:若 `ctx.user_id` 是 Some 且 `ctx.charge_points`(见下):`let cost = state.config.comfyui.workflow_points_cost; if cost > 0.0 { let bal = ledger::points_balance(&state.db, uid).await?; if bal < cost { return Err(AppError::Forbidden("工作流积分不足,请充值".into())); } }`
|
||||
- 在 `complete_run` 之后、`Ok(run_public_id)` 之前:`if let Some(uid) = ctx.user_id { let cost = state.config.comfyui.workflow_points_cost; if cost > 0.0 { let _ = ledger::apply_points_delta(&state.db, uid, -cost, "consume", Some(&format!("workflow:{}", workflow_id)), Some("system"), Some("workflow_run"), Some(&run_public_id)).await; } }`(扣费 best-effort,失败记 log 不阻断已产出结果)。
|
||||
- 注意:Admin 调用(builtin key,user_id 可能是 Some(admin) 或特殊)是否扣?按设计仅对普通用户扣。可用 RunContext 加 `pub charge_points: bool` 字段,run_context 构造时 User=>true / Admin=>false。models.rs RunContext 加该字段,两处构造(routes.rs run_context、workflow_ui.rs workflow_context)补 `charge_points`。
|
||||
|
||||
- [ ] **Step4** 测试 PASS + `cargo build -j 2`。
|
||||
- [ ] **Step5** `git add src/workflow/executor.rs src/workflow/models.rs src/workflow/routes.rs src/routes/workflow_ui.rs && git commit -m "feat(billing): workflow points gating + charge"`
|
||||
|
||||
### Task 9:comfy 充值改加积分 + admin 积分调整
|
||||
|
||||
**Files:** Modify `src/services/payment.rs`、`src/routes/admin/users.rs`、`src/config.rs`(开关)、`src/routes/admin/mod.rs`(路由)
|
||||
|
||||
- [ ] **Step1 失败测试**:
|
||||
- payment.rs:`admin_confirm_user_recharge` 确认后,credit_ledger 出现 (points, recharge, +amount)(而非 usd);user_credits 增加。
|
||||
- users.rs:`adjust_user_credits` handler 调用后 credit_ledger 出现 (points, admin_adjust)。
|
||||
- [ ] **Step2** FAIL。
|
||||
- [ ] **Step3 实现**:
|
||||
- config 开关 `payment.comfy_recharge_to_points: bool`(默认 true)。
|
||||
- `admin_confirm_user_recharge`:开关为 true 时改调 `ledger::apply_points_delta(db, user_id, amount_usd_as_points, "recharge", None, Some("admin"), Some("recharge_record"), Some(public_id))`(USD→积分换算:本期 1 USD = N 积分,N 用 config `payment.usd_to_points_rate` 默认 100;即 grant_points = amount_usd * rate)。开关 false 时维持原 credit_user_balance(USD)。**历史不迁移**。
|
||||
- 新增 `adjust_user_credits(State, Path(id), Json{delta})` handler(仿 adjust_user_balance)→ `apply_points_delta(db,id,delta,"admin_adjust",None,Some("admin"),Some("manual"),None)`,返回 {success, data{userId, pointsBalance}}。
|
||||
- admin/mod.rs 注册 `POST /api/admin/users/{id}/credits/adjust`。
|
||||
- [ ] **Step4** 相关测试 + payment/users 模块全过 + build。
|
||||
- [ ] **Step5** 分两个 commit 或一个:`git commit -m "feat(billing): comfy recharge to points + admin credit adjust"`
|
||||
|
||||
### 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`。
|
||||
@@ -0,0 +1,149 @@
|
||||
# 计费闭环系统设计
|
||||
|
||||
日期:2026-06-16
|
||||
状态:设计已获批,待写实现计划
|
||||
|
||||
## 背景
|
||||
|
||||
BoosAPI 现有计费是零散拼接的:每个用户的 downstream API key 有 `max_cost`(美元上限)/ `used_cost`(已消耗),proxy 请求路径在 `used_cost >= max_cost` 时硬拦截返回 429;充值通过 Stripe 自动到账或 comfy_cloud 管理员确认到账,调用 `credit_user_balance` 增加 `max_cost`;新用户默认 $1 额度;usage_logs 按 key 记每笔消费。
|
||||
|
||||
缺口:ComfyUI 工作流执行不扣额度、无积分体系、无消费明细可视化、无充值档位配置、无低额度预警、无全量变动审计。
|
||||
|
||||
本设计把上述补齐为一个 **ledger-first(统一流水账)** 的计费闭环。
|
||||
|
||||
## 核心模型:双轨额度
|
||||
|
||||
- **USD 轨**:LLM 对话、生图、视频等所有走 `/v1/*` 的调用。沿用 `downstream_api_keys.max_cost/used_cost`。
|
||||
- **工作流积分轨**:仅 ComfyUI 工作流执行。新建每用户独立的积分池,平台可管理。
|
||||
|
||||
两轨并行、互不换算。comfy_cloud 充值进积分轨;其他充值(Stripe)进 USD 轨。
|
||||
|
||||
## 数据模型
|
||||
|
||||
### 新表 `credit_ledger`(唯一真相源,所有变动记一行)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `id` | INTEGER PK AUTOINCREMENT | |
|
||||
| `user_id` | INTEGER NOT NULL | 所属用户 |
|
||||
| `track` | TEXT NOT NULL | `usd` / `points` |
|
||||
| `entry_type` | TEXT NOT NULL | `recharge` / `admin_adjust` / `consume` / `refund` / `grant` |
|
||||
| `amount` | REAL NOT NULL | 正=增 负=减(usd 用美元,points 用积分) |
|
||||
| `balance_after` | REAL NOT NULL | 该轨本次变动后余额快照 |
|
||||
| `ref_type` | TEXT | `usage_log` / `recharge_record` / `manual` / `stripe` 等 |
|
||||
| `ref_id` | TEXT | 关联记录 id/public_id |
|
||||
| `model` | TEXT | 消费类:模型名或工作流名(供聚合) |
|
||||
| `operator` | TEXT | 操作者:管理员名 / `system` / `user` |
|
||||
| `note` | TEXT | 备注 |
|
||||
| `created_at` | TEXT DEFAULT (datetime('now')) | |
|
||||
|
||||
索引:`(user_id, created_at DESC)`、`(track, entry_type)`、`(user_id, track)`。
|
||||
|
||||
### 新表 `user_credits`(积分余额缓存)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `user_id` | INTEGER PK | |
|
||||
| `points_balance` | REAL NOT NULL DEFAULT 0 | 当前积分余额(热路径读写) |
|
||||
| `points_total_recharged` | REAL NOT NULL DEFAULT 0 | 累计充值积分(统计) |
|
||||
| `updated_at` | TEXT | |
|
||||
|
||||
### 新表 `recharge_tiers`(充值档位,admin 可配)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `id` | INTEGER PK AUTOINCREMENT | |
|
||||
| `track` | TEXT NOT NULL | `usd` / `points` |
|
||||
| `amount_usd` | REAL NOT NULL | 支付金额(USD) |
|
||||
| `grant_amount` | REAL NOT NULL | 到账量(USD 数或积分数) |
|
||||
| `bonus` | REAL NOT NULL DEFAULT 0 | 赠送量 |
|
||||
| `title` | TEXT | 档位标题 |
|
||||
| `sort_order` | INTEGER DEFAULT 0 | 排序 |
|
||||
| `enabled` | INTEGER DEFAULT 1 | 启用 |
|
||||
|
||||
### 余额派生策略:ledger + 缓存双写
|
||||
|
||||
- **USD 轨**:`max_cost/used_cost` 作快速余额缓存(拦截/展示零延迟读),每笔变动**同事务**写一条 `credit_ledger(track=usd)`。
|
||||
- **积分轨**:`user_credits.points_balance` 作缓存,每笔变动**同事务**写 `credit_ledger(track=points)`。
|
||||
- **关键不变量**:任何余额变动 = 缓存字段更新 + 一条 ledger,**同一 DB 事务**,事务失败整体回滚,绝不只改其一。
|
||||
- ledger 是审计/明细/对账真相;缓存字段是热路径性能优化。
|
||||
|
||||
## 双轨记账接入
|
||||
|
||||
原则:所有动余额处,同事务"改缓存 + 写一条 ledger"。
|
||||
|
||||
### USD 轨
|
||||
|
||||
| 变动 | 现有代码 | 改造 |
|
||||
|---|---|---|
|
||||
| 充值到账 | `credit_user_balance`(payment.rs) | 同事务加写 ledger(usd, recharge) |
|
||||
| 消费扣费 | `increment_key_usage`/`record_billing` | 同事务加写 ledger(usd, consume, model=模型名) |
|
||||
| 管理员增减 | `adjust_user_balance` | 同事务加写 ledger(usd, admin_adjust, operator) |
|
||||
| 额度拦截 | proxy `used_cost>=max_cost`→429 | 不变,补低额度预警信号 |
|
||||
|
||||
### 积分轨(基本新建)
|
||||
|
||||
| 变动 | 现状 | 新增 |
|
||||
|---|---|---|
|
||||
| 充值到账 | comfy_cloud 确认到账现加 USD | 改为加 `points_balance` + ledger(points, recharge) |
|
||||
| 消费扣费 | 工作流不扣 | 工作流执行**成功后**按单价扣积分 + ledger(points, consume, model=工作流名);失败不扣 |
|
||||
| 管理员发放/增减 | 无 | 新增 `adjust_user_credits` + ledger(points, admin_adjust) |
|
||||
| 积分拦截 | 无 | 工作流执行**前**检查 `points_balance >= 单价`,不足拒绝;低额度预警 |
|
||||
|
||||
- 积分消费扣费点:`workflow/executor.rs` 的 `run_workflow` 成功收尾处(有 `ctx.user_id`),与现有"工作流按用户 key 记 USD 消费"平行。
|
||||
- **comfy_cloud 充值改向积分轨**:行为变更,历史 USD **不迁移**,仅对切换后新充值生效,用 config 开关控制(可回滚)。
|
||||
|
||||
## 积分消费规则:A+C 混合
|
||||
|
||||
- **A 固定/分级单价**(主):工作流执行扣固定积分(config 默认值,可按工作流类型分级)。可预测、支持并发。
|
||||
- **C 对账校准**(兜底):后台 cron 拉 Comfy 总余额对账,偏差超阈值提示管理员调单价。
|
||||
|
||||
## 四块功能
|
||||
|
||||
### A 消费明细(用户侧,明细+聚合)
|
||||
- 新建 `GET /api/user/billing/ledger`:查该用户 credit_ledger,可按 track/type/时间过滤。
|
||||
- 新建 `GET /api/user/billing/summary`:按天/按模型聚合,双轨分开。
|
||||
- 扩展 `UsageRecords.tsx`:双轨切换 + 聚合汇总卡 + 明细列表。
|
||||
|
||||
### B 充值档位(本期可配置,订阅后期)
|
||||
- 新建 `recharge_tiers` 表 + admin CRUD `GET/POST/PUT/DELETE /api/admin/recharge-tiers`。
|
||||
- 改造用户充值:档位从 DB 读(替代 env 写死的 `USER_RECHARGE_OPTIONS`)。
|
||||
- 扩展 `UserRecharge.tsx`(档位从接口拉)+ 新建 admin `RechargeTiers` 配置页。
|
||||
|
||||
### C 超额 + 预警(两轨硬拦 + 预警)
|
||||
- USD 拦截已有(不动);积分拦截新建(工作流执行前)。
|
||||
- 余额接口加 `lowBalance` 标志(余额 < 阈值,config 可配默认 20%)。
|
||||
- Dashboard/充值页/视频助手:余额低显示预警条。
|
||||
|
||||
### D 审计对账(管理员侧,全量流水)
|
||||
- 新建 `GET /api/admin/billing/ledger`:跨用户查 credit_ledger(按 user/track/type/时间过滤、分页)。
|
||||
- 新建 admin `BillingAudit` 页:全量变动流水表(谁/何时/给谁/加减/原因),站点管理风格。
|
||||
- 对账校准:cron 拉 Comfy 总余额,与积分消费 ledger 比对,偏差超阈值写 log + admin 告警标志。**不自动改数据**,仅提示,管理员手动调单价。
|
||||
|
||||
## 错误处理(不变量)
|
||||
|
||||
- 余额变动 = 缓存 + ledger 同事务;失败整体回滚。
|
||||
- 消费扣费 best-effort 不阻断响应(沿用 record_billing 原则)。
|
||||
- 积分轨:拦截是前置硬检查,扣费是后置;后置扣费失败已产出结果 → 记日志补偿。
|
||||
- comfy 充值改向积分:config 开关控制,可回滚。
|
||||
|
||||
## 测试策略
|
||||
|
||||
- 单元:ledger 写入+缓存同步事务一致性;积分拦截(不足拒绝);单价扣费;充值进积分;管理员调整双轨。
|
||||
- 复用现有 payment/billing 测试框架(in-memory SQLite)。
|
||||
|
||||
## 实现顺序(分 6 期,每期可独立上线)
|
||||
|
||||
1. **地基**:credit_ledger + user_credits 表 + 迁移;USD 轨记账接进 ledger(充值/消费/调整三处双写)。
|
||||
2. **积分轨**:工作流积分扣费 + 拦截 + comfy 充值改进积分 + 管理员积分调整。
|
||||
3. **A 消费明细**:用户侧 ledger/summary 接口 + UsageRecords 扩展。
|
||||
4. **B 充值档位**:recharge_tiers 表 + admin 配置页 + 用户充值页接 DB。
|
||||
5. **C 预警**:余额接口加 lowBalance + 前端预警条。
|
||||
6. **D 审计 + 对账**:admin BillingAudit 页 + cron 对账校准。
|
||||
|
||||
## 新增清单小结
|
||||
|
||||
- **表**:credit_ledger(核心)、user_credits(积分缓存)、recharge_tiers(档位)。
|
||||
- **后端接口**:user billing ledger/summary、admin recharge-tiers CRUD、admin billing ledger、adjust_user_credits、积分拦截/扣费。
|
||||
- **前端页面**:admin RechargeTiers、admin BillingAudit;扩展 UsageRecords / UserRecharge / Dashboard。
|
||||
- **cron**:Comfy 余额对账校准任务。
|
||||
@@ -0,0 +1,100 @@
|
||||
# NIT AI Studio — 第一阶段(聚合图像/数字人)设计
|
||||
|
||||
日期:2026-06-17
|
||||
分支:`nit_edu`
|
||||
状态:已通过 brainstorming,待落实现计划
|
||||
|
||||
## 背景
|
||||
|
||||
NIT 精英班专属的「AI 综合应用软件」是一套面向精英班学员的 AI 创作工具,完整愿景涵盖
|
||||
文本结构化创作(论文/小说/剧本/分镜/教材)、PPT 生成、图像(P图/制图)、视频
|
||||
(文生/图生/短视频)、数字人。体量过大,分阶段建设。
|
||||
|
||||
**本 spec 仅覆盖 AI Studio 第一阶段:聚合现有图像 + 数字人能力**,为精英班学员提供
|
||||
统一创作入口,复用 BoosAPI 已有的 `picture_agent`(图像)与 `digital_human`(数字人)。
|
||||
|
||||
## 已确认的关键决策
|
||||
|
||||
1. 定位:NIT 精英班专属模块,非通用套件。
|
||||
2. 第一阶段只聚合 `picture_agent`(图像)+ `digital_human`(数字人)。
|
||||
3. 视频(含 money_printer、工作流视频)留后续阶段,本阶段完全不碰 money_printer。
|
||||
4. 认证桥接:给精英班学员配后台下游 key + 扩展 surface 的 resolve_auth 接受 student JWT。
|
||||
5. 计费:学员创作消耗走积分轨(已建的 points track),管理员可给学员充积分。
|
||||
6. 文本创作 / PPT 留后续阶段。
|
||||
|
||||
## 前置依赖
|
||||
|
||||
本阶段 gate 在第一期(报考闭环)产物上,实现排在第一期之后:
|
||||
- student JWT(`audience='student'`,第一期发放)。
|
||||
- `enrollment.status=approved` 且 `tier=elite`。
|
||||
- 非精英班 / 未审核 → 403。
|
||||
|
||||
## 非目标(本阶段明确不做)
|
||||
|
||||
- 视频能力(文生/图生/短视频、money_printer、工作流视频)。
|
||||
- 文本结构化创作(论文/小说/剧本/分镜/教材)。
|
||||
- PPT 生成。
|
||||
- 通用套件(面向非 NIT 用户)。
|
||||
|
||||
## 学员下游 key 配置
|
||||
|
||||
- `downstream_api_keys` 加可空 `student_id` 列(关联 `students.id`,与现有 `user_id` 并列;
|
||||
二者互斥:一行要么属于 user,要么属于 student)。
|
||||
- 审核通过且 `tier=elite` 时,自动 provision 一个学员下游 key,沿用现有 `comfyui-agent`
|
||||
内置 key 的幂等 `ensure` 模式:缺失则建、被禁用则重新启用、保持唯一。
|
||||
- 学员从精英班降级或报考被驳回时,禁用该 key。
|
||||
- key 命名约定:`sk-nit-stu-{uuid_hex}`,`name='nit-student'`,便于识别。
|
||||
|
||||
## 认证扩展
|
||||
|
||||
仅改两个 surface:`src/routes/picture_agent.rs`、`src/routes/digital_human.rs`。
|
||||
**money_printer 不动。**
|
||||
|
||||
各自的 `resolve_auth` 加一个分支(现有 admin/proxy token、user JWT 逻辑不变):
|
||||
- 新增:`audience='student'` 的 JWT → 校验该学员存在 `approved` 且 `tier=elite` 的报考
|
||||
→ 查其 `student_id` 对应的下游 key → 作 `outbound_key`。
|
||||
- 抽共享 helper `resolve_student_outbound_key(student_id, state) -> AppResult<(key_id, key)>`,
|
||||
放在 `src/services/nit/`,两处 surface 复用,避免重复逻辑。
|
||||
- 校验失败(无 elite 报考 / key 缺失)→ 403。
|
||||
|
||||
## 计费(复用积分轨)
|
||||
|
||||
- 学员用图像/数字人,消耗扣在其下游 key 上,走已建的积分轨(`apply_points_delta`,track=points)。
|
||||
- 管理员给学员充积分:扩展现有 admin 积分调整端点支持 `student_id`(或新增 NIT 学员积分端点,
|
||||
实现时择一,优先扩展现有端点)。
|
||||
- 精英班开通时可送一笔初始积分,数额为配置项,默认 `0`。
|
||||
|
||||
## 统一入口(前端)
|
||||
|
||||
- `src/web/pages/nit/Studio.tsx`:精英班学员创作工作台首页,卡片式列出「图像 / 数字人」
|
||||
两个入口 + 显示积分余额。
|
||||
- 卡片跳进现有 `picture_agent` / `digital_human` 页面(带 student token)。
|
||||
- 非精英班学员:显示引导升级提示,不展示工具卡片。
|
||||
- `api.ts` 的 `nitApi` 扩展:查学员积分余额、查 Studio 可用工具。
|
||||
|
||||
## 错误处理
|
||||
|
||||
- 非精英班 / 未审核学员访问 → 403 + 友好提示。
|
||||
- 学员下游 key 缺失(provision 失败)→ 明确错误,提示联系管理员。
|
||||
- 积分余额不足 → 拦截创作,提示充值(复用现有 billing 引导文案范式)。
|
||||
|
||||
## 测试策略(TDD)
|
||||
|
||||
后端(rstest + 内存 SQLite,沿用现有 `recharge_test_pool` 范式):
|
||||
- 学员下游 key ensure 幂等性(缺失则建 / 禁用则启用 / 降级则禁用)。
|
||||
- `resolve_auth` 接受 elite student JWT、拒绝非精英班、拒绝未审核。
|
||||
- 学员创作扣积分、余额不足拦截。
|
||||
|
||||
前端(vitest):
|
||||
- `nitApi` 积分余额 / Studio 工具查询方法(沿用 `api.test.ts` 范式)。
|
||||
- Studio 页卡片渲染 + 余额展示 + 非精英班引导(沿用 `UsageRecords.test.tsx` 范式)。
|
||||
|
||||
## 验证
|
||||
|
||||
- `cargo build -j 2`、`cargo test -p boosapi --lib`(后端 nit / picture_agent / digital_human 相关)。
|
||||
- `npx vitest run`(相关前端测试)、`npm run typecheck`。
|
||||
|
||||
## 提交约定
|
||||
|
||||
全部在 `nit_edu` 分支,不混入 `refactor/rust-backend`。提交按逻辑拆分(迁移 / service /
|
||||
surface 认证 / 前端 分开),沿用项目中文 commit 风格。
|
||||
@@ -0,0 +1,195 @@
|
||||
# NIT 报考学习平台 — 第一期(报考闭环 A+B+C)设计
|
||||
|
||||
日期:2026-06-17
|
||||
分支:`nit_edu`
|
||||
状态:已通过 brainstorming,待落实现计划
|
||||
|
||||
## 背景
|
||||
|
||||
NIT 报考学习平台面向报考「NIT 人工智能与大模型应用」考试的学员,提供注册、报考、
|
||||
学习等功能。完整需求(见 `plan/NIT报考学习平台(初版需求备注解析).docx`)涵盖
|
||||
8 个子系统,体量过大,无法单 spec 完成,故拆分分期建设。
|
||||
|
||||
**本 spec 仅覆盖第一期:报考闭环 A+B+C** —— 学员注册 → 报考 → 缴费审核开课。
|
||||
学习中心(理论课/实操课/练习题)、OCR、校区负责人多级权限、内容保护等留后续期。
|
||||
|
||||
## 已确认的关键决策
|
||||
|
||||
1. 第一个子项目:A+B+C 报考闭环(注册 → 报考 → 缴费审核开课)。
|
||||
2. 独立 `students` 表,与现有 `users`(API 网关用户)完全隔离。
|
||||
3. 缴费:线下转账/扫码给校区 + 学员上传凭证截图 + 后台人工审核开课(非在线支付)。
|
||||
4. 第一期不做 OCR:学员手动填身份证号/姓名,二寸照手动命名上传。
|
||||
5. 开课权限只打状态标记(enrollment.status=approved + tier),学习中心留后续期再读此标记。
|
||||
6. 后台第一期只做平台超管(复用现有 admin 认证),校区负责人登录留后续期。
|
||||
7. 文件存储复用现有 workspace 机制(multipart + `record_bytes_asset` + `workspace_assets` 表)。
|
||||
8. 代码组织:NIT 作为 BoosAPI 内的新模块,复用现有 axum/sqlx/JWT/workspace 基建。
|
||||
9. 学员认证:发 `audience='student'` 的 JWT,与 admin/user token 互不通用。
|
||||
10. 报名信息表:固定核心字段 + 一个 JSON 扩展字段(应对考试中心模版变化),不做动态表单引擎。
|
||||
|
||||
## 非目标(第一期明确不做)
|
||||
|
||||
- 学习中心:理论课/实操课视频、二维码、学习进度、练习题、模拟题、错题集。
|
||||
- 实操课专属 AI 综合应用软件入口。
|
||||
- 身份证 OCR 自动识别与二寸照自动命名。
|
||||
- 校区负责人多级权限与数据隔离。
|
||||
- 内容保护(防截图/录屏/水印)。
|
||||
- 在线支付。
|
||||
|
||||
## 数据模型
|
||||
|
||||
三张新表,SQLite,迁移文件放 `migrations/`。
|
||||
|
||||
### `students` — 学员实名账户
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | INTEGER PK | |
|
||||
| `public_id` | TEXT UNIQUE | 对外 ID,格式 `stu_{uuid_hex}` |
|
||||
| `name` | TEXT NOT NULL | 姓名(实名,注册后不可改) |
|
||||
| `phone` | TEXT NOT NULL UNIQUE | 实名手机号,登录用 |
|
||||
| `id_card_no` | TEXT NOT NULL UNIQUE | 身份证号 |
|
||||
| `campus_id` | INTEGER NOT NULL | FK → `nit_campuses.id` |
|
||||
| `major` | TEXT | 所学专业(社会学员可空) |
|
||||
| `class_name` | TEXT | 班级(可空) |
|
||||
| `head_teacher` | TEXT | 班主任姓名(可空) |
|
||||
| `password_hash` | TEXT NOT NULL | 登录密码哈希(沿用现有 users 哈希方案) |
|
||||
| `privacy_agreed_at` | TEXT NOT NULL | 勾选隐私协议时间 |
|
||||
| `status` | TEXT NOT NULL DEFAULT 'active' | active / disabled |
|
||||
| `created_at` / `updated_at` | TEXT | |
|
||||
|
||||
### `nit_campuses` — 校区/机构库(超管录入,学员注册下拉选)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | INTEGER PK | |
|
||||
| `public_id` | TEXT UNIQUE | `cmp_{uuid_hex}` |
|
||||
| `province` | TEXT NOT NULL | 省份 |
|
||||
| `name` | TEXT NOT NULL | 院校/机构名称 |
|
||||
| `manager_name` | TEXT | 负责人姓名 |
|
||||
| `manager_phone` | TEXT | 负责人联系方式 |
|
||||
| `enabled` | INTEGER NOT NULL DEFAULT 1 | 仅 enabled 的校区可被学员选择 |
|
||||
| `created_at` / `updated_at` | TEXT | |
|
||||
|
||||
### `nit_enrollments` — 报考记录
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | INTEGER PK | |
|
||||
| `public_id` | TEXT UNIQUE | `enr_{uuid_hex}` |
|
||||
| `student_id` | INTEGER NOT NULL | FK → `students.id` |
|
||||
| `subject` | TEXT NOT NULL | 第一期固定「NIT人工智能与大模型应用」 |
|
||||
| `tier` | TEXT NOT NULL | `basic`(980)/ `elite`(1980) |
|
||||
| `payment_time` | TEXT | 学员填写的缴费时间 |
|
||||
| `payment_proof_asset_id` | TEXT | 缴费凭证截图(workspace asset public_id) |
|
||||
| `id_card_front_asset_id` | TEXT | 身份证正面 |
|
||||
| `id_card_back_asset_id` | TEXT | 身份证反面 |
|
||||
| `photo_asset_id` | TEXT | 二寸蓝底照 |
|
||||
| `applicant_info` | TEXT (JSON) | 报名材料表可变字段 |
|
||||
| `status` | TEXT NOT NULL DEFAULT 'pending_review' | pending_review / approved / rejected |
|
||||
| `reviewed_by` | TEXT | 审核管理员标识 |
|
||||
| `reviewed_at` | TEXT | |
|
||||
| `review_note` | TEXT | 驳回原因/备注 |
|
||||
| `created_at` / `updated_at` | TEXT | |
|
||||
|
||||
**关系**:student → campus(多对一);enrollment → student(多对一)。一个学员允许在被驳回后
|
||||
重新提交报考(可有多条 rejected + 至多一条 active 的 pending_review/approved)。
|
||||
|
||||
**班型权限**:第一期权限即 `approved` 后 enrollment 上的 `tier` 标记;`basic` 对应理论课权限、
|
||||
`elite` 对应理论+实操权限,实际课程内容由后续期读取此标记实现。
|
||||
|
||||
**文件**:四类上传文件全部走现有 `workspace_assets`(`source='nit-enrollment'`,
|
||||
经 `record_bytes_asset` 落本地磁盘),enrollment 仅存其 `public_id` 引用。
|
||||
|
||||
## 认证与路由
|
||||
|
||||
### 学员认证
|
||||
|
||||
复用现有 `auth::jwt` 工具,给 students 发 `audience='student'` 的 JWT。新增
|
||||
`resolve_student(headers, state) -> AppResult<i64>`(返回 student_id),与 admin/user token
|
||||
互不通用。登录凭证为「手机号 + 密码」。
|
||||
|
||||
### 路由分布(挂进现有 axum router via `routes/mod.rs` merge)
|
||||
|
||||
- `src/routes/nit/mod.rs` — 装配 NIT 子路由,导出 `routes()`。
|
||||
- `src/routes/nit/student_auth.rs`
|
||||
- `POST /api/nit/register` — 学员注册
|
||||
- `POST /api/nit/login` — 学员登录
|
||||
- `src/routes/nit/enrollment.rs`(学员侧,需 student JWT)
|
||||
- `POST /api/nit/enrollment/uploads` — multipart 上传单个文件,返回 asset public_id
|
||||
- `POST /api/nit/enrollment` — 创建报考记录
|
||||
- `GET /api/nit/enrollment/mine` — 查本人报考状态
|
||||
- `src/routes/nit/admin.rs`(超管侧,复用现有 admin token 校验)
|
||||
- `GET/POST/PUT /api/admin/nit/campuses` — 校区 CRUD
|
||||
- `GET /api/admin/nit/enrollments` — 报考列表(按 省份/校区/班型/状态 筛选)
|
||||
- `POST /api/admin/nit/enrollments/{public_id}/approve`
|
||||
- `POST /api/admin/nit/enrollments/{public_id}/reject`
|
||||
- `GET /api/admin/nit/enrollments/export` — CSV 导出
|
||||
|
||||
业务逻辑(查询/状态机)抽到 `src/services/nit/`(如 `mod.rs` + `enrollment.rs`),
|
||||
路由层只做参数解析 + 鉴权 + 调用 service,保持文件聚焦。
|
||||
|
||||
## 数据流
|
||||
|
||||
### 注册
|
||||
1. 校验手机号、身份证号未被占用(唯一约束 + 友好报错)。
|
||||
2. 校验 `campus_id` 存在且 enabled。
|
||||
3. 必须 `privacy_agreed=true`。
|
||||
4. 写入 `students`(password 哈希),发 student JWT 返回。
|
||||
|
||||
### 报考(两步,避免半截事务)
|
||||
1. 学员逐个调 `POST /api/nit/enrollment/uploads`(multipart,带 `purpose` 区分四类文件)
|
||||
→ `record_bytes_asset` → 返回各 asset `public_id`。
|
||||
2. 学员调 `POST /api/nit/enrollment`,带各 asset id + tier + payment_time + applicant_info
|
||||
→ 校验本人无在审/已批的活动报考 → 创建 `pending_review` 记录。
|
||||
|
||||
### 审核(超管)
|
||||
- 列表筛选 `pending_review` → 查看凭证/证件图(经 workspace asset file 接口)。
|
||||
- `approve`:状态机 `pending_review → approved`,落 `tier` 权限标记,记 reviewed_by/at。
|
||||
- `reject`:`pending_review → rejected`,记 review_note;学员可据此重新提交。
|
||||
- 状态机只允许从 `pending_review` 流转,杜绝并发重复审核。
|
||||
|
||||
### 导出
|
||||
按 省份/校区/班型/状态 筛选,导出 CSV(实名信息 + 班型),用于核算报考人数/教材数量。
|
||||
|
||||
## 前端页面(`src/web/pages/nit/`)
|
||||
|
||||
- 学员注册页(实名信息 + 校区下拉 + 隐私协议勾选)
|
||||
- 学员登录页(手机号 + 密码)
|
||||
- 报考页(选班型 + 上传四类文件 + 填缴费时间 + 报名信息表 + 提交)
|
||||
- 「我的报考」状态页(pending/approved/rejected + 驳回原因 + 可重新提交)
|
||||
- 超管校区管理页(CRUD)
|
||||
- 超管报考审核页(列表 + 筛选 + 看图 + 通过/驳回 + 导出)
|
||||
|
||||
`api.ts` 新增 `nitApi`(学员侧,带 student token 注入)与 `adminApi` NIT 审核扩展。
|
||||
复用现有 React/Vite/api.ts 范式与 UI 组件。
|
||||
|
||||
## 错误处理
|
||||
|
||||
- 唯一约束冲突 → 友好提示「该手机号/身份证号已注册」。
|
||||
- 上传非图片或超大文件 → 拒绝并提示。
|
||||
- 报考时存在活动报考 → 拒绝重复提交。
|
||||
- 审核状态机:非 `pending_review` 状态的 approve/reject → 409/冲突提示。
|
||||
|
||||
## 测试策略(TDD)
|
||||
|
||||
后端(rstest + 内存 SQLite,沿用现有 `recharge_test_pool` 范式):
|
||||
- 注册手机号/身份证唯一性、校区校验、隐私协议必填。
|
||||
- 登录成功/失败。
|
||||
- 文件上传返回 asset id。
|
||||
- 报考创建 + 重复报考拦截。
|
||||
- 审核状态机(approve/reject/非法流转)。
|
||||
- 导出筛选正确性。
|
||||
|
||||
前端(vitest):
|
||||
- `nitApi` 各方法 URL/method/body 正确(沿用 `api.test.ts` 范式)。
|
||||
- 注册/报考/审核关键页面交互(沿用 `UsageRecords.test.tsx` 的 react-test-renderer 范式)。
|
||||
|
||||
## 验证
|
||||
|
||||
- `cargo build -j 2`(避免 OOM)、`cargo test -p boosapi --lib nit`(后端)。
|
||||
- `npx vitest run`(相关前端测试)、`npm run typecheck`。
|
||||
|
||||
## 提交约定
|
||||
|
||||
NIT 工作全部在 `nit_edu` 分支,不混入 `refactor/rust-backend`。提交按逻辑拆分
|
||||
(迁移 / service / 路由 / 前端 分开),沿用项目中文 commit 风格。
|
||||
+134
@@ -14,6 +14,8 @@ pub struct AppConfig {
|
||||
#[serde(default)]
|
||||
pub comfyui: ComfyUIConfig,
|
||||
#[serde(default)]
|
||||
pub pixelle_video: PixelleVideoConfig,
|
||||
#[serde(default)]
|
||||
pub payment: PaymentConfig,
|
||||
}
|
||||
|
||||
@@ -62,6 +64,8 @@ pub struct ComfyUIConfig {
|
||||
pub money_printer_video_cost: f64,
|
||||
#[serde(default)]
|
||||
pub digital_human_video_cost: f64,
|
||||
#[serde(default = "default_workflow_points_cost")]
|
||||
pub workflow_points_cost: f64,
|
||||
#[serde(default = "default_comfyui_docker_image")]
|
||||
pub docker_image: String,
|
||||
#[serde(default = "default_comfyui_container_name")]
|
||||
@@ -77,9 +81,30 @@ pub struct ComfyUIConfig {
|
||||
pub agent_model: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct PixelleVideoConfig {
|
||||
pub enabled: bool,
|
||||
pub api_base_url: Option<String>,
|
||||
pub api_key: Option<String>,
|
||||
#[serde(default = "default_pixelle_backend_provider")]
|
||||
pub backend_provider: String,
|
||||
pub frame_template: String,
|
||||
pub media_workflow: Option<String>,
|
||||
pub tts_workflow: Option<String>,
|
||||
pub runninghub_media_workflow: Option<String>,
|
||||
pub runninghub_tts_workflow: Option<String>,
|
||||
pub mode: String,
|
||||
pub n_scenes: i64,
|
||||
pub video_fps: i64,
|
||||
pub poll_interval_secs: u64,
|
||||
pub timeout_secs: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct PaymentConfig {
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub provider: PaymentProvider,
|
||||
pub public_base_url: Option<String>,
|
||||
pub product_name_prefix: String,
|
||||
pub currency: String,
|
||||
@@ -90,8 +115,31 @@ pub struct PaymentConfig {
|
||||
pub max_pending_orders: i64,
|
||||
pub stripe_secret_key: Option<String>,
|
||||
pub stripe_webhook_secret: Option<String>,
|
||||
pub comfy_customer_token: Option<String>,
|
||||
pub comfy_customer_token_endpoint: Option<String>,
|
||||
pub comfy_firebase_api_key: Option<String>,
|
||||
pub comfy_oauth_client_id: Option<String>,
|
||||
pub comfy_oauth_client_secret: Option<String>,
|
||||
pub comfy_oauth_refresh_token: Option<String>,
|
||||
pub comfy_customer_api_base_url: Option<String>,
|
||||
pub comfy_checkout_endpoint: Option<String>,
|
||||
pub success_path: String,
|
||||
pub cancel_path: String,
|
||||
/// When true, confirming a comfy_cloud recharge grants workflow points
|
||||
/// (amount_usd * usd_to_points_rate) instead of crediting the USD pool.
|
||||
#[serde(default = "default_comfy_recharge_to_points")]
|
||||
pub comfy_recharge_to_points: bool,
|
||||
/// Points granted per 1 USD when comfy_recharge_to_points is enabled.
|
||||
#[serde(default = "default_usd_to_points_rate")]
|
||||
pub usd_to_points_rate: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, Default, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PaymentProvider {
|
||||
#[default]
|
||||
ComfyCloud,
|
||||
Stripe,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, Default)]
|
||||
@@ -113,6 +161,22 @@ fn default_comfyui_container_name() -> String {
|
||||
"boosapi-comfyui-workflow".to_string()
|
||||
}
|
||||
|
||||
fn default_pixelle_backend_provider() -> String {
|
||||
"comfy".to_string()
|
||||
}
|
||||
|
||||
fn default_workflow_points_cost() -> f64 {
|
||||
10.0
|
||||
}
|
||||
|
||||
fn default_comfy_recharge_to_points() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_usd_to_points_rate() -> f64 {
|
||||
100.0
|
||||
}
|
||||
|
||||
impl Default for ComfyUIConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -120,6 +184,7 @@ impl Default for ComfyUIConfig {
|
||||
backend: ComfyUIBackend::Boosapi,
|
||||
money_printer_video_cost: 0.0,
|
||||
digital_human_video_cost: 0.0,
|
||||
workflow_points_cost: 10.0,
|
||||
docker_image: default_comfyui_docker_image(),
|
||||
container_name: default_comfyui_container_name(),
|
||||
install_dir: None,
|
||||
@@ -135,10 +200,32 @@ impl Default for ComfyUIConfig {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PixelleVideoConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
api_base_url: Some("http://127.0.0.1:8000".to_string()),
|
||||
api_key: None,
|
||||
backend_provider: default_pixelle_backend_provider(),
|
||||
frame_template: "1080x1920/image_default.html".to_string(),
|
||||
media_workflow: None,
|
||||
tts_workflow: None,
|
||||
runninghub_media_workflow: Some("runninghub/video_wan2.1_fusionx.json".to_string()),
|
||||
runninghub_tts_workflow: Some("runninghub/tts_edge.json".to_string()),
|
||||
mode: "fixed".to_string(),
|
||||
n_scenes: 5,
|
||||
video_fps: 30,
|
||||
poll_interval_secs: 5,
|
||||
timeout_secs: 900,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PaymentConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
provider: PaymentProvider::ComfyCloud,
|
||||
public_base_url: None,
|
||||
product_name_prefix: "BossAI 账户充值".to_string(),
|
||||
currency: "usd".to_string(),
|
||||
@@ -149,8 +236,55 @@ impl Default for PaymentConfig {
|
||||
max_pending_orders: 3,
|
||||
stripe_secret_key: None,
|
||||
stripe_webhook_secret: None,
|
||||
comfy_customer_token: None,
|
||||
comfy_customer_token_endpoint: None,
|
||||
comfy_firebase_api_key: None,
|
||||
comfy_oauth_client_id: None,
|
||||
comfy_oauth_client_secret: None,
|
||||
comfy_oauth_refresh_token: None,
|
||||
comfy_customer_api_base_url: None,
|
||||
comfy_checkout_endpoint: None,
|
||||
success_path: "/user/recharge?payment=success&order={ORDER_ID}".to_string(),
|
||||
cancel_path: "/user/recharge?payment=cancel&order={ORDER_ID}".to_string(),
|
||||
comfy_recharge_to_points: true,
|
||||
usd_to_points_rate: 100.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
server: ServerConfig {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 4000,
|
||||
log_level: "info".to_string(),
|
||||
cors_origins: vec!["*".to_string()],
|
||||
},
|
||||
db: DbConfig {
|
||||
url: "sqlite::memory:".to_string(),
|
||||
max_connections: 1,
|
||||
ssl: false,
|
||||
runtime_db_url: None,
|
||||
runtime_db_dialect: None,
|
||||
runtime_db_ssl: None,
|
||||
},
|
||||
auth: AuthConfig {
|
||||
jwt_secret: "test-secret".to_string(),
|
||||
jwt_expiry_hours: 72,
|
||||
auth_token: "test-admin-token".to_string(),
|
||||
proxy_token: "sk-test-proxy".to_string(),
|
||||
},
|
||||
proxy: ProxyConfig {
|
||||
upstream_timeout_secs: 300,
|
||||
first_byte_timeout_secs: 60,
|
||||
max_retries: 3,
|
||||
proxy_log_retention_days: 30,
|
||||
template_proxy_url: None,
|
||||
},
|
||||
comfyui: ComfyUIConfig::default(),
|
||||
pixelle_video: PixelleVideoConfig::default(),
|
||||
payment: PaymentConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+49
-7
@@ -1,4 +1,5 @@
|
||||
use crate::{
|
||||
auth::jwt::validate_jwt,
|
||||
db::models::setting::Setting,
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
@@ -24,7 +25,8 @@ mod sites;
|
||||
mod tokens;
|
||||
mod users;
|
||||
|
||||
/// Middleware to require admin authentication via the configured admin API token.
|
||||
/// Middleware to require admin authentication via the configured admin API token
|
||||
/// or a JWT belonging to a user with role='admin'.
|
||||
async fn require_admin_auth(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
@@ -37,8 +39,16 @@ async fn require_admin_auth(
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.ok_or_else(|| AppError::Unauthorized("Missing or invalid Authorization header".into()))?;
|
||||
|
||||
if auth_header != state.config.auth.auth_token {
|
||||
return Err(AppError::Unauthorized("Invalid admin token".into()));
|
||||
if auth_header == state.config.auth.auth_token {
|
||||
return Ok(next.run(request).await);
|
||||
}
|
||||
|
||||
let claims = validate_jwt(&state.config.auth.jwt_secret, auth_header)
|
||||
.map_err(|_| AppError::Unauthorized("Invalid admin token".into()))?;
|
||||
if claims.role != "admin" {
|
||||
return Err(AppError::Forbidden(
|
||||
"Admin account is required for this route".into(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(next.run(request).await)
|
||||
@@ -177,6 +187,18 @@ pub fn routes(state: AppState) -> Router<AppState> {
|
||||
.delete(users::delete_user),
|
||||
)
|
||||
.route("/api/admin/users/{id}/usage", get(users::get_user_usage))
|
||||
.route(
|
||||
"/api/admin/user-balances",
|
||||
get(users::list_user_balances),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/users/{id}/balance/adjust",
|
||||
post(users::adjust_user_balance),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/users/{id}/credits/adjust",
|
||||
post(users::adjust_user_credits),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/users/{id}/keys",
|
||||
get(users::list_user_keys).post(users::create_user_key),
|
||||
@@ -354,6 +376,8 @@ mod tests {
|
||||
template_proxy_url: None,
|
||||
},
|
||||
comfyui: Default::default(),
|
||||
pixelle_video: Default::default(),
|
||||
payment: Default::default(),
|
||||
},
|
||||
pool,
|
||||
BuiltinKeys {
|
||||
@@ -419,7 +443,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_routes_accept_config_token_and_reject_admin_jwt() {
|
||||
async fn admin_routes_accept_config_token_and_admin_jwt_reject_user_jwt() {
|
||||
let state = settings_state().await;
|
||||
let app = routes(state.clone()).with_state(state.clone());
|
||||
|
||||
@@ -436,8 +460,10 @@ mod tests {
|
||||
.expect("token request");
|
||||
assert_eq!(token_response.status(), StatusCode::OK);
|
||||
|
||||
let admin_jwt = generate_jwt(&state.config.auth.jwt_secret, "1", "admin", 1)
|
||||
.expect("admin jwt");
|
||||
// Admin-role JWT is accepted: admins signed in at /user/login (which now
|
||||
// mints an admin JWT) can reach admin routes without the global token.
|
||||
let admin_jwt =
|
||||
generate_jwt(&state.config.auth.jwt_secret, "1", "admin", 1).expect("admin jwt");
|
||||
let jwt_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
@@ -449,7 +475,23 @@ mod tests {
|
||||
)
|
||||
.await
|
||||
.expect("jwt request");
|
||||
assert_eq!(jwt_response.status(), StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(jwt_response.status(), StatusCode::OK);
|
||||
|
||||
// Non-admin (user-role) JWT is rejected with 403.
|
||||
let user_jwt =
|
||||
generate_jwt(&state.config.auth.jwt_secret, "2", "user", 1).expect("user jwt");
|
||||
let user_jwt_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/settings/auth/info")
|
||||
.header("Authorization", format!("Bearer {user_jwt}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.expect("user jwt request");
|
||||
assert_eq!(user_jwt_response.status(), StatusCode::FORBIDDEN);
|
||||
|
||||
let missing_response = app
|
||||
.oneshot(
|
||||
|
||||
+322
-2
@@ -4,6 +4,7 @@ use crate::{
|
||||
auth::password::hash_password,
|
||||
db::models::{downstream_api_key::DownstreamApiKey, user::User},
|
||||
error::{AppError, AppResult},
|
||||
routes::auth::DEFAULT_NEW_USER_MAX_COST_USD,
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
@@ -238,17 +239,18 @@ pub async fn create_user(
|
||||
|
||||
let user_id = result.last_insert_rowid();
|
||||
|
||||
// Auto-generate downstream API key
|
||||
// Auto-generate downstream API key with the default starter credit.
|
||||
let key = format!("sk-user-{}", uuid::Uuid::new_v4());
|
||||
let key_name = format!("{} 的默认密钥", payload.username.trim());
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO downstream_api_keys (name, key, user_id, enabled) VALUES (?, ?, ?, ?)",
|
||||
"INSERT INTO downstream_api_keys (name, key, user_id, enabled, max_cost) VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&key_name)
|
||||
.bind(&key)
|
||||
.bind(user_id)
|
||||
.bind(true)
|
||||
.bind(DEFAULT_NEW_USER_MAX_COST_USD)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
@@ -708,6 +710,232 @@ pub async fn update_user_key(
|
||||
Ok(Json(json!({ "data": updated })))
|
||||
}
|
||||
|
||||
// ── User balance management (admin) ────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListBalancesQuery {
|
||||
pub page: Option<i64>,
|
||||
pub page_size: Option<i64>,
|
||||
pub q: Option<String>,
|
||||
}
|
||||
|
||||
/// GET /api/admin/user-balances — list users with their credit pool
|
||||
/// (summed max_cost / used_cost across their downstream keys).
|
||||
pub async fn list_user_balances(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<ListBalancesQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let page = query.page.unwrap_or(1).max(1);
|
||||
let page_size = query.page_size.unwrap_or(50).clamp(1, 200);
|
||||
let offset = (page - 1) * page_size;
|
||||
let q = query.q.unwrap_or_default().trim().to_string();
|
||||
let q_like = format!("%{}%", q);
|
||||
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
u.id AS user_id,
|
||||
u.username AS username,
|
||||
u.email AS email,
|
||||
u.role AS role,
|
||||
u.status AS status,
|
||||
COALESCE(SUM(COALESCE(k.used_cost, 0.0)), 0.0) AS used_cost,
|
||||
COALESCE(SUM(CASE WHEN k.max_cost IS NULL THEN 0.0 ELSE k.max_cost END), 0.0) AS limited_max_cost,
|
||||
SUM(CASE WHEN k.id IS NULL THEN 0 WHEN k.max_cost IS NULL THEN 1 ELSE 0 END) AS unlimited_key_count,
|
||||
COUNT(k.id) AS key_count
|
||||
FROM users u
|
||||
LEFT JOIN downstream_api_keys k ON k.user_id = u.id
|
||||
WHERE (? = '' OR u.username LIKE ? OR u.email LIKE ? OR CAST(u.id AS TEXT) = ?)
|
||||
GROUP BY u.id
|
||||
ORDER BY u.id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
"#,
|
||||
)
|
||||
.bind(&q)
|
||||
.bind(&q_like)
|
||||
.bind(&q_like)
|
||||
.bind(&q)
|
||||
.bind(page_size)
|
||||
.bind(offset)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM users
|
||||
WHERE (? = '' OR username LIKE ? OR email LIKE ? OR CAST(id AS TEXT) = ?)",
|
||||
)
|
||||
.bind(&q)
|
||||
.bind(&q_like)
|
||||
.bind(&q_like)
|
||||
.bind(&q)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
let items: Vec<Value> = rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
use sqlx::Row;
|
||||
let used: f64 = row.try_get("used_cost").unwrap_or(0.0);
|
||||
let limited_max: f64 = row.try_get("limited_max_cost").unwrap_or(0.0);
|
||||
let unlimited: i64 = row.try_get("unlimited_key_count").unwrap_or(0);
|
||||
let remaining = if unlimited > 0 {
|
||||
Value::Null
|
||||
} else {
|
||||
Value::from((limited_max - used).max(0.0))
|
||||
};
|
||||
json!({
|
||||
"userId": row.try_get::<i64, _>("user_id").unwrap_or(0),
|
||||
"username": row.try_get::<Option<String>, _>("username").ok().flatten(),
|
||||
"email": row.try_get::<Option<String>, _>("email").ok().flatten(),
|
||||
"role": row.try_get::<Option<String>, _>("role").ok().flatten(),
|
||||
"status": row.try_get::<Option<String>, _>("status").ok().flatten(),
|
||||
"keyCount": row.try_get::<i64, _>("key_count").unwrap_or(0),
|
||||
"usedCost": used,
|
||||
"maxCost": if unlimited > 0 { Value::Null } else { Value::from(limited_max) },
|
||||
"remainingCost": remaining,
|
||||
"unlimitedKeyCount": unlimited,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(json!({
|
||||
"data": items,
|
||||
"meta": {
|
||||
"page": page,
|
||||
"pageSize": page_size,
|
||||
"total": total,
|
||||
"totalPages": ((total + page_size - 1) / page_size).max(1),
|
||||
},
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AdjustBalanceRequest {
|
||||
/// Amount to add (positive) or remove (negative) from the user's credit pool.
|
||||
pub delta: f64,
|
||||
}
|
||||
|
||||
/// POST /api/admin/users/{id}/balance/adjust — add or remove credit on the
|
||||
/// user's primary (lowest-id) downstream key by bumping its max_cost.
|
||||
pub async fn adjust_user_balance(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
Json(payload): Json<AdjustBalanceRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let delta = payload.delta;
|
||||
if !delta.is_finite() || delta == 0.0 {
|
||||
return Err(AppError::BadRequest("delta must be a non-zero finite number".into()));
|
||||
}
|
||||
|
||||
crate::services::billing::ledger::ensure_ledger_schema(&state.db).await?;
|
||||
|
||||
let key: Option<(i64, Option<f64>, Option<f64>)> = sqlx::query_as(
|
||||
"SELECT id, max_cost, used_cost
|
||||
FROM downstream_api_keys
|
||||
WHERE user_id = ?
|
||||
ORDER BY id ASC
|
||||
LIMIT 1",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
|
||||
let Some((key_id, max_cost, used_cost)) = key else {
|
||||
return Err(AppError::BadRequest(
|
||||
"该用户没有可调整额度的 API Key(请先为其创建 Key)".into(),
|
||||
));
|
||||
};
|
||||
|
||||
let used = used_cost.unwrap_or(0.0).max(0.0);
|
||||
let current = max_cost.unwrap_or(used).max(used);
|
||||
// Never let the limit drop below what's already been spent.
|
||||
let next_max = (current + delta).max(used);
|
||||
let remaining = (next_max - used).max(0.0);
|
||||
|
||||
let mut tx = state.db.begin().await?;
|
||||
sqlx::query(
|
||||
"UPDATE downstream_api_keys
|
||||
SET max_cost = ?, updated_at = datetime('now')
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(next_max)
|
||||
.bind(key_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
crate::services::billing::ledger::record_ledger_entry(
|
||||
&mut tx,
|
||||
crate::services::billing::ledger::LedgerEntry {
|
||||
user_id: id,
|
||||
track: "usd",
|
||||
entry_type: "admin_adjust",
|
||||
amount: delta,
|
||||
balance_after: remaining,
|
||||
ref_type: Some("manual"),
|
||||
ref_id: None,
|
||||
model: None,
|
||||
operator: Some("admin"),
|
||||
note: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
info!(
|
||||
user_id = id,
|
||||
key_id, delta, next_max, "admin adjusted user balance"
|
||||
);
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"userId": id,
|
||||
"keyId": key_id,
|
||||
"maxCost": next_max,
|
||||
"usedCost": used,
|
||||
"remainingCost": remaining,
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
/// POST /api/admin/users/{id}/credits/adjust — add or remove workflow points
|
||||
/// on the user's points pool (`user_credits`), writing a points/admin_adjust
|
||||
/// ledger entry. Mirrors `adjust_user_balance` but for the points track.
|
||||
pub async fn adjust_user_credits(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
Json(payload): Json<AdjustBalanceRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let delta = payload.delta;
|
||||
if !delta.is_finite() || delta == 0.0 {
|
||||
return Err(AppError::BadRequest("delta must be a non-zero finite number".into()));
|
||||
}
|
||||
|
||||
crate::services::billing::ledger::ensure_ledger_schema(&state.db).await?;
|
||||
let new_bal = crate::services::billing::ledger::apply_points_delta(
|
||||
&state.db,
|
||||
id,
|
||||
delta,
|
||||
"admin_adjust",
|
||||
None,
|
||||
Some("admin"),
|
||||
Some("manual"),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!(user_id = id, delta, new_bal, "admin adjusted user credits");
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"userId": id,
|
||||
"pointsBalance": new_bal,
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -800,6 +1028,8 @@ mod tests {
|
||||
template_proxy_url: None,
|
||||
},
|
||||
comfyui: Default::default(),
|
||||
pixelle_video: Default::default(),
|
||||
payment: Default::default(),
|
||||
},
|
||||
pool,
|
||||
BuiltinKeys {
|
||||
@@ -847,4 +1077,94 @@ mod tests {
|
||||
.expect("read role");
|
||||
assert_eq!(role, "admin");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn adjust_user_balance_writes_admin_adjust_ledger() {
|
||||
let state = test_state().await;
|
||||
crate::services::billing::ledger::ensure_ledger_schema(&state.db)
|
||||
.await
|
||||
.expect("ledger schema");
|
||||
|
||||
let user_id = sqlx::query(
|
||||
"INSERT INTO users (username, email, password_hash, role, status)
|
||||
VALUES (?, ?, ?, 'user', 'active')",
|
||||
)
|
||||
.bind("Spender")
|
||||
.bind("spender@example.test")
|
||||
.bind("hash")
|
||||
.execute(&*state.db)
|
||||
.await
|
||||
.expect("insert user")
|
||||
.last_insert_rowid();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO downstream_api_keys (name, key, user_id, max_cost, used_cost)
|
||||
VALUES (?, ?, ?, 10.0, 0.0)",
|
||||
)
|
||||
.bind("primary")
|
||||
.bind("sk-spender")
|
||||
.bind(user_id)
|
||||
.execute(&*state.db)
|
||||
.await
|
||||
.expect("insert key");
|
||||
|
||||
adjust_user_balance(
|
||||
State(state.clone()),
|
||||
Path(user_id),
|
||||
Json(AdjustBalanceRequest { delta: 5.0 }),
|
||||
)
|
||||
.await
|
||||
.expect("adjust balance");
|
||||
|
||||
let row: (String, String, f64) = sqlx::query_as(
|
||||
"SELECT track, entry_type, amount FROM credit_ledger
|
||||
WHERE user_id = ? ORDER BY id DESC LIMIT 1",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_one(&*state.db)
|
||||
.await
|
||||
.expect("ledger row");
|
||||
assert_eq!(row, ("usd".into(), "admin_adjust".into(), 5.0));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn adjust_user_credits_writes_points_admin_adjust_ledger() {
|
||||
let state = test_state().await;
|
||||
crate::services::billing::ledger::ensure_ledger_schema(&state.db)
|
||||
.await
|
||||
.expect("ledger schema");
|
||||
|
||||
let user_id = sqlx::query(
|
||||
"INSERT INTO users (username, email, password_hash, role, status)
|
||||
VALUES (?, ?, ?, 'user', 'active')",
|
||||
)
|
||||
.bind("Pointer")
|
||||
.bind("pointer@example.test")
|
||||
.bind("hash")
|
||||
.execute(&*state.db)
|
||||
.await
|
||||
.expect("insert user")
|
||||
.last_insert_rowid();
|
||||
|
||||
let response = adjust_user_credits(
|
||||
State(state.clone()),
|
||||
Path(user_id),
|
||||
Json(AdjustBalanceRequest { delta: 50.0 }),
|
||||
)
|
||||
.await
|
||||
.expect("adjust credits");
|
||||
|
||||
assert_eq!(response.0["success"], true);
|
||||
assert_eq!(response.0["data"]["pointsBalance"], 50.0);
|
||||
|
||||
let row: (String, String, f64) = sqlx::query_as(
|
||||
"SELECT track, entry_type, amount FROM credit_ledger
|
||||
WHERE user_id = ? ORDER BY id DESC LIMIT 1",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_one(&*state.db)
|
||||
.await
|
||||
.expect("ledger row");
|
||||
assert_eq!(row, ("points".into(), "admin_adjust".into(), 50.0));
|
||||
}
|
||||
}
|
||||
|
||||
+1094
-14
File diff suppressed because it is too large
Load Diff
+879
-99
File diff suppressed because it is too large
Load Diff
+82
-41
@@ -13,10 +13,9 @@ use crate::{
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::{
|
||||
DefaultBodyLimit,
|
||||
multipart::Multipart,
|
||||
ws::{Message, WebSocketUpgrade},
|
||||
FromRequest, Path, Query, State,
|
||||
DefaultBodyLimit, FromRequest, Path, Query, State,
|
||||
},
|
||||
http::{header, HeaderMap, HeaderValue, Method, StatusCode, Uri},
|
||||
response::{Html, IntoResponse, Response},
|
||||
@@ -1311,7 +1310,10 @@ fn is_boosapi_extension_node_type(node_type: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
fn workflow_node_defs_with_models(models: &[String], include_compat_core: bool) -> Map<String, Value> {
|
||||
fn workflow_node_defs_with_models(
|
||||
models: &[String],
|
||||
include_compat_core: bool,
|
||||
) -> Map<String, Value> {
|
||||
let core_defs = include_compat_core
|
||||
.then(core_node_definitions)
|
||||
.unwrap_or_default();
|
||||
@@ -1865,9 +1867,7 @@ fn cache_control_for_path(path: &StdPath) -> &'static str {
|
||||
.to_ascii_lowercase();
|
||||
match ext.as_str() {
|
||||
"html" | "js" | "mjs" | "css" => "no-cache",
|
||||
"woff2" | "png" | "jpg" | "jpeg" | "webp" | "svg" => {
|
||||
"public, max-age=604800"
|
||||
}
|
||||
"woff2" | "png" | "jpg" | "jpeg" | "webp" | "svg" => "public, max-age=604800",
|
||||
_ => "public, max-age=3600",
|
||||
}
|
||||
}
|
||||
@@ -1921,7 +1921,8 @@ async fn get_setting(
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let _ = auth_caller(&state, &headers).await?;
|
||||
if state.config.comfyui.uses_cloud_backend() && id == "Comfy.Workflow.ShowMissingModelsWarning" {
|
||||
if state.config.comfyui.uses_cloud_backend() && id == "Comfy.Workflow.ShowMissingModelsWarning"
|
||||
{
|
||||
return Ok(Json(Value::Bool(false)));
|
||||
}
|
||||
Ok(Json(Value::Null))
|
||||
@@ -2936,7 +2937,10 @@ fn repair_resize_image_mask_node_inputs_without_workflow(
|
||||
inputs
|
||||
.entry("resize_type".to_string())
|
||||
.or_insert_with(|| Value::String("scale dimensions".to_string()));
|
||||
inputs.insert("resize_type.crop".to_string(), Value::String("center".to_string()));
|
||||
inputs.insert(
|
||||
"resize_type.crop".to_string(),
|
||||
Value::String("center".to_string()),
|
||||
);
|
||||
tracing::warn!(
|
||||
node_id = %node_id,
|
||||
"repaired ResizeImageMaskNode crop prompt input without workflow metadata"
|
||||
@@ -3186,14 +3190,11 @@ fn dynamic_combo_value_matches_field(value: &Value, field: &DynamicComboField) -
|
||||
.as_str()
|
||||
.is_some_and(|text| field.options.iter().any(|option| option == text)),
|
||||
"INT" => dynamic_combo_value_as_i64(value).is_some_and(|number| {
|
||||
field
|
||||
.min
|
||||
.is_none_or(|min| (number as f64) >= min)
|
||||
field.min.is_none_or(|min| (number as f64) >= min)
|
||||
&& field.max.is_none_or(|max| (number as f64) <= max)
|
||||
}),
|
||||
"FLOAT" => dynamic_combo_value_as_f64(value).is_some_and(|number| {
|
||||
field.min.is_none_or(|min| number >= min)
|
||||
&& field.max.is_none_or(|max| number <= max)
|
||||
field.min.is_none_or(|min| number >= min) && field.max.is_none_or(|max| number <= max)
|
||||
}),
|
||||
"BOOLEAN" => dynamic_combo_value_as_bool(value).is_some(),
|
||||
_ => true,
|
||||
@@ -3239,18 +3240,26 @@ fn dynamic_combo_value_as_i64(value: &Value) -> Option<i64> {
|
||||
.as_i64()
|
||||
.or_else(|| value.as_u64().and_then(|number| i64::try_from(number).ok()))
|
||||
.or_else(|| value.as_f64().map(|number| number.round() as i64))
|
||||
.or_else(|| value.as_str().and_then(|text| text.trim().parse::<i64>().ok()))
|
||||
.or_else(|| {
|
||||
value
|
||||
.as_str()
|
||||
.and_then(|text| text.trim().parse::<i64>().ok())
|
||||
})
|
||||
}
|
||||
|
||||
fn dynamic_combo_value_as_f64(value: &Value) -> Option<f64> {
|
||||
value.as_f64().or_else(|| {
|
||||
value
|
||||
.as_f64()
|
||||
.or_else(|| value.as_str().and_then(|text| text.trim().parse::<f64>().ok()))
|
||||
.as_str()
|
||||
.and_then(|text| text.trim().parse::<f64>().ok())
|
||||
})
|
||||
}
|
||||
|
||||
fn dynamic_combo_value_as_bool(value: &Value) -> Option<bool> {
|
||||
value.as_bool().or_else(|| {
|
||||
value.as_str().and_then(|text| match text.trim().to_ascii_lowercase().as_str() {
|
||||
value
|
||||
.as_str()
|
||||
.and_then(|text| match text.trim().to_ascii_lowercase().as_str() {
|
||||
"true" | "1" | "yes" | "on" => Some(true),
|
||||
"false" | "0" | "no" | "off" => Some(false),
|
||||
_ => None,
|
||||
@@ -3259,8 +3268,14 @@ fn dynamic_combo_value_as_bool(value: &Value) -> Option<bool> {
|
||||
}
|
||||
|
||||
fn clamp_i64_to_field(value: i64, field: &DynamicComboField) -> i64 {
|
||||
let min = field.min.map(|value| value.ceil() as i64).unwrap_or(i64::MIN);
|
||||
let max = field.max.map(|value| value.floor() as i64).unwrap_or(i64::MAX);
|
||||
let min = field
|
||||
.min
|
||||
.map(|value| value.ceil() as i64)
|
||||
.unwrap_or(i64::MIN);
|
||||
let max = field
|
||||
.max
|
||||
.map(|value| value.floor() as i64)
|
||||
.unwrap_or(i64::MAX);
|
||||
value.clamp(min, max)
|
||||
}
|
||||
|
||||
@@ -3284,9 +3299,7 @@ fn repair_wan2_api_nodes(prompt: &mut Value) {
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let field_names = match class_type.as_str() {
|
||||
"Wan2ImageToVideoApi" => {
|
||||
&["prompt", "negative_prompt", "resolution", "duration"][..]
|
||||
}
|
||||
"Wan2ImageToVideoApi" => &["prompt", "negative_prompt", "resolution", "duration"][..],
|
||||
"Wan2TextToVideoApi" | "Wan2ReferenceVideoApi" => &[
|
||||
"prompt",
|
||||
"negative_prompt",
|
||||
@@ -3789,7 +3802,11 @@ async fn upload_image_to_native_backend(
|
||||
let content_type = headers
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.filter(|value| value.to_ascii_lowercase().starts_with("multipart/form-data"))
|
||||
.filter(|value| {
|
||||
value
|
||||
.to_ascii_lowercase()
|
||||
.starts_with("multipart/form-data")
|
||||
})
|
||||
.ok_or_else(|| AppError::BadRequest("multipart/form-data content-type is required".into()))?
|
||||
.to_string();
|
||||
let response = native_workflow_request(state, Method::POST, "/upload/image", None)
|
||||
@@ -4179,10 +4196,16 @@ async fn native_workflow_raw_response(
|
||||
) -> AppResult<Response> {
|
||||
let is_prompt_post = method == Method::POST && path.split('?').next() == Some("/prompt");
|
||||
let mut request = native_workflow_request(state, method, path, None);
|
||||
if let Some(content_type) = headers.get(header::CONTENT_TYPE).and_then(|value| value.to_str().ok()) {
|
||||
if let Some(content_type) = headers
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
{
|
||||
request = request.header(reqwest::header::CONTENT_TYPE, content_type);
|
||||
}
|
||||
if let Some(accept) = headers.get(header::ACCEPT).and_then(|value| value.to_str().ok()) {
|
||||
if let Some(accept) = headers
|
||||
.get(header::ACCEPT)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
{
|
||||
request = request.header(reqwest::header::ACCEPT, accept);
|
||||
}
|
||||
if !body.is_empty() {
|
||||
@@ -4861,7 +4884,10 @@ async fn experiment_models_in_folder(
|
||||
native_items.extend(items);
|
||||
}
|
||||
}
|
||||
return Ok(Json(merge_model_payload_items(api_model_items, native_items)));
|
||||
return Ok(Json(merge_model_payload_items(
|
||||
api_model_items,
|
||||
native_items,
|
||||
)));
|
||||
}
|
||||
let path = format!("/experiment/models/{}", simple_urlencode(&folder));
|
||||
return native_workflow_json_cached(
|
||||
@@ -4909,7 +4935,10 @@ async fn experiment_models_in_folder(
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(Json(merge_model_payload_items(native_items, api_model_items)));
|
||||
return Ok(Json(merge_model_payload_items(
|
||||
native_items,
|
||||
api_model_items,
|
||||
)));
|
||||
}
|
||||
let caller =
|
||||
auth_caller_optional(&state, &headers).unwrap_or(WorkflowCaller::User { user_id: 0 });
|
||||
@@ -4926,7 +4955,10 @@ async fn experiment_models_in_folder(
|
||||
}));
|
||||
}
|
||||
}
|
||||
Ok(Json(merge_model_payload_items(api_model_items, local_items)))
|
||||
Ok(Json(merge_model_payload_items(
|
||||
api_model_items,
|
||||
local_items,
|
||||
)))
|
||||
}
|
||||
|
||||
async fn view_metadata(
|
||||
@@ -5228,9 +5260,14 @@ async fn workflow_context(state: &AppState, caller: &WorkflowCaller) -> AppResul
|
||||
.bind(user_id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
maybe_key
|
||||
.map(|key| key.key)
|
||||
.unwrap_or_else(|| state.builtin_keys.comfyui_agent.1.clone())
|
||||
// Must run on the user's own key so consumption is billed to them.
|
||||
// Falling back to the global builtin key would mix every user's
|
||||
// cost into one shared account, breaking per-user accounting.
|
||||
maybe_key.map(|key| key.key).ok_or_else(|| {
|
||||
AppError::Forbidden(
|
||||
"当前用户没有可用的 API Key,无法计费执行工作流,请先创建 API Key".into(),
|
||||
)
|
||||
})?
|
||||
}
|
||||
};
|
||||
let host = if state.config.server.host == "0.0.0.0" || state.config.server.host == "::" {
|
||||
@@ -5242,6 +5279,7 @@ async fn workflow_context(state: &AppState, caller: &WorkflowCaller) -> AppResul
|
||||
user_id: caller_user_id(caller),
|
||||
bearer,
|
||||
base_url: format!("http://{}:{}/v1", host, state.config.server.port),
|
||||
charge_points: !caller_is_admin(caller),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5812,20 +5850,23 @@ mod tests {
|
||||
.filter_map(|item| item.get("name").and_then(Value::as_str))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(
|
||||
names,
|
||||
vec![
|
||||
"wan2.1-t2v",
|
||||
"local-video-model.safetensors",
|
||||
]
|
||||
);
|
||||
assert_eq!(names, vec!["wan2.1-t2v", "local-video-model.safetensors",]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_ui_runtime_assets_revalidate_after_install() {
|
||||
assert_eq!(cache_control_for_path(StdPath::new("index.html")), "no-cache");
|
||||
assert_eq!(cache_control_for_path(StdPath::new("assets/index.js")), "no-cache");
|
||||
assert_eq!(cache_control_for_path(StdPath::new("assets/index.css")), "no-cache");
|
||||
assert_eq!(
|
||||
cache_control_for_path(StdPath::new("index.html")),
|
||||
"no-cache"
|
||||
);
|
||||
assert_eq!(
|
||||
cache_control_for_path(StdPath::new("assets/index.js")),
|
||||
"no-cache"
|
||||
);
|
||||
assert_eq!(
|
||||
cache_control_for_path(StdPath::new("assets/index.css")),
|
||||
"no-cache"
|
||||
);
|
||||
assert_eq!(
|
||||
cache_control_for_path(StdPath::new("assets/logo.svg")),
|
||||
"public, max-age=604800"
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
use crate::error::AppResult;
|
||||
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.
|
||||
pub struct LedgerEntry<'a> {
|
||||
pub user_id: i64,
|
||||
pub track: &'a str, // "usd" | "points"
|
||||
pub entry_type: &'a str, // "recharge" | "admin_adjust" | "consume" | "refund" | "grant"
|
||||
pub amount: f64, // +credit / -debit
|
||||
pub balance_after: f64,
|
||||
pub ref_type: Option<&'a str>,
|
||||
pub ref_id: Option<&'a str>,
|
||||
pub model: Option<&'a str>,
|
||||
pub operator: Option<&'a str>,
|
||||
pub note: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub async fn ensure_ledger_schema(db: &SqlitePool) -> AppResult<()> {
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS credit_ledger (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
track TEXT NOT NULL,
|
||||
entry_type TEXT NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
balance_after REAL NOT NULL,
|
||||
ref_type TEXT,
|
||||
ref_id TEXT,
|
||||
model TEXT,
|
||||
operator TEXT,
|
||||
note TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"CREATE INDEX IF NOT EXISTS credit_ledger_user_created_idx
|
||||
ON credit_ledger (user_id, created_at DESC)",
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"CREATE INDEX IF NOT EXISTS credit_ledger_track_type_idx
|
||||
ON credit_ledger (track, entry_type)",
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS user_credits (
|
||||
user_id INTEGER PRIMARY KEY NOT NULL,
|
||||
points_balance REAL NOT NULL DEFAULT 0,
|
||||
points_total_recharged REAL NOT NULL DEFAULT 0,
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Append one ledger row inside an existing transaction. Caller is responsible
|
||||
/// for also updating the cached balance in the same `tx`.
|
||||
pub async fn record_ledger_entry(
|
||||
tx: &mut Transaction<'_, sqlx::Sqlite>,
|
||||
entry: LedgerEntry<'_>,
|
||||
) -> AppResult<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO credit_ledger
|
||||
(user_id, track, entry_type, amount, balance_after,
|
||||
ref_type, ref_id, model, operator, note)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(entry.user_id)
|
||||
.bind(entry.track)
|
||||
.bind(entry.entry_type)
|
||||
.bind(entry.amount)
|
||||
.bind(entry.balance_after)
|
||||
.bind(entry.ref_type)
|
||||
.bind(entry.ref_id)
|
||||
.bind(entry.model)
|
||||
.bind(entry.operator)
|
||||
.bind(entry.note)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Current points balance for a user (0 if no row yet).
|
||||
pub async fn points_balance(db: &SqlitePool, user_id: i64) -> AppResult<f64> {
|
||||
let bal: Option<f64> = sqlx::query_scalar(
|
||||
"SELECT points_balance FROM user_credits WHERE user_id = ?",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
Ok(bal.unwrap_or(0.0))
|
||||
}
|
||||
|
||||
/// Apply a points delta (+credit / -debit) atomically: upsert the cached
|
||||
/// balance in `user_credits` and append one `credit_ledger(track=points)` row,
|
||||
/// in a single transaction. Returns the new balance.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn apply_points_delta(
|
||||
db: &SqlitePool,
|
||||
user_id: i64,
|
||||
delta: f64,
|
||||
entry_type: &str,
|
||||
model: Option<&str>,
|
||||
operator: Option<&str>,
|
||||
ref_type: Option<&str>,
|
||||
ref_id: Option<&str>,
|
||||
) -> AppResult<f64> {
|
||||
ensure_ledger_schema(db).await?;
|
||||
let mut tx = db.begin().await?;
|
||||
let current: f64 = sqlx::query_scalar(
|
||||
"SELECT points_balance FROM user_credits WHERE user_id = ?",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.unwrap_or(0.0);
|
||||
let next = current + delta;
|
||||
let total_recharged_inc = if delta > 0.0 && entry_type == "recharge" { delta } else { 0.0 };
|
||||
sqlx::query(
|
||||
"INSERT INTO user_credits (user_id, points_balance, points_total_recharged, updated_at)
|
||||
VALUES (?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
points_balance = ?,
|
||||
points_total_recharged = points_total_recharged + ?,
|
||||
updated_at = datetime('now')",
|
||||
)
|
||||
.bind(user_id).bind(next).bind(total_recharged_inc)
|
||||
.bind(next).bind(total_recharged_inc)
|
||||
.execute(&mut *tx).await?;
|
||||
record_ledger_entry(&mut tx, LedgerEntry {
|
||||
user_id, track: "points", entry_type,
|
||||
amount: delta, balance_after: next,
|
||||
ref_type, ref_id, model, operator, note: None,
|
||||
}).await?;
|
||||
tx.commit().await?;
|
||||
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::*;
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
|
||||
async fn mem_pool() -> SqlitePool {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
ensure_ledger_schema(&pool).await.unwrap();
|
||||
pool
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn record_ledger_entry_writes_row_with_balance_snapshot() {
|
||||
let pool = mem_pool().await;
|
||||
let mut tx = pool.begin().await.unwrap();
|
||||
record_ledger_entry(
|
||||
&mut tx,
|
||||
LedgerEntry {
|
||||
user_id: 7,
|
||||
track: "usd",
|
||||
entry_type: "recharge",
|
||||
amount: 10.0,
|
||||
balance_after: 10.0,
|
||||
ref_type: Some("stripe"),
|
||||
ref_id: Some("cs_1"),
|
||||
model: None,
|
||||
operator: Some("system"),
|
||||
note: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let row: (i64, String, String, f64, f64) = sqlx::query_as(
|
||||
"SELECT user_id, track, entry_type, amount, balance_after
|
||||
FROM credit_ledger WHERE user_id = 7",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(row, (7, "usd".into(), "recharge".into(), 10.0, 10.0));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_points_delta_upserts_balance_and_writes_ledger() {
|
||||
let pool = mem_pool().await;
|
||||
// 首次 +100(充值)
|
||||
let bal = apply_points_delta(&pool, 9, 100.0, "recharge", None, Some("system"), Some("recharge_record"), None).await.unwrap();
|
||||
assert_eq!(bal, 100.0);
|
||||
// 再 -30(消费)
|
||||
let bal2 = apply_points_delta(&pool, 9, -30.0, "consume", Some("wf/video"), Some("system"), Some("workflow"), None).await.unwrap();
|
||||
assert_eq!(bal2, 70.0);
|
||||
// user_credits 缓存 = 70
|
||||
let cached: f64 = sqlx::query_scalar("SELECT points_balance FROM user_credits WHERE user_id = 9").fetch_one(&pool).await.unwrap();
|
||||
assert_eq!(cached, 70.0);
|
||||
// ledger 两行
|
||||
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"));
|
||||
}
|
||||
}
|
||||
+148
-11
@@ -1,6 +1,8 @@
|
||||
pub mod ledger;
|
||||
|
||||
use crate::{
|
||||
error::{AppError, AppResult},
|
||||
services::shared::{enforce_key_request_limit, increment_key_usage},
|
||||
services::shared::enforce_key_request_limit,
|
||||
};
|
||||
use chrono::Timelike;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -54,25 +56,68 @@ impl UsageTracker {
|
||||
cost: f64,
|
||||
) -> AppResult<()> {
|
||||
Self::ensure_schema(&self.db).await?;
|
||||
crate::services::billing::ledger::ensure_ledger_schema(&self.db).await?;
|
||||
let prompt_tokens = prompt_tokens.max(0);
|
||||
let completion_tokens = completion_tokens.max(0);
|
||||
let total_tokens = prompt_tokens + completion_tokens;
|
||||
let cost = cost.max(0.0);
|
||||
let model = model.into();
|
||||
|
||||
let mut tx = self.db.begin().await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO usage_logs
|
||||
(downstream_key_id, model, prompt_tokens, completion_tokens, total_tokens, cost)
|
||||
VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(key_id)
|
||||
.bind(model.into())
|
||||
.bind(&model)
|
||||
.bind(prompt_tokens)
|
||||
.bind(completion_tokens)
|
||||
.bind(total_tokens)
|
||||
.bind(cost.max(0.0))
|
||||
.execute(&*self.db)
|
||||
.bind(cost)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
increment_key_usage(&self.db, key_id, cost.max(0.0)).await?;
|
||||
sqlx::query(
|
||||
"UPDATE downstream_api_keys
|
||||
SET used_requests = COALESCE(used_requests,0)+1,
|
||||
used_cost = COALESCE(used_cost,0)+?,
|
||||
last_used_at = datetime('now'), updated_at = datetime('now')
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(cost)
|
||||
.bind(key_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Resolve owning user + new used_cost for the ledger snapshot.
|
||||
let owner: Option<(Option<i64>, Option<f64>, Option<f64>)> = sqlx::query_as(
|
||||
"SELECT user_id, max_cost, used_cost FROM downstream_api_keys WHERE id = ?",
|
||||
)
|
||||
.bind(key_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
if let Some((Some(user_id), max_cost, used_cost)) = owner {
|
||||
let used = used_cost.unwrap_or(0.0);
|
||||
let remaining = max_cost.map(|m| (m - used).max(0.0)).unwrap_or(used);
|
||||
crate::services::billing::ledger::record_ledger_entry(
|
||||
&mut tx,
|
||||
crate::services::billing::ledger::LedgerEntry {
|
||||
user_id,
|
||||
track: "usd",
|
||||
entry_type: "consume",
|
||||
amount: -cost,
|
||||
balance_after: remaining,
|
||||
ref_type: Some("usage_log"),
|
||||
ref_id: None,
|
||||
model: Some(&model),
|
||||
operator: Some("system"),
|
||||
note: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -229,7 +274,12 @@ impl UsageTracker {
|
||||
.bind(event.key_id)
|
||||
.bind(&event.model)
|
||||
.bind(&event.model)
|
||||
.bind(event.error_message.as_deref().unwrap_or("Comfy task failed"))
|
||||
.bind(
|
||||
event
|
||||
.error_message
|
||||
.as_deref()
|
||||
.unwrap_or("Comfy task failed"),
|
||||
)
|
||||
.bind(event.client_family)
|
||||
.bind(event.client_app_id)
|
||||
.bind(event.client_app_name)
|
||||
@@ -825,6 +875,7 @@ mod tests {
|
||||
used_cost REAL DEFAULT 0,
|
||||
max_requests INTEGER,
|
||||
used_requests INTEGER DEFAULT 0,
|
||||
user_id INTEGER,
|
||||
last_used_at TEXT,
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
)",
|
||||
@@ -844,6 +895,62 @@ mod tests {
|
||||
pool
|
||||
}
|
||||
|
||||
async fn billing_test_pool() -> SqlitePool {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TEXT,
|
||||
updated_at TEXT
|
||||
)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TABLE downstream_api_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
max_cost REAL,
|
||||
used_cost REAL DEFAULT 0,
|
||||
max_requests INTEGER,
|
||||
used_requests INTEGER DEFAULT 0,
|
||||
user_id INTEGER,
|
||||
last_used_at TEXT,
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO users (id, username, email, password_hash, role, status)
|
||||
VALUES (9, 'creator', 'creator@example.test', 'hash', 'user', 'active')",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO downstream_api_keys (id, name, key, max_cost, used_cost, user_id)
|
||||
VALUES (7, 'default', 'sk-user-test', 10.0, 2.0, 9)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
pool
|
||||
}
|
||||
|
||||
async fn create_proxy_logs(pool: &SqlitePool) {
|
||||
sqlx::query(
|
||||
"CREATE TABLE proxy_logs (
|
||||
@@ -911,6 +1018,32 @@ mod tests {
|
||||
assert_eq!(summary.total_cost, 0.25);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn record_usage_writes_consume_ledger_entry() {
|
||||
let pool = billing_test_pool().await;
|
||||
let tracker = UsageTracker::new(std::sync::Arc::new(pool.clone()));
|
||||
crate::services::billing::ledger::ensure_ledger_schema(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
// key_id=7 属于 user 9,max_cost=10 used_cost=2(由 helper 建好)
|
||||
tracker
|
||||
.record_usage(7, "gpt-4o", 100, 50, 0.42)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let row: (String, String, f64, Option<String>) = sqlx::query_as(
|
||||
"SELECT track, entry_type, amount, model FROM credit_ledger
|
||||
WHERE user_id = 9 ORDER BY id DESC LIMIT 1",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(row.0, "usd");
|
||||
assert_eq!(row.1, "consume");
|
||||
assert_eq!(row.2, -0.42); // 消费为负
|
||||
assert_eq!(row.3.as_deref(), Some("gpt-4o"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_checks_request_and_cost_limits() {
|
||||
let tracker = UsageTracker::new(Arc::new(pool().await));
|
||||
@@ -1017,7 +1150,10 @@ mod tests {
|
||||
.fetch_one(&*tracker.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(proxy_log, ("failed".to_string(), 0.0, "Not found".to_string()));
|
||||
assert_eq!(
|
||||
proxy_log,
|
||||
("failed".to_string(), 0.0, "Not found".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1076,7 +1212,9 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let projected = UsageTracker::project_proxy_usage_for_db(&pool).await.unwrap();
|
||||
let projected = UsageTracker::project_proxy_usage_for_db(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(projected, 1);
|
||||
|
||||
let model_usage: (i64, i64, f64, i64) = sqlx::query_as(
|
||||
@@ -1099,9 +1237,8 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(site_usage, (1, 1, 0.12));
|
||||
|
||||
let hour_bucket: String = sqlx::query_scalar(
|
||||
"SELECT bucket_start_utc FROM site_hour_usage WHERE site_id = 10",
|
||||
)
|
||||
let hour_bucket: String =
|
||||
sqlx::query_scalar("SELECT bucket_start_utc FROM site_hour_usage WHERE site_id = 10")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
+2508
-33
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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: () =>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+110
-1
@@ -13,6 +13,30 @@ use std::{collections::HashMap, sync::Arc, time::Duration};
|
||||
|
||||
const DEFAULT_TEXT_TO_VIDEO_MODEL: &str = "seedance-1-0-lite-t2v-250428";
|
||||
|
||||
/// Gate a workflow run on the caller's points balance. No-op when the caller
|
||||
/// is not being charged (admin), has no user id, or the cost is zero.
|
||||
async fn ensure_workflow_points(
|
||||
db: &sqlx::SqlitePool,
|
||||
user_id: Option<i64>,
|
||||
charge_points: bool,
|
||||
cost: f64,
|
||||
) -> AppResult<()> {
|
||||
if !charge_points {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(uid) = user_id else {
|
||||
return Ok(());
|
||||
};
|
||||
if cost <= 0.0 {
|
||||
return Ok(());
|
||||
}
|
||||
let bal = crate::services::billing::ledger::points_balance(db, uid).await?;
|
||||
if bal < cost {
|
||||
return Err(AppError::Forbidden("工作流积分不足,请充值".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_video_model(value: &str) -> String {
|
||||
match value.trim() {
|
||||
"seedance-1-0-lite-t2v" => "seedance-1-0-lite-t2v-250428".to_string(),
|
||||
@@ -81,6 +105,8 @@ pub async fn run_workflow_with_progress(
|
||||
.map_err(|err| AppError::BadRequest(format!("invalid workflow graph: {err}")))?;
|
||||
let order = graph::validate_and_sort(&graph)?;
|
||||
let total = order.len().max(1);
|
||||
let cost = state.config.comfyui.workflow_points_cost;
|
||||
ensure_workflow_points(&state.db, ctx.user_id, ctx.charge_points, cost).await?;
|
||||
let (run_db_id, run_public_id) =
|
||||
storage::insert_run(state, workflow_id, ctx.user_id, &input_overrides).await?;
|
||||
emit_progress(
|
||||
@@ -161,6 +187,30 @@ pub async fn run_workflow_with_progress(
|
||||
|
||||
let final_outputs = json!({ "nodes": store });
|
||||
storage::complete_run(state, run_db_id, &final_outputs).await?;
|
||||
if ctx.charge_points && cost > 0.0 {
|
||||
if let Some(uid) = ctx.user_id {
|
||||
if let Err(err) = crate::services::billing::ledger::apply_points_delta(
|
||||
&state.db,
|
||||
uid,
|
||||
-cost,
|
||||
"consume",
|
||||
Some(&format!("workflow:{}", workflow_id)),
|
||||
Some("system"),
|
||||
Some("workflow_run"),
|
||||
Some(&run_public_id),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
user_id = uid,
|
||||
workflow_id,
|
||||
run = %run_public_id,
|
||||
error = %err,
|
||||
"failed to charge workflow points after successful run"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
emit_progress(
|
||||
&progress,
|
||||
WorkflowProgressEvent::Finished {
|
||||
@@ -658,7 +708,11 @@ fn video_request_body(node: &WorkflowNode, prompt: String, image_url: Option<Str
|
||||
let mut body = Map::new();
|
||||
body.insert(
|
||||
"model".to_string(),
|
||||
json!(normalize_video_model(¶m_str(node, "model", DEFAULT_TEXT_TO_VIDEO_MODEL))),
|
||||
json!(normalize_video_model(¶m_str(
|
||||
node,
|
||||
"model",
|
||||
DEFAULT_TEXT_TO_VIDEO_MODEL
|
||||
))),
|
||||
);
|
||||
body.insert("prompt".to_string(), json!(prompt));
|
||||
for key in ["ratio", "resolution"] {
|
||||
@@ -1088,3 +1142,58 @@ fn value_to_string(value: Option<&Value>) -> String {
|
||||
Some(other) => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::services::billing::ledger;
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
|
||||
async fn mem_pool() -> sqlx::SqlitePool {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
ledger::ensure_ledger_schema(&pool).await.unwrap();
|
||||
pool
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_workflow_points_skips_when_not_charging() {
|
||||
let pool = mem_pool().await;
|
||||
// charge_points=false (admin) + zero balance + cost 10 => Ok (no gating).
|
||||
ensure_workflow_points(&pool, Some(9), false, 10.0)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_workflow_points_rejects_insufficient_balance() {
|
||||
let pool = mem_pool().await;
|
||||
// user 9 has zero points, cost 10 => Forbidden.
|
||||
let err = ensure_workflow_points(&pool, Some(9), true, 10.0)
|
||||
.await
|
||||
.unwrap_err();
|
||||
match err {
|
||||
AppError::Forbidden(msg) => assert!(msg.contains("积分不足"), "msg was: {msg}"),
|
||||
other => panic!("expected Forbidden, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_workflow_points_allows_sufficient_balance_and_zero_cost() {
|
||||
let pool = mem_pool().await;
|
||||
ledger::apply_points_delta(&pool, 9, 100.0, "recharge", None, Some("system"), None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
// Balance 100 >= cost 10 => Ok.
|
||||
ensure_workflow_points(&pool, Some(9), true, 10.0)
|
||||
.await
|
||||
.unwrap();
|
||||
// cost 0 => noop Ok regardless of balance.
|
||||
ensure_workflow_points(&pool, Some(9), true, 0.0)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@ pub struct RunContext {
|
||||
pub user_id: Option<i64>,
|
||||
pub bearer: String,
|
||||
pub base_url: String,
|
||||
pub charge_points: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
||||
@@ -163,6 +163,7 @@ async fn run_context(state: &AppState, caller: &WorkflowCaller) -> AppResult<Run
|
||||
user_id: caller_user_id(caller),
|
||||
bearer,
|
||||
base_url: format!("http://{}:{}/v1", host, state.config.server.port),
|
||||
charge_points: !caller_is_admin(caller),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user