Compare commits
6 Commits
a89edf6f75
...
nit_edu
| Author | SHA1 | Date | |
|---|---|---|---|
| c3293e007f | |||
| a1b7b60ad4 | |||
| bfd804f719 | |||
| e7f3da5961 | |||
| 5cfd533d89 | |||
| 0b7abed79a |
@@ -567,3 +567,128 @@ pub async fn apply_points_delta(
|
|||||||
|
|
||||||
### Task 10:第 2 期全量回归
|
### Task 10:第 2 期全量回归
|
||||||
- [ ] `cargo test 2>&1 | grep "test result"` 全 ok;`cargo build -j 2 | tail -2` Finished。
|
- [ ] `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,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 风格。
|
||||||
+950
-170
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
|||||||
use crate::error::AppResult;
|
use crate::error::AppResult;
|
||||||
use sqlx::{SqlitePool, Transaction};
|
use serde_json::{json, Value};
|
||||||
|
use sqlx::{Row, SqlitePool, Transaction};
|
||||||
|
|
||||||
/// One balance-changing event. Written to `credit_ledger` in the same
|
/// One balance-changing event. Written to `credit_ledger` in the same
|
||||||
/// transaction that updates the cached balance, so ledger and cache never diverge.
|
/// transaction that updates the cached balance, so ledger and cache never diverge.
|
||||||
@@ -143,6 +144,77 @@ pub async fn apply_points_delta(
|
|||||||
Ok(next)
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -207,4 +279,17 @@ mod tests {
|
|||||||
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM credit_ledger WHERE user_id=9 AND track='points'").fetch_one(&pool).await.unwrap();
|
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);
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -539,6 +539,22 @@ describe('user recharge api', () => {
|
|||||||
expect((fetchMock.mock.calls[1]?.[1] as RequestInit)?.method).toBe('DELETE');
|
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 () => {
|
it('keeps billing guidance on quota errors', async () => {
|
||||||
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||||
error: { message: '下游密钥成本额度已用完' },
|
error: { message: '下游密钥成本额度已用完' },
|
||||||
@@ -561,4 +577,29 @@ describe('user recharge api', () => {
|
|||||||
retryAfterSecs: 60,
|
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 & {
|
type RequestOptions = RequestInit & {
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
|
preserveAuthErrors?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ApiRequestError = Error & {
|
export type ApiRequestError = Error & {
|
||||||
@@ -44,6 +45,18 @@ function requireAuthToken(): string {
|
|||||||
return token;
|
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> {
|
async function extractResponseError(res: Response): Promise<ApiRequestError> {
|
||||||
let message = `HTTP ${res.status}`;
|
let message = `HTTP ${res.status}`;
|
||||||
let payload: any = null;
|
let payload: any = null;
|
||||||
@@ -69,7 +82,7 @@ async function extractResponseError(res: Response): Promise<ApiRequestError> {
|
|||||||
message = `${message} (${json.path.trim()})`;
|
message = `${message} (${json.path.trim()})`;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
message = `${message}: ${text.slice(0, 120)}`;
|
message = sanitizeErrorText(res.status, text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -136,6 +149,7 @@ async function fetchAuthenticatedResponse(
|
|||||||
const {
|
const {
|
||||||
timeoutMs = 30_000,
|
timeoutMs = 30_000,
|
||||||
signal: externalSignal,
|
signal: externalSignal,
|
||||||
|
preserveAuthErrors = false,
|
||||||
...fetchOptions
|
...fetchOptions
|
||||||
} = options;
|
} = options;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -168,7 +182,7 @@ async function fetchAuthenticatedResponse(
|
|||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
if (res.status === 401 || res.status === 403) {
|
if (!preserveAuthErrors && (res.status === 401 || res.status === 403)) {
|
||||||
const hadToken = !!getSessionToken('admin', localStorage);
|
const hadToken = !!getSessionToken('admin', localStorage);
|
||||||
clearSession('admin', localStorage);
|
clearSession('admin', localStorage);
|
||||||
if (
|
if (
|
||||||
@@ -205,6 +219,7 @@ async function fetchUserResponse(
|
|||||||
const {
|
const {
|
||||||
timeoutMs = 30_000,
|
timeoutMs = 30_000,
|
||||||
signal: externalSignal,
|
signal: externalSignal,
|
||||||
|
preserveAuthErrors: _preserveAuthErrors = false,
|
||||||
...fetchOptions
|
...fetchOptions
|
||||||
} = options;
|
} = options;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -336,6 +351,65 @@ async function request<T = any>(
|
|||||||
return parseJsonResponse<T>(res);
|
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[] {
|
function unwrapArrayResponse(value: any, keys: string[] = ["items", "data"]): any[] {
|
||||||
if (Array.isArray(value)) return value;
|
if (Array.isArray(value)) return value;
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
@@ -889,6 +963,7 @@ export type RuntimeSettingsPayload = {
|
|||||||
systemProxyUrl?: string;
|
systemProxyUrl?: string;
|
||||||
payloadRules?: Record<string, unknown> | null;
|
payloadRules?: Record<string, unknown> | null;
|
||||||
modelAvailabilityProbeEnabled?: boolean;
|
modelAvailabilityProbeEnabled?: boolean;
|
||||||
|
registrationDisabled?: boolean;
|
||||||
codexUpstreamWebsocketEnabled?: boolean;
|
codexUpstreamWebsocketEnabled?: boolean;
|
||||||
responsesCompactFallbackToResponsesEnabled?: boolean;
|
responsesCompactFallbackToResponsesEnabled?: boolean;
|
||||||
disableCrossProtocolFallback?: boolean;
|
disableCrossProtocolFallback?: boolean;
|
||||||
@@ -1422,6 +1497,18 @@ export type DigitalHumanStatusResponse = {
|
|||||||
mode: string;
|
mode: string;
|
||||||
keyName?: string;
|
keyName?: string;
|
||||||
capabilities?: 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?: {
|
runninghub?: {
|
||||||
configured?: boolean;
|
configured?: boolean;
|
||||||
endpoint?: string;
|
endpoint?: string;
|
||||||
@@ -1448,6 +1535,8 @@ export type MoneyPrinterJobsResponse = {
|
|||||||
export type MoneyPrinterStatusResponse = {
|
export type MoneyPrinterStatusResponse = {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
mode: string;
|
mode: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
disabledReason?: string | null;
|
||||||
keyName?: string;
|
keyName?: string;
|
||||||
capabilities?: string[];
|
capabilities?: string[];
|
||||||
comfy?: {
|
comfy?: {
|
||||||
@@ -1548,6 +1637,106 @@ export type ClearUserRechargeRecordsResponse = {
|
|||||||
rechargePath?: string;
|
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 = {
|
export type WorkflowModelItem = {
|
||||||
name: string;
|
name: string;
|
||||||
pathIndex?: number;
|
pathIndex?: number;
|
||||||
@@ -1716,6 +1905,176 @@ export const api = {
|
|||||||
timeoutMs: data?.wait ? 150_000 : 30_000,
|
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
|
// Account tokens
|
||||||
getAccountTokens: async (accountId?: number) => {
|
getAccountTokens: async (accountId?: number) => {
|
||||||
const result = await request<any>(
|
const result = await request<any>(
|
||||||
@@ -2412,6 +2771,33 @@ export const api = {
|
|||||||
request(`/api/admin/users${params ? `?${params.toString()}` : ""}`),
|
request(`/api/admin/users${params ? `?${params.toString()}` : ""}`),
|
||||||
getUserById: (id: number) => request(`/api/admin/users/${id}`),
|
getUserById: (id: number) => request(`/api/admin/users/${id}`),
|
||||||
getUserUsage: (id: number) => request(`/api/admin/users/${id}/usage`),
|
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 }) =>
|
createUser: (data: { username: string; email: string; password: string; role?: string }) =>
|
||||||
request("/api/admin/users", { method: "POST", body: JSON.stringify(data) }),
|
request("/api/admin/users", { method: "POST", body: JSON.stringify(data) }),
|
||||||
updateUser: (id: number, data: { role?: string; status?: string; username?: string; email?: string; password?: string }) =>
|
updateUser: (id: number, data: { role?: string; status?: string; username?: string; email?: string; password?: string }) =>
|
||||||
@@ -2545,6 +2931,15 @@ export const userApi = {
|
|||||||
},
|
},
|
||||||
getAnnouncements: (params?: string) =>
|
getAnnouncements: (params?: string) =>
|
||||||
userRequest(`/api/user/announcements${params ? "?" + params : ""}`),
|
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) =>
|
markAnnouncementRead: (id: number) =>
|
||||||
userRequest(`/api/user/announcements/${id}/read`, { method: "POST" }),
|
userRequest(`/api/user/announcements/${id}/read`, { method: "POST" }),
|
||||||
markAllAnnouncementsRead: () =>
|
markAllAnnouncementsRead: () =>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { MODEL_TESTER_STORAGE_KEY } from './helpers/modelTesterSession.js';
|
|||||||
const { userApiMock, toastMock } = vi.hoisted(() => ({
|
const { userApiMock, toastMock } = vi.hoisted(() => ({
|
||||||
userApiMock: {
|
userApiMock: {
|
||||||
getLogs: vi.fn(),
|
getLogs: vi.fn(),
|
||||||
|
getBillingLedger: vi.fn(),
|
||||||
|
getBillingSummary: vi.fn(),
|
||||||
},
|
},
|
||||||
toastMock: {
|
toastMock: {
|
||||||
success: vi.fn(),
|
success: vi.fn(),
|
||||||
@@ -503,4 +505,58 @@ describe('UsageRecords diagnostics', () => {
|
|||||||
|
|
||||||
expect(collectText(root.root.findByProps({ 'data-testid': 'location-probe' }))).toBe('/user/usage');
|
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 React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { userApi } from '../api.js';
|
import { userApi } from '../api.js';
|
||||||
|
import type { BillingLedgerEntry, BillingSummaryRow } from '../api.js';
|
||||||
import { MobileCard, MobileField } from '../components/MobileCard.js';
|
import { MobileCard, MobileField } from '../components/MobileCard.js';
|
||||||
import PageHeader from '../components/PageHeader.js';
|
import PageHeader from '../components/PageHeader.js';
|
||||||
import ResponsiveFilterPanel from '../components/ResponsiveFilterPanel.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() {
|
export default function UsageRecords() {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -298,6 +311,10 @@ export default function UsageRecords() {
|
|||||||
const [dateTo, setDateTo] = useState(initialFilters.dateTo);
|
const [dateTo, setDateTo] = useState(initialFilters.dateTo);
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
const [showFilters, setShowFilters] = useState(false);
|
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 toast = useToast();
|
||||||
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
@@ -324,6 +341,28 @@ export default function UsageRecords() {
|
|||||||
}
|
}
|
||||||
}, [toast]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const next = readUserUsageFiltersFromSearch(location.search);
|
const next = readUserUsageFiltersFromSearch(location.search);
|
||||||
setPage((value) => (value === next.page ? value : next.page));
|
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 */}
|
{/* Summary cards */}
|
||||||
<div className="usage-records-summary-grid">
|
<div className="usage-records-summary-grid">
|
||||||
{[
|
{[
|
||||||
@@ -863,6 +985,8 @@ export default function UsageRecords() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user