145 Commits

Author SHA1 Message Date
boos c3293e007f docs(nit): AI Studio 第一阶段(聚合图像/数字人)设计 spec
NIT 精英班专属 AI Studio 第一阶段:聚合 picture_agent(图像)+ digital_human(数字人)。
学员下游 key 配置 + surface 认证扩展(接受 student JWT)+ 积分轨计费 + 统一入口前端。
不碰 money_printer,视频/文本创作/PPT 留后续阶段。前置依赖第一期报考闭环。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-17 04:43:51 +08:00
boos a1b7b60ad4 docs(nit): 第一期报考闭环 A+B+C 设计 spec
NIT 报考学习平台第一期设计:独立 students 表、线下缴费上传凭证人工审核、
开课权限状态标记、平台超管后台、模块内嵌复用现有 axum/sqlx/JWT/workspace 基建。
不含学习中心/OCR/校区负责人多级权限/内容保护(留后续期)。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-17 04:24:05 +08:00
boos bfd804f719 feat(billing): UsageRecords dual-track ledger view
api.ts 新增 userApi.getBillingLedger / getBillingSummary 及 Billing* 类型;
UsageRecords.tsx 增加 USD/积分 双轨切换 tab、积分汇总卡与明细表(切到积分轨时懒加载)。
随文件一并提交此前累积的支付/Comfy Cloud/工作流前端 API 改动。

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-17 02:56:12 +08:00
boos 5cfd533d89 feat(billing): user ledger list + summary queries 2026-06-17 01:49:46 +08:00
boos 0b7abed79a docs: 细化计费第3期(消费明细)TDD Task11-14 2026-06-17 01:44:28 +08:00
boos a89edf6f75 feat(payment): comfy 登录态/充值确认/记录管理 + Task9 积分到账调用适配
含本会话累积的支付路由功能(firebase-login 回执登录、recharge-records
列表/确认/清空、诊断卡)及 admin_confirm_user_recharge 传 config 的调用适配,
使计费 Task9(comfy 充值改积分)调用方编译完整。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 21:13:16 +08:00
boos 5d42671172 feat(billing): comfy recharge to points + admin credit adjust
Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 21:11:44 +08:00
boos ca9e45c59c feat(billing): workflow points gating + charge
Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 20:58:02 +08:00
boos be7538a1da feat(billing): workflow_points_cost config 2026-06-16 20:32:10 +08:00
boos cfe2098b73 feat(billing): points track apply_points_delta + balance 2026-06-16 20:24:50 +08:00
boos bd464016a8 docs: 细化计费第2期(积分轨)TDD 任务 Task6-10 2026-06-16 20:20:22 +08:00
boos 7e8430934e feat(billing): USD admin adjust writes credit_ledger
Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 17:46:56 +08:00
boos ab2fd271e8 feat(billing): USD recharge writes credit_ledger 2026-06-16 17:40:43 +08:00
boos 4dd2be4cc3 feat(billing): USD consume writes credit_ledger in one tx
Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 17:34:21 +08:00
boos fd94303433 feat(billing): credit_ledger schema + record_ledger_entry 2026-06-16 17:25:59 +08:00
boos 91dcbc5c53 docs: 计费闭环实现计划(第1期详细TDD + 后5期概要)
第1期地基: credit_ledger + user_credits 表, USD 轨三处记账
(消费/充值/管理员调整)同事务接入 ledger。每步含完整测试与代码。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 17:17:54 +08:00
boos 278b0c66b8 docs: 计费闭环系统设计 spec
双轨额度(USD 池 + 工作流积分池)、ledger-first 统一流水账、
消费明细/充值档位/超额预警/全量审计四块功能、A+C 混合积分计费、
Comfy 余额对账校准。分 6 期实现。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 17:12:02 +08:00
boos d697146941 feat(audit): login audit log with account info and IPv4
- 审计日志记录具体账号(用户名<邮箱>)
- 优先显示 IPv4,保留 IPv6
- Program Logs 页面新增 account/IPv4 列
2026-06-10 19:54:32 +08:00
boos d1f3f4e93d feat(payment): Stripe Checkout payment module
- payment_orders 建表迁移,支持多 provider(stripe)
- Stripe Checkout Session 创建、Webhook 验签、到账
- 支付成功自动为 API Key 增加额度
- payment.enabled 开关,关闭时不影响旧 Comfy Checkout
2026-06-10 19:54:27 +08:00
boos df97633300 fix(recharge): per-amount distinct checkout URLs
- 前端 /0/0/0 各自携带独立 checkoutUrl
- 后端按金额解析独立 Checkout URL 配置
- 同时校验 optionId 和 amountUsd 防止金额错配
- 移除旧单一固定 checkout URL 兜底
2026-06-10 19:54:20 +08:00
boos 3d54c7d529 fix: support user recharge record cancellation 2026-06-09 17:22:23 +08:00
boos 55b3d4d99a feat: enhance new website experiment motion 2026-06-09 16:59:04 +08:00
boos 59106e6a96 feat: refine BoosAPI console and user workspace 2026-06-09 16:48:18 +08:00
boos aaba6f152e perf: optimize proxy streaming and database access 2026-06-07 00:00:24 +08:00
boos f3d2d50f9a fix: make website boot animation resilient 2026-06-06 22:38:31 +08:00
boos 16162095f7 style: polish admin operations pages 2026-06-06 22:19:42 +08:00
boos 6629d9d0a3 fix: stabilize console topbar layout 2026-06-06 22:09:44 +08:00
boos 3c6f061709 fix: improve assistant thinking and image progress 2026-06-06 22:02:45 +08:00
boos d7d14e077e fix: stabilize website boot loader 2026-06-06 21:54:25 +08:00
boos 3b3292b934 feat: add admin operations war room 2026-06-06 21:50:23 +08:00
boos f7916a42e5 feat: enhance website flow and user workbench 2026-06-06 21:44:58 +08:00
boos f725de28bd feat: polish website and console dashboards 2026-06-06 21:15:33 +08:00
boos 97113964c8 fix: remove sidebar identity card 2026-06-06 21:01:20 +08:00
boos b429695cba fix: stabilize topbar layout 2026-06-06 20:57:33 +08:00
boos d6bf7352a0 feat: support local avatar uploads 2026-06-06 20:52:34 +08:00
boos d800ed1f9f fix: keep admin sidebar actions visible 2026-06-06 20:44:34 +08:00
boos 4855db3fc8 feat: polish user auth entrance 2026-06-06 20:38:18 +08:00
boos 6cd0a8caca fix: stream real agent reasoning 2026-06-06 20:26:31 +08:00
boos 44dc89a0dd refactor: share console page header 2026-06-06 20:18:28 +08:00
boos 823ac3c25d fix: show only generated outputs in workspace 2026-06-06 20:03:02 +08:00
boos 5ecea182bc feat: polish console shell and notification settings 2026-06-06 19:56:56 +08:00
boos cd843465b2 feat: show conversation content in usage logs 2026-06-06 19:36:42 +08:00
boos ef0f91e683 perf: speed up app loading 2026-06-06 19:21:59 +08:00
boos 9c1e5ae293 fix: keep video assistant reasoning label stable 2026-06-06 19:02:41 +08:00
boos 00b3365f5a feat: refine website gateway os hero 2026-06-06 18:59:10 +08:00
boos d5babf5f3f fix: support website color schemes 2026-06-06 18:45:12 +08:00
boos 4b757b8250 feat: add light website intro motion 2026-06-06 18:29:10 +08:00
boos def8d7a204 chore: speed up local build workflow 2026-06-06 18:10:16 +08:00
boos 25c6fdbd6c revert website high contrast redesign 2026-06-06 17:58:10 +08:00
boos 47202e6848 redesign website with high contrast motion 2026-06-06 17:54:38 +08:00
boos 1b2880739a polish auth entry surfaces 2026-06-06 17:42:14 +08:00
boos 188e457e7f polish topbar user experience 2026-06-06 17:31:41 +08:00
boos 13756adc9d polish about page experience 2026-06-06 17:25:31 +08:00
boos f026582008 fix: persist runtime proxy token 2026-06-06 17:11:51 +08:00
boos b3eb86d5ea chore: remove update center 2026-06-06 16:55:15 +08:00
boos 301d086cb6 fix: show runtime logs in program logs 2026-06-06 16:40:55 +08:00
boos 023384b14b style: refine website section spacing 2026-06-06 16:18:11 +08:00
boos d315f0ffe2 feat: polish website model radar 2026-06-06 16:14:05 +08:00
boos f0385c9c8c chore: install vercel agent skills 2026-06-06 16:07:34 +08:00
boos 339f91d014 feat: sync upstream analytics schema support 2026-06-06 16:07:13 +08:00
boos 0d231ec467 feat: enhance website model radar motion 2026-06-06 15:46:51 +08:00
boos be1fc1df48 feat: optimize routes and registration UX 2026-06-06 15:37:37 +08:00
boos dd874bae30 feat: refresh BossAI console frontend 2026-06-06 15:25:04 +08:00
boos 45aa741d9c feat: refine BossAI console and website design 2026-06-06 15:09:53 +08:00
boos 49c398db5e feat: polish BossAI website motion 2026-06-06 14:56:33 +08:00
boos 4d748dd938 feat: refine BossAI website product story 2026-06-06 06:27:28 +08:00
boos b40c522749 feat: add gsap motion to BossAI website 2026-06-06 06:18:40 +08:00
boos d467e27fd8 fix: guard embedded frontend assets 2026-06-06 06:11:15 +08:00
boos 5ef017c54e feat: serve website at root path 2026-06-06 05:59:48 +08:00
boos 03046152fd feat: isolate BossAI website with Next.js 2026-06-06 05:30:10 +08:00
boos d75daf72d6 feat: add premium public landing page 2026-06-06 05:06:37 +08:00
boos d39100b56d feat: optimize money printer factory 2026-06-06 04:56:02 +08:00
boos 2e4c5801b6 chore: tighten repository hygiene 2026-06-06 04:40:25 +08:00
boos 870b2a2da3 feat: surface admin settings attention states 2026-06-06 03:57:15 +08:00
boos 4448567368 feat: optimize admin settings page 2026-06-06 03:49:30 +08:00
boos fae0f607aa test: restore project verification suite 2026-06-06 03:35:29 +08:00
boos e4630c4cb6 fix: harden workflow smoke issues 2026-06-06 03:23:39 +08:00
boos e72e323313 feat: refine money printer comfy template mode 2026-06-06 03:09:08 +08:00
boos 2b836b3e1e Add built-in Cloud video template fallback 2026-06-06 02:58:16 +08:00
boos 598b2f48d6 Handle Comfy Cloud video job status 2026-06-05 22:01:03 +08:00
boos 3f22d7b1ab Make short video factory use Comfy templates 2026-06-05 21:35:12 +08:00
boos 5ce5ea1687 Route Comfy workflows through video API 2026-06-03 11:39:14 +08:00
boos f8741b544e Filter MoneyPrinter Comfy video templates 2026-06-03 11:29:55 +08:00
boos c316a78511 Default MoneyPrinter videos to BoosAPI route 2026-06-03 11:03:04 +08:00
boos 68ae9acb27 Clarify bundled Picture Agent template errors 2026-06-03 11:01:47 +08:00
boos 13c7d8aa4e Remove stale Picture Agent remote template state 2026-06-03 10:59:11 +08:00
boos fb5fc9f1ae Use bundled Linux Do picture templates 2026-06-03 10:55:43 +08:00
boos 5765ea48b0 Clarify Picture Agent template refresh state 2026-06-02 17:38:54 +08:00
boos 55bd3f41f8 Follow Picture Agent gallery data URL 2026-06-02 17:30:15 +08:00
boos 938597b766 Fix Picture Agent loading fallback 2026-06-02 17:23:36 +08:00
boos fe0e3faa22 Generate MoneyPrinter Comfy prompts from form 2026-06-02 17:18:23 +08:00
boos d0f26118ff Normalize MoneyPrinter Comfy workflow templates 2026-06-02 17:11:15 +08:00
boos c98d9a2c6d Load Comfy templates for MoneyPrinter 2026-06-02 17:00:46 +08:00
boos 1293ebae68 Use Comfy API for MoneyPrinter video generation 2026-06-02 16:47:48 +08:00
boos b29c7ffa7a Add native MoneyPrinter short video factory 2026-06-02 15:26:42 +08:00
boos 056e9fb651 Improve digital human template UI 2026-06-02 12:17:05 +08:00
boos fa5a2419d8 Add Comfy digital human jobs 2026-06-02 11:07:31 +08:00
boos 7b34153235 Proxy external template requests 2026-06-01 11:21:56 +08:00
boos 04792bd3f7 Improve offline template loading 2026-06-01 11:02:36 +08:00
boos bb31118792 Reference local Workflow UI repo directly 2026-05-28 17:19:32 +08:00
boos 5027767ca2 Use local Workflow UI source for BossAI deploy 2026-05-28 17:16:12 +08:00
boos fd930757f9 Move BossAI deploy worktree out of tmp 2026-05-28 17:03:08 +08:00
boos eb6ed11cff Add BossAI Debian deploy script 2026-05-28 16:59:51 +08:00
boos 931bcf3fb9 Update BossAI workflow and workspace experience 2026-05-28 10:32:20 +08:00
boos e0bd9b4194 Embed workflow UI and isolate workspace assets 2026-05-26 17:00:10 +08:00
boos 0f9fe4a81d chore: prune legacy node backend and mix workflow models 2026-05-26 15:18:33 +08:00
boos 6869504119 chore: add optional bun frontend build 2026-05-25 22:05:03 +08:00
boos 3f9f9f7665 docs: remove readme screenshots 2026-05-25 21:43:46 +08:00
boos a1f3400af3 docs: add hardware requirements 2026-05-25 21:41:47 +08:00
boos da686ecda4 chore: vendor workflow ui as submodule 2026-05-25 21:19:49 +08:00
boos 1ba293c262 feat: polish workflow studio and workspace assets 2026-05-25 20:42:38 +08:00
boos cb35d5fc6e feat: add workflow studio and postgres support 2026-05-25 16:03:07 +08:00
boos 7cdb02ef1d feat: add workspace and claude messages support 2026-05-22 05:57:37 +08:00
boos c1074ddf71 Integrate ComfyUI agents and video workflows 2026-05-22 04:46:15 +08:00
boos 3c12738138 fix: allow comfyui assets through proxy 2026-05-22 01:27:57 +08:00
boos 8de175a8c5 feat: add comfyui agent attachments 2026-05-22 01:16:37 +08:00
boos 7c6364a160 fix: enable multi tenant comfy workspace 2026-05-22 00:44:57 +08:00
boos 5ef3f8aea8 fix: route video workspace to comfyui proxy 2026-05-21 17:27:41 +02:00
boos 9e7ce80574 fix: complete account token admin routes 2026-05-21 17:16:24 +02:00
boos 65d2f1bbe5 fix: restore account health tokens and dashboard data 2026-05-21 17:07:18 +02:00
boos 0207ba748f fix: use user api key for comfyui agent 2026-05-21 16:55:32 +02:00
boos d0912e44b4 fix: route video agent to comfyui and ignore openspec 2026-05-21 16:44:30 +02:00
boos 9c84759498 feat: merge opus comfyui and login updates 2026-05-21 16:32:04 +02:00
boos c16479da7c fix(rust): sweep orphan route_channels when their model has no active account
The per-route loop in `rebuild_routes` only visits routes whose model_pattern
appears in the current `by_model` set. When the last active account for a
model goes away (account deleted, status flipped, model dropped from
model_availability), the model falls out of `by_model` entirely and its
existing auto-managed channels are never reconciled — they stay behind
pointing at a route nothing can serve.

Add a final orphan sweep over all auto-managed route_channels. Anything whose
route wasn't processed in the per-route loop gets hard-deleted. Manual-override
channels still get the same exemption as before.

Covered by two new in-memory sqlite tests:
- orphan_channel_is_cleaned_when_last_account_goes_inactive
- orphan_sweep_preserves_manual_channels
2026-05-20 19:58:45 +08:00
boos 423912277b fix(build): reuse multi-tenant ComfyUI image instead of rebuilding every run
- skip docker build when boosapi-comfyui:multi-tenant already exists;
  --rebuild-image opts back into a fresh build
- fall back to `docker load` from docker/boosapi-comfyui-multi-tenant-*.tar
  when the image is absent but the saved tar is present
- gate the whole block on `docker info` so a client-only host doesn't hang
  on build when the daemon is unreachable
2026-05-20 19:40:39 +08:00
boos cf696c01a5 fix(rust): trigger route rebuild on account writes; add segment filter
- spawn_route_rebuild after create / delete / model refresh / manual model
  upsert; gate update_account on status change so cosmetic edits don't
  burn a transaction
- list_accounts now accepts ?segment=apikey|session|oauth, filtered against
  extra_config.$.credentialMode (legacy null rows treated as apikey)
2026-05-20 19:35:53 +08:00
boos 7c9a1e2cc4 fix(rust): match main's drizzle-style camelCase response shape
Frontend reads main's drizzle-generated camelCase fields (siteId,
accessToken, oauthProvider, etc.) and a nested `site: {...}` on each
account row. Rust models were serializing snake_case with no nested
site, so PUT /api/sites/undefined/disabled-models 404s, account.site?.id
is undefined everywhere, and similar shape mismatches break Accounts,
Sites, Tokens, and Dashboard pages.

- Add #[serde(rename_all = "camelCase")] to every model struct in
  src/db/models/* (Account, AccountToken, Site, User, Event,
  TokenRoute, RouteChannel, BackgroundTask, DownstreamApiKey,
  Organization/Member/Invitation, SiteAnnouncement)
- list_accounts now nests `site: {...}` per row, matching main's
  `{...account, site: {...}}` shape from accountsOverviewService
- admin user-detail endpoint emits camelCase keys (totalRequests,
  totalCost, last7DaysCost, recentLogs, promptTokens, completionTokens,
  totalTokens, createdAt) to match the frontend
- routes refresh endpoint now emits both affected_routes and
  affectedRoutes for forward compat

Co-Authored-By: Kiro <noreply@kiro.dev>
2026-05-20 07:21:40 +08:00
boos 9c0d4ec69f feat(rust): wire downstream-key billing, add /v1/responses, ComfyUI shim
Previously every executor took `_state: AppState` (unused) and `check_auth`
discarded the matched downstream key id, so `usage_logs` was orphan code.
This change plumbs the key id from auth → handlers → executors → billing,
adds the OpenAI Responses API endpoint, and exposes shim aliases under
`/api/comfyui-agent/proxy/openai/*` so embedded ComfyUI's OpenAI nodes
flow through the same auth/conductor/billing pipeline.

- check_auth now returns Option<i64> (managed key id) instead of unit
- record_billing helper writes usage_logs after each successful upstream call
- extract_usage handles both chat-completions and responses usage shapes
- conductor wrappers record billing post-Success; stream variant scans
  chunks in reverse for the final usage event
- POST /v1/responses (stream + non-stream) via ProxyConductor
- shim aliases mounted on the same Router so auth/failover/billing work for free
- ComfyUIProcess injects BOOSAPI_BUILTIN_KEY env + --comfy-api-base on spawn
- bootstrap built-in `comfyui-agent` downstream key at startup (idempotent)
- get_auth_header in vendored ComfyUI falls back to BOOSAPI_BUILTIN_KEY env

Co-Authored-By: Kiro <noreply@kiro.dev>
2026-05-20 06:20:39 +08:00
boos 22751effd5 fix: refresh user logout state 2026-05-19 10:41:40 +02:00
boos 4fe84707e5 feat: complete user login admin keys and deployment docs 2026-05-19 10:06:52 +02:00
boos 812a7c6c07 fix: persist site api endpoints and account verification 2026-05-19 11:22:09 +08:00
boos b6aae5f301 feat: unify user and admin login entry 2026-05-19 10:53:38 +08:00
boos 27c2aca312 fix: show site presets and api key site options 2026-05-19 10:49:41 +08:00
boos 11144d0697 fix: complete gemini frontend and usage follow-up 2026-05-19 10:37:29 +08:00
boos 31e95c2634 fix: usage SQL 查询 ambiguous column
l.created_at 缺少表别名前缀导致歧义错误
2026-05-19 03:06:11 +08:00
boos 499dba6104 fix: 完善用户路由和注册回调
- App.tsx 导入 hasValidUserSession 修复 TS 编译错误
- UserRegister 增加 onLogin 回调,注册成功自动跳转
2026-05-19 02:47:23 +08:00
boos d432ded68f feat: 用户管理页面增加用量统计标签
- 用户列表新增总请求、总消耗($)、7天消耗列
- 点击用户行展开用量详情(趋势+最近日志)
- 添加"用量统计"标签页切换
2026-05-19 02:47:19 +08:00
boos 1cef28169c feat: 管理员创建用户自动生成密钥 & 用量统计 API
- create_user() 自动生成 sk-user-* 密钥并关联用户
  (使用事务确保用户+密钥原子创建)
- 新增 GET /api/admin/users/{id}/usage 返回用量统计
  (总请求/成本、7天成本、30天趋势、最近50条日志)
- api.ts 新增 getUserUsage(id) 方法
- mod.rs 导出 stats 模块供 user 路由复用
2026-05-19 02:47:15 +08:00
boos a0d3743564 fix: 修复后端 API 路径不匹配
Gemini 创建的路由为 /v1/user/logs 和 /v1/user/summary,
但前端 api.ts 调用的是 /api/user/logs 和 /api/user/summary,
改为 /api/ 前缀修复 404。
2026-05-19 02:36:57 +08:00
boos c9e98df3f1 chore: 优化 ComfyUI Docker 配置
- .dockerignore: 精简忽略规则
- config.toml: 添加 [comfyui] 配置段
- Dockerfile: 本地源码构建、清华镜像加速
2026-05-19 02:33:23 +08:00
boos ba07517974 fix: 密钥管理排除站点列表缺少无账号站点
loadExclusionSources() 原只从 accounts 提取站点,
新增的/无关联账号的站点不显示。
增加 api.getSites() 调用,合并所有站点到排除选项。
2026-05-19 02:33:18 +08:00
boos c8a523d973 fix: 站点管理预设选项添加 label 标签
SiteInitializationPreset 类型缺少 label 字段,
导致 coding plan、deepseek 等预设在下拉菜单显示空白。
给接口添加 label: string,全部 13 个预设补上中文标签。
2026-05-19 02:33:13 +08:00
boos 5890a9b4ac feat: 添加用户端后端 API 路由
- 新增 GET /api/user/logs(用户 API 调用历史)
- 新增 GET /api/user/summary(用户使用统计摘要)
- stats.rs: ProxyLogRow 改为 pub 供 user 模块复用
2026-05-19 02:33:08 +08:00
boos bf00296a90 feat: 新增普通用户界面
- 重构 App.tsx 路由,分离普通用户/管理员导航
  (userSidebarGroups / userAuthed 状态)
- UserDashboard 重写为概览页:用量统计、最近记录、快速入口
- 新增 UsageRecords 使用记录页(分页)
- 新增 UserKeys 密钥管理页(创建/删除 sk-user- 密钥)
- 新增 UserProfile 个人资料页(修改用户名/密码)
- UserLogin 增加 onLogin 回调
- api.ts 增加 userApi 工具方法
2026-05-19 02:32:58 +08:00
434 changed files with 143025 additions and 15986 deletions
+24
View File
@@ -0,0 +1,24 @@
# Cargo build acceleration.
#
# The biggest single speedup is the `mold` linker (~3-5x faster link
# time on debug builds). `build-and-run.sh` enables mold/lld automatically
# when available, so the block below is kept as an optional manual setup for
# direct `cargo build` / `cargo run` usage:
#
# sudo apt-get install -y mold
#
# (or build it from source: https://github.com/rui314/mold)
#
# Without mold/lld, the [profile.dev] tweaks in Cargo.toml still give a
# meaningful speedup (line-tables-only debuginfo + bumped codegen-units).
# Optional: uncomment once mold is on PATH if you want direct cargo commands
# to use it without going through build-and-run.sh.
# [target.x86_64-unknown-linux-gnu]
# linker = "clang"
# rustflags = ["-C", "link-arg=-fuse-ld=mold"]
[build]
# Default to all CPUs for parallel codegen. Override with --jobs N if
# you need to leave headroom for other work.
# jobs = 8
+20 -9
View File
@@ -1,9 +1,20 @@
node_modules
dist
data
.git
.env
*.md
docs
.github
*.log
comfy_source/venv
comfy_source/.git
comfy_source/__pycache__
**/__pycache__
target/
node_modules/
dist/
release/
data/
logs/
tmp/
.agents/
.cache/
.codex/
.claude/
.comfyui/
boosapi
bossai
boosapi.db
docker/*.tar
+14 -1
View File
@@ -1,13 +1,15 @@
/target
**/*.rs.bk
Cargo.lock
.env
.env.*
data/
node_modules/
website/.next/
website/out/
dist/
release/
*.log
*.tsbuildinfo
logs/
tmp/
!tmp/.gitkeep
@@ -17,4 +19,15 @@ docker/data/
docker/.env
.ace-tool/
.comfyui/
.claude/
.codex/
.agents/
.cache/
openspec/
boosapi
bossai
docker/*.tar
boosapi.db-shm
boosapi.db-wal
.tours/
bun.lock
+4
View File
@@ -0,0 +1,4 @@
[submodule "vendor/ComfyUI_frontend"]
path = vendor/ComfyUI_frontend
url = https://github.com/Comfy-Org/ComfyUI_frontend.git
branch = main
+14 -2
View File
@@ -31,9 +31,15 @@ boosapi-rs/ ← 根目录(已迁移至此)
## ComfyUI Agent
ComfyUI Agent 后端直连上游 API(`api.boos.lat/v1`),不走本地代理路由。
ComfyUI Agent 默认走 **本地模式**:复用本服务自身的 `/v1/*` 路由,bearer 用启动时引导出来的内置 downstream key(`name='comfyui-agent' AND user_id IS NULL`,密钥格式 `sk-comfyui-{uuid_v4_hex}`,同时镜像写入 `settings.builtin_key.comfyui_agent.secret`)。
支持的结构化标记:
行为契约:
- **Local(默认)**:`upstream_base` = `http://{server.host}/v1`(host 是 `0.0.0.0` / `::` 时改写为 `127.0.0.1`),bearer = 内置 key;`/api/comfyui-agent/status` 回 `mode: "local"` + `keyName: "comfyui-agent"`
- **FallbackRemote**:当 `BOOSAPI_UPSTREAM_BASE`(trim 后非空)被设置时切换到外部上游;`BOOSAPI_UPSTREAM_KEY` 缺省时仍用内置 key;status 回 `mode: "fallback_remote"` + `keyName: "external"`
- 内置 key 受保护:admin 列表对其 key 字段打码(`••••<后4位>`),删除返 403,禁用(`enabled=false`)返 403;`builtin_key.*` settings 不在 list 中暴露,PUT 返 403
- 启动期 `ensure_comfyui_agent_key` 是幂等的:缺密钥就生成、`enabled=0` 自动恢复并 `tracing::warn!`、settings 与 downstream_api_keys 双向同步
支持的结构化标记(输出由 LLM 生成、由 agent SSE 解析下发):
- 【CHARACTER】 → 自动调用 gpt-image-2 生成四视图
- 【SCENE】 → 自动生成场景图
- 【TTS】 → 自动调用 TTS API
@@ -58,3 +64,9 @@ BOOSAPI_AUTH__AUTH_TOKEN=change-me-admin-token
前端为 React SPA,使用 Vite 构建,产物在 `dist/web/`:
- 开发时用 Vite dev server(需 Node.js)
- 生产时由 Rust 后端直接 serve 静态文件
### 登录与会话
- 登录页位于 `src/web/pages/auth/`:`LoginShell` 提供共享外壳,`AdminLoginPage`(token / 账密双模式)与 `UserLoginPage`(账密)分别承担两类受众。OAuth 入口用 `OAuthSlot` 渲染占位区域,**实际行为由后续 change 接入**——当前不要在这里挂任何 onClick / 跳转。
- 会话状态统一走 `src/web/authSession.ts` 的 `commitSession / clearSession / getSessionToken`,受众通过 `'admin' | 'user'` 区分;旧的 `persistAuthSession / clearAuthSession / getAuthToken / hasValidAuthSession`(及 `*UserSession` 同族)保留为 `@deprecated` 薄壳,新代码不要再引用。
- 登录错误文案统一走 `src/web/loginError.ts` 的 `resolveLoginErrorMessage(status, raw)`;新增登录错误请加在该处而不是各自分支里手写。
Generated
+4177
View File
File diff suppressed because it is too large Load Diff
+35 -2
View File
@@ -6,24 +6,27 @@ description = "AI API Gateway — Rust rewrite of BoosAPI"
[dependencies]
# Web framework
axum = { version = "0.8", features = ["macros"] }
axum = { version = "0.8", features = ["macros", "ws", "multipart"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace", "fs", "compression-gzip"] }
# Async runtime
async-stream = "0.3"
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["io"] }
# Database
sqlx = { version = "0.8", features = [
"runtime-tokio",
"sqlite",
"postgres",
"any",
"migrate",
"chrono",
] }
# HTTP client
reqwest = { version = "0.12", features = ["stream", "json"] }
reqwest = { version = "0.12", features = ["stream", "json", "multipart"] }
lettre = { version = "0.11", default-features = false, features = ["builder", "hostname", "pool", "rustls-tls", "smtp-transport"] }
# Serialization
@@ -58,6 +61,7 @@ uuid = { version = "1", features = ["v4", "serde"] }
# Streaming
futures = "0.3"
tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] }
tokio-stream = "0.1"
bytes = "1"
@@ -65,6 +69,12 @@ bytes = "1"
once_cell = "1"
parking_lot = "0.12"
mime = "0.3"
base64 = "0.22"
hmac = "0.12"
sha2 = "0.10"
hex = "0.4"
include_dir = "0.7"
mime_guess = "2"
[dev-dependencies]
rstest = "0.24"
@@ -77,3 +87,26 @@ axum-test = "16"
opt-level = 3
lto = true
codegen-units = 1
# Faster production-like build for staging/manual verification. This keeps
# optimizations on but avoids the slow LTO + single-codegen-unit release path.
[profile.fast-release]
inherits = "release"
opt-level = 2
lto = false
codegen-units = 256
incremental = true
# Dev-profile speedups: line-tables-only debuginfo (much smaller .debug
# sections → much faster link) and 256 codegen units (more parallelism
# during codegen, slightly worse runtime — fine for dev). Dependencies
# build with no debuginfo at all since we rarely step into them.
[profile.dev]
opt-level = 0
debug = "line-tables-only"
codegen-units = 256
incremental = true
[profile.dev.package."*"]
opt-level = 0
debug = false
+263
View File
@@ -0,0 +1,263 @@
# BoosAPI 部署与环境指南
本文档介绍如何配置和运行 BoosAPI 的开发环境与生产环境。
## 1. 快速参数参考 (CLI Args)
后端二进制文件支持以下命令行参数,优先级高于 `config.toml` 和环境变量:
- `-c, --config <PATH>`: 指定配置文件路径 (默认: `config.toml`)
- `-p, --port <PORT>`: 覆盖监听端口
- `--host <HOST>`: 覆盖监听地址 (如 `0.0.0.0`)
- `--log-level <LEVEL>`: 设置日志级别 (`trace`, `debug`, `info`, `warn`, `error`)
- `--migrate-only`: 仅运行数据库迁移后退出
- `--check-config`: 加载并验证配置后退出
- `-h, --help, -help`: 显示帮助信息
- `-V, --version, -version`: 显示版本信息
示例:
```bash
./boosapi --config prod.toml --port 8080 --log-level warn
# 仅执行迁移
./boosapi --migrate-only
```
---
## 2. 开发环境 (Development)
### 前提条件
- **Rust**: 1.85+ (推荐使用 rustup)
- **Node.js**: 20+ (仅用于前端构建)
- **SQLite**: 系统自带或手动安装
### 步骤
1. **克隆项目**:
```bash
git clone https://github.com/boos/boosapi.git
cd boosapi
```
2. **配置环境**:
复制 `config.toml.example` 并根据需要修改:
```bash
cp config.toml.example config.toml
```
或者直接设置环境变量。
3. **运行前端 (热更新模式)**:
```bash
npm install
# 启动 vite 开发服务器
npm run dev:web
```
默认访问 `http://localhost:5173`。Vite 会将 `/api` 请求代理到后端 `localhost:4000`。
4. **运行后端**:
```bash
# 使用默认配置运行
cargo run
# 推荐:使用 cargo-watch 自动重载
# cargo install cargo-watch
# cargo watch -x run
```
---
## 3. 生产环境 (Production)
### 单二进制生产包
推荐用项目脚本生成生产包:
```bash
./build-and-run.sh --production
```
生产模式会构建主前端和 Workflow UI,并把静态资产嵌入 Rust 二进制。部署目录通常只需要:
```txt
boosapi
config.toml
```
部署目录只需要运行产物和配置。仓库里的 `.agents/`、`.cache/`、本地二进制、Docker tar 包、运行日志等均为本地缓存或构建产物,不应进入发布包。`Cargo.lock` 应保留在仓库中,用于保证生产构建依赖可复现。
启动前检查配置:
```bash
./boosapi --check-config
./boosapi --migrate-only
./boosapi --config config.toml
```
### 推荐:Docker Compose 部署
1. **准备 docker-compose.yml**:
```yaml
services:
boosapi:
image: ghcr.io/boos/boosapi:latest
container_name: boosapi
ports:
- "4000:4000"
volumes:
- ./data:/app/data
- ./config.toml:/app/config.toml
environment:
- BOOSAPI_AUTH__AUTH_TOKEN=your-secure-admin-token
- BOOSAPI_AUTH__JWT_SECRET=your-secure-jwt-secret
- BOOSAPI_DB__URL=sqlite:/app/data/boosapi.db?mode=rwc
restart: unless-stopped
```
2. **启动**:
```bash
docker compose up -d
```
### 传统 Linux 部署 (Systemd + Nginx)
1. **构建前端与后端**:
```bash
# 构建前端
npm run build
# 构建后端
cargo build --release
```
2. **安装**:
将生成的二进制文件 `target/release/boosapi` 和静态资源目录 `dist/web` 拷贝到部署目录 (如 `/opt/boosapi`)。
3. **初始化数据库**:
```bash
/opt/boosapi/boosapi --migrate-only
```
4. **Systemd 配置** (`/etc/systemd/system/boosapi.service`):
```ini
[Unit]
Description=BoosAPI Service
After=network.target
[Service]
Type=simple
User=www-data
WorkingDirectory=/opt/boosapi
ExecStart=/opt/boosapi/boosapi --config /opt/boosapi/config.toml
Restart=always
Environment=BOOSAPI_SERVER__LOG_LEVEL=info
[Install]
WantedBy=multi-user.target
```
5. **Nginx 反向代理配置**:
```nginx
server {
listen 80;
server_name api.yourdomain.com;
location / {
proxy_pass http://127.0.0.1:4000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 支持 WebSockets 与 SSE
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
proxy_cache off;
}
}
```
---
## 4. 关键配置项
| 配置项 (config.toml) | 环境变量 (ENV) | 说明 |
|-------------------|--------------|------|
| `server.host` | `BOOSAPI_SERVER__HOST` | 后端监听地址 (默认 127.0.0.1) |
| `server.port` | `BOOSAPI_SERVER__PORT` | 后端监听端口 (默认 4000) |
| `auth.auth_token` | `BOOSAPI_AUTH__AUTH_TOKEN` | 管理员登录 Token (静态,必填) |
| `auth.jwt_secret` | `BOOSAPI_AUTH__JWT_SECRET` | JWT 签名密钥 (普通用户,必填) |
| `db.url` | `BOOSAPI_DB__URL` | SQLite 数据库路径 (如 `sqlite:data/boosapi.db`) |
| `comfyui.backend` | `BOOSAPI_COMFYUI__BACKEND` | Workflow 后端:`cloud` / `runninghub` / `external` / `boosapi` / `docker` |
| `comfyui.base_url` | `BOOSAPI_COMFYUI__BASE_URL` | Comfy Cloud、RunningHub 或外部 ComfyUI API 地址 |
| `comfyui.api_key` | `BOOSAPI_COMFYUI__API_KEY` | Workflow 后端 API Key,按后端需要填写 |
| `web_dir` | `BOOSAPI_WEB_DIR` | 前端静态资源目录 (默认 `dist/web`) |
### Workflow 后端配置
BoosAPI 的 `/workflows` 页面使用内嵌 ComfyUI 前端,后端由 `[comfyui]` 选择。
Comfy Cloud:
```toml
[comfyui]
backend = "cloud"
base_url = "https://cloud.comfy.org"
api_key = "your-comfy-cloud-api-key"
```
RunningHub:
```toml
[comfyui]
backend = "runninghub"
base_url = "https://www.runninghub.cn/your-comfy-compatible-endpoint"
api_key = "your-runninghub-api-key"
```
外部完整版 ComfyUI:
```toml
[comfyui]
backend = "external"
base_url = "http://127.0.0.1:8188"
```
BoosAPI API-native 执行器:
```toml
[comfyui]
backend = "boosapi"
```
说明:
- `cloud`、`runninghub`、`external` 和 `boosapi` 模式不要求 BoosAPI 机器有 GPU。
- `docker` / 本地 ComfyUI 模式需要本机有完整模型目录和匹配的 NVIDIA GPU。
- 生产构建应包含 `dist/workflow-ui`、`dist/workflow-ui-cloud`、`dist/workflow-ui-external` 中可用的 Workflow UI 资产;脚本会尽量自动安装与构建。
---
## 5. 运维操作 (Ops)
### 检查配置
在启动前验证配置文件是否正确:
```bash
./boosapi --check-config
```
### 数据库备份
BoosAPI 使用 SQLite,只需备份数据库文件即可:
```bash
cp data/boosapi.db data/boosapi.db.bak
```
如使用 PostgreSQL,请按数据库自身的 `pg_dump` / `pg_restore` 流程备份恢复。
### 查看日志
如果使用 Systemd:
```bash
journalctl -u boosapi -f
```
如果使用 Docker:
```bash
docker logs -f boosapi
```
-253
View File
@@ -1,253 +0,0 @@
# 模型白名单功能 - PR 准备文档
## 功能概述
实现全局模型白名单功能,允许管理员配置只启用特定模型的路由。当白名单配置后,不在白名单中的模型路由会被自动移除,实现精细化的模型访问控制。
## 业务场景
- **成本控制**: 只允许使用价格合理的模型
- **合规要求**: 限制只使用审批通过的模型
- **简化管理**: 隐藏不需要的模型,减少用户困惑
- **测试验证**: 在特定场景下只开放测试模型
## 技术实现
### 1. 配置层 (src/server/config.ts)
```typescript
globalAllowedModels: [] as string[]
```
- 默认为空数组(向后兼容)
- 存储在内存中,启动时从数据库加载
### 2. 数据持久化 (src/server/routes/api/settings.ts)
**数据库存储:**
- Key: `global_allowed_models`
- Value: JSON 字符串数组
**API 接口:**
#### GET /api/settings/runtime
返回当前白名单配置
#### PUT /api/settings/runtime
```json
{
"globalAllowedModels": ["gpt-4", "gpt-4o", "claude-3-sonnet"]
}
```
**处理流程:**
1. 验证输入(必须是字符串数组)
2. 去重、trim、过滤空值
3. 比较新旧配置
4. 更新数据库和内存配置
5. 如果配置变更,自动触发路由重建
### 3. 路由重建 (src/server/services/modelService.ts)
**核心函数:** `rebuildTokenRoutesFromAvailability()`
```typescript
// 加载白名单
const globalAllowedModels = new Set(
config.globalAllowedModels.map(m => m.toLowerCase().trim()).filter(Boolean)
);
// 判断模型是否允许
function isModelAllowedByWhitelist(modelName: string): boolean {
if (globalAllowedModels.size === 0) return true; // 向后兼容
return globalAllowedModels.has(modelName.toLowerCase().trim());
}
// 添加模型候选时过滤
const addModelCandidate = (modelNameRaw, accountId, tokenId, siteId) => {
const modelName = (modelNameRaw || '').trim();
if (!modelName) return;
if (!isModelAllowedByWhitelist(modelName)) return; // 白名单过滤
// ... 其他过滤逻辑
};
```
**处理逻辑:**
1. 从 `token_model_availability` 加载所有可用模型
2. 通过 `addModelCandidate()` 过滤,只保留白名单中的模型
3. 创建/更新路由和渠道
4. 删除不在白名单中的旧路由
### 4. 模型候选API (src/server/routes/api/stats.ts)
**接口:** GET /api/models/token-candidates
**返回字段:**
- `models`: 有token的模型
- `modelsWithoutToken`: 无token的模型
- `modelsMissingTokenGroups`: 缺少token分组的模型
**过滤逻辑:**
```typescript
if (globalAllowedModels.size > 0) {
// 白名单模式:只返回白名单中的模型
for (const [modelName, candidates] of Object.entries(result)) {
if (globalAllowedModels.has(modelName.toLowerCase().trim())) {
filteredResult[modelName] = candidates;
}
}
} else {
// 向后兼容:返回所有模型
Object.assign(filteredResult, result);
}
```
### 5. 前端UI (src/web/pages/Settings.tsx)
**新增卡片:** "全局模型白名单"
**功能特性:**
- 输入框手动添加模型名称(支持回车)
- 可用模型列表展示(点击快速添加)
- 已选模型徽章显示(绿色徽章)
- 点击 × 删除模型
- "保存模型白名单"按钮
- 保存成功后显示提示并自动刷新页面
**交互流程:**
1. 加载当前白名单配置
2. 加载可用模型列表
3. 用户添加/删除模型
4. 点击保存触发API调用
5. 后端自动重建路由
6. 前端显示成功提示
## 数据流程图
```
用户输入 → Settings API
↓
数据库存储 (global_allowed_models)
↓
内存配置更新 (config.globalAllowedModels)
↓
触发路由重建 (异步后台任务)
↓
加载 token_model_availability
↓
白名单过滤 (isModelAllowedByWhitelist)
↓
创建/更新/删除路由
↓
候选API过滤 (token-candidates)
↓
前端显示过滤后的模型列表
```
## 向后兼容性
| 场景 | 行为 |
|------|------|
| 白名单为空 | 允许所有模型(原有行为) |
| 白名单有值 | 只允许白名单中的模型 |
| 数据库无配置 | 默认为空数组 |
| API未传该字段 | 不修改现有配置 |
## 测试验证
### 功能测试清单
- [x] 白名单为空时,所有模型可见
- [x] 设置白名单后,只显示白名单模型
- [x] 大小写不敏感匹配(GPT-4 = gpt-4)
- [x] 自动trim空格
- [x] 保存后自动触发路由重建
- [x] 路由重建成功后,路由正确过滤
- [x] 候选API正确过滤三个返回字段
- [ ] 前端UI交互正常
- [ ] 输入框支持回车添加
- [ ] 可用模型列表正确显示
- [ ] 点击模型快速添加
- [ ] 删除模型功能正常
- [ ] 保存成功提示显示
### API 测试
```bash
# 查看白名单
curl -H "Authorization: Bearer test-admin-token" \
http://localhost:4000/api/settings/runtime
# 设置白名单
curl -X PUT \
-H "Authorization: Bearer test-admin-token" \
-H "Content-Type: application/json" \
-d '{"globalAllowedModels": ["gpt-4", "gpt-4o"]}' \
http://localhost:4000/api/settings/runtime
# 查看模型候选
curl -H "Authorization: Bearer test-admin-token" \
http://localhost:4000/api/models/token-candidates
```
## 文件修改列表
| 文件 | 修改内容 | 行数 |
|------|---------|-----|
| src/server/config.ts | 新增 globalAllowedModels 配置 | 1 |
| src/server/routes/api/settings.ts | 类型定义、加载、保存、触发重建 | ~80 |
| src/server/services/modelService.ts | 白名单过滤逻辑 | ~20 |
| src/server/routes/api/stats.ts | 候选API过滤 | ~30 |
| src/web/pages/Settings.tsx | 前端UI组件 | ~150 |
**总计:** 约 280 行代码新增/修改
## 性能影响
- **内存:** 新增一个 Set 数据结构,大小为白名单模型数量
- **路由重建:** 无额外开销(仅增加一个 O(1) 的 Set 查找)
- **候选API:** 增加过滤循环,复杂度 O(n),n为模型数量
**结论:** 性能影响可忽略不计
## 安全考虑
- ✅ 仅管理员可配置(需要 admin token)
- ✅ 输入验证(类型检查、trim、去重)
- ✅ 无SQL注入风险(使用 ORM)
- ✅ 配置变更自动记录日志
## 后续优化建议
1. **批量导入**: 支持从文件批量导入白名单
2. **预设模板**: 提供常用模型组合模板
3. **分组管理**: 支持多个白名单分组
4. **权限控制**: 不同用户看到不同白名单
5. **审计日志**: 记录白名单变更历史
## 已知限制
1. 白名单只支持精确匹配,不支持通配符或正则
2. 模型名称大小写不敏感,但保留原始大小写显示
3. 白名单全局生效,不支持按站点或账号单独配置
## 相关 Issue
- Issue #XXX: 模型访问控制需求
- Issue #XXX: 简化模型列表显示
## 测试环境
- Node.js: v20.x
- pnpm: 8.x
- SQLite: 3.x
- 测试服务器: http://localhost:4000
- 真实模型数据已验证
## 下一步计划
1. ✅ 完成功能开发和测试
2. ✅ 编写PR文档
3. ⏳ 等待用户前端测试验证
4. ⏳ 合并到主仓库
5. ⏳ 开始第二个功能:新站点跳转选择
+238 -26
View File
@@ -2,14 +2,14 @@
# BoosAPI
**AI 聚合网关 + ComfyUI Agent — 将分散的中转站整合为一个统一入口**
**AI 聚合网关 + Workflow Studio — 将分散的中转站整合为一个统一入口**
<p>
把你各处注册的 New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API 等站点,
<br>
汇聚成 <strong>一个 API Key、一个入口</strong>,自动发现模型、智能路由、成本最优。
<br>
<strong>内置 ComfyUI 视频助手</strong> — 自然语言描述脚本,自动生成角色、场景、TTS、ComfyUI 工作流。
<strong>Workflow Studio / 视频助手</strong> — 用内嵌 ComfyUI 前端、多 API 后端和自然语言助手组合图像、视频、语音与文本能力。
<br>
<strong>SaaS 多租户架构</strong>,组织管理、成员邀请、套餐限制,开箱即用。
</p>
@@ -24,7 +24,8 @@
<p align="center">
<a href="README.md"><strong>中文</strong></a> |
<a href="README_EN.md">English</a>
<a href="README_EN.md">English</a> |
<a href="DEPLOY.md">部署指南</a>
</p>
</div>
@@ -52,24 +53,37 @@
| 📊 余额分散各处 | **集中看板** 一目了然,余额不足自动告警 |
| ✅ 每天签到领额度 | **自动签到** 定时执行,奖励自动追踪 |
| 🤷 不知道各站有什么模型 | **自动模型发现**,上游新增模型零配置 |
| 🎨 想用 ComfyUI 但不想配工作流 | **对话助手**,自然语言描述即可生成图像/视频 |
| 🎨 想编排图像/视频流程但不想跑本地模型 | **Workflow Studio + 对话助手**,直接复用 BoosAPI 模型路由 |
| 👥 多人协作管理中转站 | **组织管理**,成员邀请,角色权限控制 |
| 📋 不同团队需要不同配额 | **套餐系统**,Free/Pro/Enterprise 三级限制 |
---
## 2026-06 更新重点
- **后台登录收口**:管理员后台只允许使用 `BOOSAPI_AUTH__AUTH_TOKEN` 这类 API Token 登录;普通用户仍使用账号密码登录用户工作台。
- **日志边界清晰**:管理员「使用日志」保留在 `/logs`,系统/程序日志保留在 `/events`;`/api/events` 现在只接受管理员 API Token,普通用户不会再看到运行日志。
- **通知隔离**:普通用户顶部通知只读取用户公告;管理员通知面板继续聚合签到、余额、令牌、代理、状态与公告事件。
- **后台信息架构**:侧栏按接入管理、创作应用、系统管理、我的账户精简;外部监控入口已移除,系统日志入口保留在系统管理分组。
- **运维与 IP 管理**:新增运维监控视图与 IP 管理页面,支持代理质量检测、连接测试、批量操作与更清晰的状态展示。
- **用户自助工作台**:API Key、计费、账户充值、使用记录、工作空间和模型测试统一到用户侧;工作空间支持分页、一键选择和默认预览。
- **Comfy Checkout 充值**:用户先在系统内创建充值记录,再跳转到配置的 Comfy Checkout 链接付款;充值记录会保留用于后续对账。
- **Workflow UI 更新**:`npm run workflow-ui:install:cloud` 会安装当前 Comfy Cloud 前端资产并写入构建元数据,Rust 服务会对 JS/CSS 走 no-cache,避免继续显示旧版本。
- **本地化时间**:前端展示时间优先使用浏览器/UA 时区,后端无时区时间按 UTC 解析后再转成本地显示。
## ✨ 核心功能
### 🎨 内置 ComfyUI 视频助手(Rust)
### 🎨 Workflow Studio 与视频助手(Rust)
- **自然语言描述脚本**:说想法 → AI 自动提取角色、场景、对白
- **自动生成四视图**:每个角色正/背/左/右 4 角度,调用 gpt-image-2 生成图片
- **场景生成**:自动为每个场景生成配图
- **TTS 语音合成**:对话台词自动转语音
- **ComfyUI 工作流导出**:完整 workflow JSON,可直接导入 ComfyUI
- **Workflow Studio**:内嵌 ComfyUI 前端,可切换 Comfy Cloud、RunningHub、外部 ComfyUI、BoosAPI API-native 执行器或本地 Docker 完整版 ComfyUI
- **远程执行优先**:使用 Comfy Cloud、RunningHub、外部 API 或 BoosAPI 上游路由时,BoosAPI 服务器不需要 GPU
- **外部 ComfyUI 可选代理**:已有独立 ComfyUI 服务时,可通过 `comfyui.base_url` 接入 `/api/comfyui/proxy/*`
- **Rust 后端**(boosapi-rs):高性能 SSE 流式响应,零 Node.js 运行时依赖
- 侧边栏「视频助手」入口,与聚合网关同一界面
- 自动对接上游 API(`api.boos.lat/v1`),无需额外配置
- **默认走本地模式**:复用本服务自身的 `/v1/*` 路由 + 启动时引导出来的内置 downstream key(`comfyui-agent`),开箱即用,无需额外配置
- **回退远端**:设置 `BOOSAPI_UPSTREAM_BASE`(可选 `BOOSAPI_UPSTREAM_KEY`)即切换为外部上游;`/api/comfyui-agent/status` 的 `mode` 字段会反映当前模式
### 🌐 统一代理网关
@@ -152,6 +166,103 @@
---
## 🖥️ 硬件配置建议
BoosAPI 本身是 Rust 网关与管理后台,不在本地跑大模型推理时不需要 GPU。实际硬件主要取决于你是否把图像/视频生成放在本机 ComfyUI 上执行。
| 使用模式 | CPU | 内存 | 磁盘 | GPU |
|----------|-----|------|------|-----|
| 仅网关 / 管理后台 / `/v1/*` 代理 | 2 核起步,4 核推荐 | 2GB 起步,4GB 推荐 | 10GB+ | 不需要 |
| BoosAPI + Workflow UI + 远程模型 API / Comfy Cloud / RunningHub | 4 核推荐 | 4GB 起步,8GB 推荐 | 20-50GB | 不需要 |
| 外部独立 ComfyUI 代理 | 4 核推荐 | 8GB 推荐 | 50GB+ | BoosAPI 机器不需要;ComfyUI 机器按模型需求配置 |
| 本机 Docker / 本地 ComfyUI 轻量文生图 | 4-8 核 | 16GB+ | 100GB+ | NVIDIA 8GB VRAM 勉强,12GB+ 推荐 |
| 本机视频生成 / 数字人 / 大工作流 | 8 核+ | 32GB 起步,64GB 推荐 | 200GB-1TB | NVIDIA 24GB VRAM 起步,48GB+ 更稳 |
推荐的 SaaS 网关部署规格:
```txt
4 核 CPU
8GB 内存
50GB SSD
无需 GPU
```
如果图像、视频、语音能力走 BoosAPI 上游路由、Comfy Cloud、RunningHub 或其他远程 API,本机只负责网关、数据库、Workflow UI 代理、日志和 Workspace 归档,上面这档配置通常足够。只有把 ComfyUI 模型和视频生成放在本机运行时,才需要高显存 NVIDIA GPU。
## 🎛️ Workflow API 后端模式
BoosAPI 的 Workflow 使用内嵌 ComfyUI 前端,后端可按配置切换:
| backend | 用途 |
|---------|------|
| `cloud` | 代理 Comfy Cloud API,适合默认创作、官方订阅模型和稳定执行 |
| `runninghub` | 代理 RunningHub 的 ComfyUI 兼容 API,适合使用 RunningHub 专属插件、工作流和模型 |
| `external` | 代理你自己部署的完整版 ComfyUI |
| `boosapi` | 使用 BoosAPI 自带 Rust Workflow/API 节点执行器 |
| `docker` | 本地完整版 ComfyUI 模式,适合有本地 GPU 和完整模型目录的环境 |
推荐配置是默认 `cloud`,需要 RunningHub 专属插件或模型时切到 `runninghub`。RunningHub 模式会复用 external 兼容前端,并对 `/object_info`、模型库、`/system_stats` 做短期缓存,减少每次打开工作流时的卡顿。
配置示例:
```toml
[comfyui]
backend = "cloud"
base_url = "https://cloud.comfy.org"
api_key = "your-comfy-cloud-api-key"
```
```toml
[comfyui]
backend = "runninghub"
base_url = "https://www.runninghub.cn/your-comfy-compatible-endpoint"
api_key = "your-runninghub-api-key"
```
```toml
[comfyui]
backend = "external"
base_url = "http://127.0.0.1:8188"
```
```toml
[comfyui]
backend = "boosapi"
```
### 将 Comfy 视频节点包装为 BoosAPI `/v1/videos`
ComfyUI/Comfy Cloud 没有 OpenAI 兼容 `/v1/videos`,但 BoosAPI 可以把一个已保存的视频工作流模板包装成统一视频模型:
1. 在工作流页面保存一个真实 Comfy 视频模板,模板需要能被 `POST /prompt` 执行。
2. 模板图里最好保存节点映射:
- `prompt`:提示词节点和字段
- `negativePrompt`:反向提示词节点和字段,可选
- `ratio` / `resolution` / `duration`:画幅、分辨率和时长节点字段,可选
3. 在模型路由里新增一个模型,例如 `comfy-wan-video`。
4. 让该路由的上游地址或实际模型映射指向模板:
- 上游地址:`comfy://<workflow_public_id>`
- 或模型映射:`comfy:<workflow_public_id>`
之后用户可以直接调用:
```bash
curl http://127.0.0.1:4000/v1/videos \
-H "Authorization: Bearer <BoosAPI downstream key>" \
-H "Content-Type: application/json" \
-d '{
"model": "comfy-wan-video",
"prompt": "cinematic product video, smooth camera movement",
"duration": 5,
"ratio": "9:16",
"resolution": "1080p"
}'
```
返回的 `id` 是 Comfy `prompt_id`,可用 `GET /v1/videos/{id}?model=comfy-wan-video` 查询 Comfy history。
> Workflow UI 有 cloud 与 external/runninghub 两套前端资产。开发环境可用 `npm run workflow-ui:install:cloud`、`npm run workflow-ui:install:external` 单独安装;安装脚本会写入 `boosapi-workflow-ui.json` 构建元数据,生产构建会把可用资产嵌入 Rust 二进制。若浏览器「关于」仍显示旧前端版本,请重启 Rust 服务并强刷浏览器缓存。
## 🚀 快速开始
### Docker Compose(推荐)
@@ -176,14 +287,14 @@ services:
restart: unless-stopped
EOF
# AUTH_TOKEN = 管理后台登录令牌
# AUTH_TOKEN = 管理后台登录令牌(管理员只支持 API Token 登录)
# PROXY_TOKEN = 下游客户端调用 /v1/* 的 Token
export AUTH_TOKEN=your-admin-token
export PROXY_TOKEN=sk-your-proxy-token
docker compose up -d
```
启动后访问 `http://localhost:4000`,用 `AUTH_TOKEN` 登录即可。
启动后访问 `http://localhost:4000/boos`,用 `AUTH_TOKEN` 登录管理员后台;普通用户从用户登录页使用账号密码进入用户工作台。
> [!IMPORTANT]
> 请务必修改 `AUTH_TOKEN` 和 `PROXY_TOKEN`,不要使用默认值。
@@ -207,8 +318,8 @@ docker compose up -d
│ ├── db/ # 数据库模型与查询
│ ├── routes/
│ │ ├── mod.rs # 路由组装 + 静态文件 serve
│ │ ├── comfyui_agent.rs # ComfyUI 视频助手 SSE 聊天
│ │ ├── comfyui/ # ComfyUI 代理(prompt/history/view)
│ │ ├── comfyui_agent.rs # 视频/工作流助手 SSE 聊天
│ │ ├── comfyui/ # 外部 ComfyUI 代理(prompt/history/view)
│ │ ├── proxy/ # OpenAI 兼容代理入口
│ │ ├── auth.rs # 用户注册/登录 JWT
│ │ ├── checkin.rs # 自动签到
@@ -226,11 +337,16 @@ docker compose up -d
│ ├── billing/ # 计费逻辑
│ └── notification/ # 通知推送
├── migrations/ # SQLite 迁移
├── src/web/ # 前端源码(React 19 + Vite)
├── src/web/ # 管理台前端源码(React + Vite)
│ ├── authSession.ts # 统一会话工具:commitSession/clearSession/getSessionToken(按 'admin' | 'user' 分桶)
│ ├── loginError.ts # 登录错误归一化(resolveLoginErrorMessage)
│ ├── pages/
│ │ ├── ComfyUIAgent.tsx # 视频助手聊天界面
│ │ ├── auth/ # 登录页拆分:LoginShell + AdminLoginPage(token-only) + UserLoginPage(OAuthSlot 占位)
│ │ ├── ComfyUIAgent.tsx # 视频/工作流助手聊天界面
│ │ ├── Workflows.tsx # Workflow Studio 入口
│ │ └── ...
│ └── components/
├── website/ # 独立官网(Next.js 16 App Router + React 19)
└── dist/web/ # 前端构建产物(由 Rust serve)
```
@@ -249,15 +365,30 @@ BOOSAPI_AUTH__AUTH_TOKEN=your-admin-token
BOOSAPI_AUTH__PROXY_TOKEN=sk-your-proxy-token
BOOSAPI_AUTH__JWT_SECRET=your-jwt-secret
# 数据库
# 数据库(默认 SQLite)
BOOSAPI_DB__URL=sqlite:data/boosapi.db?mode=rwc
# 上游 API(ComfyUI Agent 使用)
# PostgreSQL 准备/迁移
# 当前版本保留 SQLite 作为运行主路径;可先把 SQLite 核心数据迁移到 PostgreSQL。
# 完整 PostgreSQL 业务查询适配需要后续切换运行时 SQL 方言。
# 示例:
# boosapi migrate-sqlite-to-postgres \
# --sqlite-url 'sqlite:data/boosapi.db?mode=rwc' \
# --postgres-url 'postgres://boosapi:password@127.0.0.1:5432/boosapi'
# 目标库已有数据时默认拒绝;确认覆盖时追加 --force-truncate。
# 上游 API(视频/工作流助手回退用,默认本地模式时不需要设置)
# 设置 BOOSAPI_UPSTREAM_BASE 后切换到 fallback-remote 模式;BOOSAPI_UPSTREAM_KEY 缺省时仍用内置 comfyui-agent key
BOOSAPI_UPSTREAM_BASE=https://api.boos.lat/v1
BOOSAPI_UPSTREAM_KEY=sk-your-key
# 前端静态目录(默认 dist/web)
BOOSAPI_WEB_DIR=dist/web
# Workflow 后端
BOOSAPI_COMFYUI__BACKEND=cloud
BOOSAPI_COMFYUI__BASE_URL=https://cloud.comfy.org
BOOSAPI_COMFYUI__API_KEY=your-workflow-backend-key
```
## 🏗️ 技术栈
@@ -266,13 +397,15 @@ BOOSAPI_WEB_DIR=dist/web
|----|------|
| 后端框架 | **Axum 0.8**(Rust) |
| 运行时 | **Tokio**(异步运行时) |
| 数据库 | **SQLite** + **SQLx**(编译期 SQL 检查) |
| 数据库 | **SQLite** 运行主路径 + **PostgreSQL** schema/迁移准备(SQLx) |
| 并行计算 | **Rayon**(CPU 密集任务) |
| 认证鉴权 | **JWT** + API Key 双模式 |
| 代理引擎 | 自定义 Token Router + Proxy Conductor |
| 协议转换 | OpenAI / Claude / Gemini 全兼容 |
| ComfyUI Agent | SSE 流式 + gpt-image-2 + TTS 自动生成 |
| 前端框架 | React 19 + Vite(仅构建工具) |
| Workflow Studio | 内嵌 ComfyUI 前端 + Cloud / RunningHub / External / BoosAPI 多后端 |
| 视频助手 | SSE 流式 + gpt-image-2 + TTS 自动生成 |
| 管理台前端 | React + Vite(仅构建工具) |
| 独立官网 | Next.js 16 App Router + React 19 + TypeScript |
| 样式 | Tailwind CSS v4 |
| 数据可视化 | VChart |
| 容器化 | Docker + Docker Compose + K3s |
@@ -280,6 +413,8 @@ BOOSAPI_WEB_DIR=dist/web
> **运行时零 Node.js 依赖**,Node.js 仅用作前端构建工具。生产环境只需 Rust 编译后的单二进制文件。
> 独立官网位于 `website/`。默认构建会把官网静态导出到 `dist/website/`,Rust 后端会让根路径 `/` 显示官网;管理台仍由 `dist/web/` 提供,管理员入口是 `/boos`,登录后进入 `/dashboard`。
---
## 🛠️ 本地开发
@@ -287,11 +422,20 @@ BOOSAPI_WEB_DIR=dist/web
### 后端(Rust)
```bash
# 编译并运行
cargo run --release
# 最快验证:只检查 Rust 后端,不构建前端、不启动服务
./build-and-run.sh --check
# 仅检查编译
cargo check
# 日常启动:复用前端缓存,后端 dev profile 编译后启动
./build-and-run.sh
# 只重启/编译后端,不碰 Vite/Next
./build-and-run.sh --backend-only
# 接近生产性能但比 release 快
./build-and-run.sh --fast-release
# 完整生产包:构建并嵌入前端/Workflow UI,只输出 boosapi + config.toml
./build-and-run.sh --production
# 运行测试
cargo test
@@ -302,9 +446,15 @@ export BOOSAPI_SERVER__PORT=4000
export BOOSAPI_AUTH__AUTH_TOKEN=your-admin-token
export BOOSAPI_AUTH__PROXY_TOKEN=sk-your-proxy-token
export BOOSAPI_UPSTREAM_BASE=https://api.boos.lat/v1
export BOOSAPI_UPSTREAM_KEY=sk-boos4721
export BOOSAPI_UPSTREAM_KEY=sk-your-upstream-key
```
构建加速说明:
- 脚本会在 Linux 下自动使用 `mold` / `lld` 快速链接器;可用 `BOOSAPI_FAST_LINKER=off` 关闭。
- `BOOSAPI_CARGO_JOBS=8 ./build-and-run.sh --check` 可限制 Cargo 并发,避免把开发机打满。
- 生产性能验证优先用 `--fast-release`,真正发布再用 `--production` 或 `--release`。
### 前端(React SPA)
前端使用 Vite 构建,产物由 Rust 后端静态 serve:
@@ -316,14 +466,76 @@ npm install
# 开发模式(热更新,需要 Node.js)
cd src/web && npx vite --host 0.0.0.0
# 构建生产版本
npm run build
# 类型检查与构建生产版本
npm run typecheck:web
npm run build:web
# 输出到 dist/web/,Rust 后端自动 serve
```
Workflow UI 前端资产单独更新:
```bash
# Comfy Cloud 前端
npm run workflow-ui:install:cloud
# External / RunningHub 前端
npm run workflow-ui:install:external
```
可选使用 Bun 加速主前端构建:
```bash
# 一次性使用 Bun 构建主前端
./build-and-run.sh --bun
# 或设置环境变量,auto 会优先使用可用的 bun,缺省回退 npm/npx
BOOSAPI_FRONTEND_PM=bun ./build-and-run.sh --no-run
BOOSAPI_FRONTEND_PM=auto ./build-and-run.sh
```
> Workflow UI 子模块仍按官方 `pnpm-lock.yaml` 使用 pnpm 构建,避免 ComfyUI 前端依赖兼容问题。
> 生产环境不需要 Node.js 进程。`npm run build` 后,Rust 后端直接 serve 静态文件。
> 前端 API 请求通过 Vite 代理(开发)或同域访问(生产)指向 `localhost:4000`。
### 独立官网(Next.js)
官网已经从管理台中隔离,使用 Next.js 16 App Router、React 19、TypeScript、Tailwind CSS 4 和自定义 SVG/CSS motion system:
```bash
# 首次安装
npm --prefix website install
# 本地开发官网
npm run website:dev
# 类型检查与生产构建
npm run website:typecheck
npm run website:build
```
`./build-and-run.sh` 会同时构建管理台 `dist/web/` 和官网 `dist/website/`。打开主域名 `/` 显示官网;后台路径 `/boos`、`/dashboard`、`/user/*`、`/api/*`、`/v1/*` 等仍由 BoosAPI Rust 服务处理。
### 生产单二进制构建
```bash
./build-and-run.sh --production
```
生产模式会构建主前端与 Workflow UI 资产,并将它们嵌入 Rust 二进制。部署目录通常只需要:
```txt
boosapi
config.toml
```
仓库卫生说明:
- `Cargo.lock` 应随代码提交,保证 Rust 应用在 CI、生产构建和本地排障时依赖版本一致。
- `boosapi.db`、`config.toml` 是本地运行态文件;实际部署时不要提交带真实密钥、Comfy Cloud Key 或生产数据的改动。
- `.agents/`、`.cache/`、`boosapi` / `bossai` 本地二进制、`docker/*.tar` 等构建缓存与大文件已加入忽略规则。
- 旧的 `boosapi-comfyui-multi-tenant-*.tar` 不再作为当前 Workflow 路线的必需产物,生产包应优先使用单二进制 + 配置文件。
### Docker 部署
```bash
+140 -151
View File
@@ -2,206 +2,195 @@
# BoosAPI
**AI Aggregation Gateway + Built-in ComfyUI — Unify scattered relay stations into one entry point**
**AI Aggregation Gateway + Workflow Studio — one endpoint for many AI relay stations**
<p>
Aggregate your New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API stations
Aggregate New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API stations
<br>
into <strong>one API Key, one endpoint</strong> — with auto model discovery, smart routing, and cost optimization.
into <strong>one API key and one compatible endpoint</strong>, with model discovery, smart routing, logs, billing, and failover.
<br>
<strong>Built-in ComfyUI chat assistant</strong> — generate images/videos from natural language, ready out of the box.
<br>
<strong>SaaS multi-tenant architecture</strong> — organization management, member invitations, plan limits.
Workflow Studio embeds the ComfyUI frontend and can run against Comfy Cloud, RunningHub, your own ComfyUI, or BoosAPI's API-native executor.
</p>
<p align="center">
<img alt="Node.js" src="https://img.shields.io/badge/Node.js-22.15%2B-339933?logo=node.js&style=flat">
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5.x-3178C6?logo=typescript&style=flat">
<img alt="Rust" src="https://img.shields.io/badge/Rust-1.85%2B-000000?logo=rust&style=flat">
<img alt="Axum" src="https://img.shields.io/badge/Axum-0.8-3182F6?style=flat">
<img alt="SQLx" src="https://img.shields.io/badge/SQLx-0.8-3E6E93?style=flat">
<img alt="React" src="https://img.shields.io/badge/React-19-61DAFB?logo=react&style=flat">
<img alt="License" src="https://img.shields.io/badge/license-MIT-brightgreen?style=flat">
</p>
<p align="center">
<a href="README.md">中文</a> |
<a href="README_EN.md"><strong>English</strong></a>
<a href="README_EN.md"><strong>English</strong></a> |
<a href="DEPLOY.md">Deployment</a>
</p>
</div>
---
## 📖 Introduction
## Introduction
With the growing ecosystem of AI relay stations (New API / One API based), managing balances, models, and API keys across multiple stations is tedious and time-consuming.
BoosAPI is a self-hosted Rust gateway for AI relay stations. It centralizes upstream sites, accounts, downstream API keys, model routes, usage logs, image/video generation, user dashboards, and Workflow Studio in one service.
**BoosAPI** acts as a **meta-aggregation layer** above these stations, unifying them under **a single entry point** — all downstream tools connect seamlessly without configuration changes.
Supported upstreams:
### Supported Upstreams
- **Relay panels**: New API, One API, OneHub, DoneHub, Veloera, AnyRouter, Sub2API
- **Compatible APIs**: OpenAI / Claude / Gemini compatible, cliproxyapi / CPA
- **Preset providers**: Aliyun, Zhipu, Doubao Coding Plan, DeepSeek, Moonshot, MiniMax, ModelScope
- **OAuth connections**: Codex, Claude, Gemini CLI, Antigravity
- **Panels**: New API, One API, OneHub, DoneHub, Veloera, AnyRouter, Sub2API
- **Generic**: OpenAI / Claude / Gemini compatible, cliproxyapi / CPA
- **Official**: Aliyun, Zhipu, Doubao Coding Plan, DeepSeek, Moonshot, MiniMax, ModelScope
- **OAuth**: Codex, Claude, Gemini CLI, Antigravity
## Key Features
| Pain Point | BoosAPI Solution |
|------------|-----------------|
| 🔑 One key per station, messy configs | **Unified proxy entry + multi-key strategy** |
| 💸 Which station is cheapest? | **Smart routing** by cost, balance, usage |
| 🔄 Manual failover | **Auto failover** with cooldown and retry |
| 📊 Scattered balances | **Central dashboard** with alerts |
| ✅ Daily check-ins | **Auto check-in** with reward tracking |
| 🤷 What models are available? | **Auto model discovery**, zero config |
| 🎨 ComfyUI workflows too complex? | **Chat assistant**, describe in natural language |
| 👥 Need multi-user access? | **Organizations**, member invites, role-based access |
| 📋 Different teams need quotas? | **Plan system**, Free/Pro/Enterprise tiers |
### Unified API Gateway
---
- OpenAI-compatible `/v1/chat/completions`, `/v1/responses`, `/v1/images/generations`, `/v1/models`, and files endpoints
- Claude-compatible `/v1/messages`, including request/response and streaming event conversion
- Smart token routing, channel cooldown, failover, model discovery, and usage accounting
- Admin and user dashboards with API key management and model visibility controls
## ✨ Features
### Workflow Studio
### 🎨 Built-in ComfyUI Chat Assistant
Workflow Studio uses an embedded ComfyUI frontend. The execution backend is selected by `comfyui.backend`:
- **Natural language to workflow**: describe what you want, AI builds the ComfyUI pipeline
- **Multi-view generation**: front/back/left/right directional image generation
- **Scene/video generation**: text description directly generates video content
- **SSE real-time streaming**: generation progress feedback
- Sidebar "Chat Assistant" entry, same UI as the aggregation gateway
- Auto-connects to ComfyUI API (`:8188`), zero configuration required
| backend | Purpose |
|---------|---------|
| `cloud` | Proxy Comfy Cloud API for cloud execution and subscription models |
| `runninghub` | Proxy RunningHub's ComfyUI-compatible API for its plugins, models, and workflows |
| `external` | Proxy a full ComfyUI instance you deploy yourself |
| `boosapi` | Use BoosAPI's Rust API-native workflow executor and configured model routes |
| `docker` | Local full ComfyUI mode for GPU machines and local model directories |
### 🌐 Unified Proxy Gateway
Cloud, RunningHub, external API, and BoosAPI executor modes do not require a GPU on the BoosAPI server. A GPU is only needed when you run local ComfyUI inference on the same machine.
- Compatible with **OpenAI** and **Claude** downstream formats
- Responses / Chat Completions / Messages / Completions / Embeddings / Images / Models / Files
- SSE streaming with automatic format conversion
### Assistants
### 🧠 Smart Routing Engine
- Video / workflow assistant with Markdown chat, reasoning sections, attachments, and model route selection
- Picture assistant using `/v1/images/generations`, background tasks, progress display, cancellation, and Workspace archive
- Volcengine video task templates for create-task and query-task flows
- Auto-discovers models from all upstream stations
- Four-tier cost signals: measured → configured → catalog → fallback
- Multi-channel weighted distribution (cost 40%, balance 30%, usage 30%)
- Auto cooldown on failure (default 10 min), seamless channel switching
## Hardware Guide
### 📡 Multi-Platform Management
| Mode | CPU | Memory | Disk | GPU |
|------|-----|--------|------|-----|
| Gateway, admin UI, `/v1/*` proxy only | 2 cores minimum, 4 recommended | 2GB minimum, 4GB recommended | 10GB+ | Not required |
| BoosAPI + Workflow UI + remote APIs / Comfy Cloud / RunningHub | 4 cores recommended | 4GB minimum, 8GB recommended | 20-50GB | Not required |
| External ComfyUI proxy | 4 cores recommended | 8GB recommended | 50GB+ | Not on BoosAPI; required by the ComfyUI machine as needed |
| Local Docker / local ComfyUI image generation | 4-8 cores | 16GB+ | 100GB+ | NVIDIA 8GB VRAM minimum, 12GB+ recommended |
| Local video / digital human / large workflows | 8 cores+ | 32GB minimum, 64GB recommended | 200GB-1TB | NVIDIA 24GB VRAM minimum, 48GB+ recommended |
| Platform | Adapter |
|----------|---------|
| New API | `new-api` |
| One API | `one-api` |
| OneHub | `onehub` |
| DoneHub | `done-hub` |
| Veloera | `veloera` |
| AnyRouter | `anyrouter` |
| Sub2API | `sub2api` |
Recommended SaaS gateway server:
### 👥 Account & Token Management
- Multi-station, multi-account with health tracking (healthy / unhealthy / degraded / disabled)
- Encrypted credential storage, auto-renewal, cascading disable
### 💰 Balance Management
- Scheduled refresh (default hourly), income/expense trends
- Fallback estimation when API is unavailable
### 👥 SaaS Multi-Tenant
- **Organizations**: Create orgs, each with independent members and quotas
- **Member Invitations**: Email-based invites with admin/member roles
- **Three Plans**: Free (3 members) / Pro (10) / Enterprise (100)
- **Role Control**: Owner / Admin / Member with granular permissions
- **Plan Enforcement**: Auto-limit members, API keys, daily requests
- **Invite Links**: Token-based, 48h expiry
### 🔔 Alerts & Notifications
Five channels: Webhook / Bark / ServerChan / Telegram Bot / SMTP
Scenarios: low balance, station failure, check-in failure, proxy errors, token expiry, daily digest
### 🔌 REST API Overview
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/organizations` | GET/POST | List / Create org |
| `/api/organizations/plans` | GET | List available plans |
| `/api/organizations/:id` | GET/PATCH | Org detail / Update |
| `/api/organizations/:id/members` | GET | List members |
| `/api/organizations/:id/members/:uid` | PATCH/DELETE | Change role / Remove |
| `/api/organizations/:id/invitations` | GET/POST | List / Create invite |
| `/api/organizations/:id/invitations/:invId` | DELETE | Cancel invite |
| `/api/invitations/:token` | GET | Public invite lookup |
| `/api/invitations/:token/accept` | POST | Accept invitation |
| `/api/users/register` | POST | Register new user |
| `/api/users/login` | POST | Login (returns JWT) |
| `/api/users/me` | GET/PATCH | Profile / Update |
| `/api/user-api-keys` | GET/POST | List / Create API key |
---
## 🚀 Quick Start
### Docker Compose (Recommended)
```bash
mkdir boosapi && cd boosapi
cat > docker-compose.yml << 'EOF'
services:
boosapi:
image: 1467078763/boosapi:latest
ports:
- "4000:4000"
volumes:
- ./data:/app/data
environment:
AUTH_TOKEN: ${AUTH_TOKEN:?AUTH_TOKEN is required}
PROXY_TOKEN: ${PROXY_TOKEN:?PROXY_TOKEN is required}
CHECKIN_CRON: "0 8 * * *"
BALANCE_REFRESH_CRON: "0 * * * *"
PORT: ${PORT:-4000}
DATA_DIR: /app/data
TZ: ${TZ:-Asia/Shanghai}
restart: unless-stopped
EOF
export AUTH_TOKEN=your-admin-token
export PROXY_TOKEN=your-proxy-sk-token
docker compose up -d
```txt
4 CPU cores
8GB RAM
50GB SSD
No GPU required
```
Open `http://localhost:4000` and log in with `AUTH_TOKEN`.
## Quick Start
---
```bash
cp config.toml.example config.toml
./build-and-run.sh
```
## 🏗️ Tech Stack
For a production bundle with embedded web assets:
```bash
./build-and-run.sh --production
```
The production output is intended to run with only:
```txt
boosapi
config.toml
```
Web assets and Workflow UI assets are embedded in the Rust binary when built for production.
## Workflow Backend Configuration
Comfy Cloud:
```toml
[comfyui]
backend = "cloud"
base_url = "https://cloud.comfy.org"
api_key = "your-comfy-cloud-api-key"
```
RunningHub:
```toml
[comfyui]
backend = "runninghub"
base_url = "https://www.runninghub.cn/your-comfy-compatible-endpoint"
api_key = "your-runninghub-api-key"
```
External ComfyUI:
```toml
[comfyui]
backend = "external"
base_url = "http://127.0.0.1:8188"
```
BoosAPI API-native executor:
```toml
[comfyui]
backend = "boosapi"
```
## Environment Variables
Configuration can be overridden with `BOOSAPI_<SECTION>__<KEY>`:
```bash
BOOSAPI_SERVER__HOST=0.0.0.0
BOOSAPI_SERVER__PORT=4000
BOOSAPI_AUTH__AUTH_TOKEN=your-admin-token
BOOSAPI_AUTH__PROXY_TOKEN=sk-your-proxy-token
BOOSAPI_AUTH__JWT_SECRET=your-jwt-secret
BOOSAPI_DB__URL=sqlite:data/boosapi.db?mode=rwc
```
## Tech Stack
| Layer | Tech |
|-------|------|
| Backend | Fastify |
| Frontend | React 18 + Vite |
| Language | TypeScript |
| Styling | Tailwind CSS v4 |
| Database | SQLite / MySQL / PostgreSQL + Drizzle ORM |
| Visualization | VChart |
| Container | Docker + Docker Compose |
| Test | Vitest |
| Backend | Rust, Axum 0.8, Tokio |
| Database | SQLx, SQLite runtime path, PostgreSQL schema/migration preparation |
| Proxy engine | Token Router + Proxy Conductor |
| Protocols | OpenAI / Claude / Gemini compatible transformers |
| Frontend | React 19 + Vite |
| Workflow UI | Embedded ComfyUI frontend variants for cloud/external-compatible backends |
| Static serving | Rust binary serves `dist/web` and Workflow UI assets |
| Container | Docker / Docker Compose |
---
Node.js is only a build-time tool for the frontend. Production does not require a Node.js process.
## 🛠️ Local Development
## Local Development
```bash
npm install
npm run db:migrate
npm run dev
cargo check
cargo run
pnpm install
pnpm run dev:web
```
Build checks:
```bash
npm run build # Build web + server
npm test # Run tests
cargo check
pnpm run typecheck:web
npx vite build
```
---
## 📜 License
## License
[MIT](LICENSE)
-178
View File
@@ -1,178 +0,0 @@
# Security Policy / 安全政策
## Overview / 概述
The security of Metapi is important to us. This document outlines our security policy and how to report vulnerabilities.
Metapi 的安全对我们很重要。本文档概述了我们的安全政策以及如何报告漏洞。
Since Metapi is a self-hosted meta-aggregation layer that manages sensitive credentials (API keys, account passwords) and proxies AI API requests, security is a critical concern.
由于 Metapi 是一个自托管的元聚合层,管理敏感凭证(API 密钥、账号密码)并代理 AI API 请求,因此安全性至关重要。
## Supported Versions / 支持的版本
We provide security updates for the following versions / 我们为以下版本提供安全更新:
| Version / 版本 | Supported / 支持状态 |
| -------------- | -------------------- |
| Latest release / 最新版本 | ✅ Fully supported / 完全支持 |
| `main` branch / `main` 分支 | ✅ Supported / 支持 |
| Older releases / 旧版本 | ⚠️ Best effort / 尽力而为 |
| Forks / 分支 | ❌ Not supported / 不支持 |
**Recommendation** / **建议**: Always use the latest stable release from [GitHub Releases](https://github.com/cita-777/metapi/releases) or the `main` branch for the most up-to-date security patches.
始终使用 [GitHub Releases](https://github.com/cita-777/metapi/releases) 的最新稳定版本或 `main` 分支以获得最新的安全补丁。
## Security Considerations / 安全注意事项
When deploying Metapi, please consider / 部署 Metapi 时,请考虑:
### Credential Storage / 凭证存储
- All sensitive credentials (API keys, passwords) are encrypted at rest in the database / 所有敏感凭证(API 密钥、密码)在数据库中静态加密存储
- Use strong `AUTH_TOKEN` and `PROXY_TOKEN` values / 使用强 `AUTH_TOKEN` 和 `PROXY_TOKEN` 值
- Never commit `.env` files or expose tokens in logs / 切勿提交 `.env` 文件或在日志中暴露令牌
### Network Security / 网络安全
- Deploy behind HTTPS/TLS in production / 在生产环境中部署在 HTTPS/TLS 后面
- Use `ADMIN_IP_ALLOWLIST` to restrict admin access / 使用 `ADMIN_IP_ALLOWLIST` 限制管理员访问
- Consider firewall rules to limit access to port 4000 / 考虑使用防火墙规则限制对端口 4000 的访问
### Database Security / 数据库安全
- Secure your database with strong credentials / 使用强凭证保护您的数据库
- Regularly backup the `data/` directory / 定期备份 `data/` 目录
- For production, consider using MySQL/PostgreSQL instead of SQLite / 对于生产环境,考虑使用 MySQL/PostgreSQL 而不是 SQLite
### Docker Security / Docker 安全
- Keep Docker images up to date / 保持 Docker 镜像最新
- Use volume mounts carefully to avoid exposing sensitive data / 谨慎使用卷挂载以避免暴露敏感数据
- Run containers with minimal privileges / 以最小权限运行容器
## Reporting a Vulnerability / 报告漏洞
**⚠️ Please do NOT report security vulnerabilities through public GitHub issues, discussions, or pull requests.**
**⚠️ 请勿通过公开的 GitHub issue、讨论或 pull request 报告安全漏洞。**
### Reporting Channels / 报告渠道
Use one of these private channels / 使用以下私密渠道之一:
1. **GitHub Security Advisory** (Preferred) / **GitHub 安全公告**(首选)
- Go to: https://github.com/cita-777/metapi/security/advisories/new
- This allows for coordinated disclosure and CVE assignment / 这允许协调披露和 CVE 分配
2. **Email** / **邮件**
- Send to: `cita-777@users.noreply.github.com`
- Subject: `[Metapi Security] <brief description>` / 主题:`[Metapi Security] <简要描述>`
### What to Include / 应包含的内容
To help us understand and address the issue quickly, please include / 为了帮助我们快速理解和解决问题,请包含:
- **Description** / **描述**: Clear description of the vulnerability / 漏洞的清晰描述
- **Impact** / **影响**: What an attacker could achieve / 攻击者可能实现的目标
- **Affected versions** / **受影响的版本**: Version, commit hash, or deployment mode / 版本、提交哈希或部署模式
- **Affected components** / **受影响的组件**: Specific endpoints, modules, or configuration / 特定端点、模块或配置
- **Reproduction steps** / **复现步骤**: Step-by-step instructions to reproduce / 逐步复现说明
- **Proof of concept** / **概念验证**: Code, screenshots, or logs (with secrets redacted) / 代码、截图或日志(删除敏感信息)
- **Attack preconditions** / **攻击前提条件**: Required access level, network position, etc. / 所需访问级别、网络位置等
- **Suggested fix** / **建议修复**: If you have ideas for mitigation or patches / 如果您有缓解措施或补丁的想法
### Example Report / 报告示例
```
Subject: [Metapi Security] SQL Injection in account search
Description:
The account search endpoint is vulnerable to SQL injection through the
'name' parameter, allowing unauthorized database access.
Impact:
An authenticated attacker could extract all account credentials from
the database.
Affected Version:
v1.2.2 and earlier
Reproduction:
1. Login to Metapi admin panel
2. Navigate to /api/accounts/search?name=' OR '1'='1
3. Observe all accounts returned
Proof of Concept:
[Screenshot attached with sensitive data redacted]
Suggested Fix:
Use parameterized queries instead of string concatenation in
src/server/routes/api/accounts.ts:45
```
## Response Process / 响应流程
Our security response process / 我们的安全响应流程:
1. **Acknowledgment** / **确认**: We aim to acknowledge your report within **3 business days** / 我们力求在 **3 个工作日**内确认您的报告
2. **Initial Triage** / **初步分类**: We will provide initial assessment or follow-up questions within **7 business days** / 我们将在 **7 个工作日**内提供初步评估或后续问题
3. **Investigation** / **调查**: We may request additional details, logs, or reproduction steps / 我们可能会要求额外的细节、日志或复现步骤
4. **Fix Development** / **修复开发**: We will develop and test a fix / 我们将开发并测试修复方案
5. **Coordinated Disclosure** / **协调披露**: We will coordinate disclosure timing with you / 我们将与您协调披露时间
- Please keep the vulnerability confidential until we release a fix / 请在我们发布修复之前对漏洞保密
- We will credit you in the security advisory (unless you prefer to remain anonymous) / 我们将在安全公告中致谢您(除非您希望保持匿名)
6. **Release** / **发布**: We will release a patched version and publish a security advisory / 我们将发布修补版本并发布安全公告
### Severity Assessment / 严重性评估
We use the following severity levels / 我们使用以下严重性级别:
- **Critical** / **严重**: Remote code execution, credential theft, data breach / 远程代码执行、凭证盗窃、数据泄露
- **High** / **高**: Authentication bypass, privilege escalation / 身份验证绕过、权限提升
- **Medium** / **中**: Information disclosure, denial of service / 信息泄露、拒绝服务
- **Low** / **低**: Minor information leaks, configuration issues / 轻微信息泄露、配置问题
## Public Disclosure / 公开披露
If a vulnerability is accidentally posted publicly / 如果漏洞被意外公开发布:
1. We will immediately assess the risk / 我们将立即评估风险
2. We may redact sensitive details from the public post / 我们可能会从公开帖子中删除敏感细节
3. We will redirect the reporter to this private process / 我们将把报告者重定向到此私密流程
4. We will expedite the fix and release process / 我们将加快修复和发布流程
## Security Updates / 安全更新
Security updates will be announced through / 安全更新将通过以下方式公布:
- [GitHub Security Advisories](https://github.com/cita-777/metapi/security/advisories)
- [GitHub Releases](https://github.com/cita-777/metapi/releases) with `[SECURITY]` tag / 带有 `[SECURITY]` 标签
- Project README and documentation / 项目 README 和文档
Subscribe to repository notifications to stay informed / 订阅仓库通知以保持了解。
## Bug Bounty / 漏洞赏金
We currently do not offer a paid bug bounty program. However, we deeply appreciate security researchers who responsibly disclose vulnerabilities and will publicly acknowledge your contribution (with your permission).
我们目前不提供付费漏洞赏金计划。但是,我们非常感谢负责任地披露漏洞的安全研究人员,并将公开致谢您的贡献(经您许可)。
## Questions / 问题
If you have questions about this security policy, please contact `cita-777@users.noreply.github.com`.
如果您对本安全政策有疑问,请联系 `cita-777@users.noreply.github.com`。
---
**Thank you for helping keep Metapi and its users safe!**
**感谢您帮助保护 Metapi 及其用户的安全!**
-208
View File
@@ -1,208 +0,0 @@
# 本地测试环境搭建指南
## 目录结构
```
D:\Code\Projects\Metapi\
├── metapi # 主仓库(upstream)
├── metapi-routing-ux-optimization # 开发 worktree (模型白名单功能)
├── metapi-upstream-latest # 其他 worktree
└── ...
```
## 快速启动测试服务器
### 1. 进入开发 worktree
```bash
cd D:/Code/Projects/Metapi/metapi-routing-ux-optimization
```
### 2. 安装依赖(首次运行)
```bash
pnpm install
```
### 3. 构建项目
```bash
pnpm build
```
### 4. 启动测试服务器
```bash
DATA_DIR="./tmp/test-db" node dist/server/index.js
```
服务器将在 http://localhost:4000 启动
**测试Token:** `test-admin-token`
### 5. 访问前端
打开浏览器访问: http://localhost:4000
登录使用 Token: `test-admin-token`
## 测试数据准备
### 方式1: 导入真实站点
在前端"站点管理"中添加真实的 new-api 站点:
- 站点URL: 真实的 new-api 地址
- 平台: new-api
- 添加账号和token
然后触发模型发现:
```bash
curl -X POST -H "Authorization: Bearer test-admin-token" \
-H "Content-Type: application/json" \
-d '{"refreshModels": true, "wait": true}' \
http://localhost:4000/api/routes/rebuild
```
### 方式2: 使用测试脚本(可选)
```bash
DATA_DIR="./tmp/test-db" node scripts/seed-test-data.js
```
## 测试模型白名单功能
### 1. 进入设置页面
点击左侧导航"设置",滚动到"全局模型白名单"卡片
### 2. 测试功能
#### 添加模型到白名单
- **方式1**: 在输入框输入模型名称,按回车
- **方式2**: 点击"可用模型列表"中的模型
#### 删除模型
- 点击已选模型徽章上的 × 按钮
#### 保存配置
- 点击"保存模型白名单"按钮
- 系统自动触发路由重建
### 3. 验证效果
#### 查看路由
进入"令牌路由"页面,确认只有白名单中的模型有路由
#### 查看模型候选
```bash
curl -H "Authorization: Bearer test-admin-token" \
http://localhost:4000/api/models/token-candidates | python -m json.tool
```
应该只返回白名单中的模型
### 4. API 测试命令
#### 查看当前白名单
```bash
curl -H "Authorization: Bearer test-admin-token" \
http://localhost:4000/api/settings/runtime | python -m json.tool | grep -A 5 "globalAllowedModels"
```
#### 设置白名单
```bash
curl -X PUT \
-H "Authorization: Bearer test-admin-token" \
-H "Content-Type: application/json" \
-d '{"globalAllowedModels": ["gpt-4", "gpt-4o"]}' \
http://localhost:4000/api/settings/runtime | python -m json.tool
```
#### 清空白名单(允许所有模型)
```bash
curl -X PUT \
-H "Authorization: Bearer test-admin-token" \
-H "Content-Type: application/json" \
-d '{"globalAllowedModels": []}' \
http://localhost:4000/api/settings/runtime | python -m json.tool
```
## 停止测试服务器
### 查找进程
```bash
# Windows
netstat -ano | grep ":4000" | grep "LISTENING"
# Linux/Mac
lsof -i :4000
```
### 停止进程
```bash
# Windows (替换 PID)
taskkill //F //PID <PID>
# Linux/Mac
kill <PID>
```
## 开发工作流
### 修改代码后重新构建
```bash
pnpm build
```
### 运行测试
```bash
pnpm test
```
### 提交代码
```bash
git add .
git commit -m "feat: 实现模型白名单功能"
git push origin routing-ux-optimization
```
## 常见问题
### Q: 路由列表为空?
A: 检查 token_model_availability 表是否有数据,触发模型发现重新加载
### Q: 白名单保存后路由消失?
A: 正常现象,白名单会过滤掉不在列表中的模型路由
### Q: 如何恢复所有模型?
A: 清空白名单(设置为空数组 [])即可
## 数据库位置
测试数据库存储在: `./tmp/test-db/hub.db`
查看数据库:
```bash
sqlite3 ./tmp/test-db/hub.db
```
常用查询:
```sql
-- 查看所有站点
SELECT id, name, url, platform FROM sites WHERE status = 'active';
-- 查看所有账号
SELECT id, site_id, username, status FROM accounts;
-- 查看所有token
SELECT id, account_id, name, value_status FROM account_tokens;
-- 查看模型可用性
SELECT COUNT(*) FROM token_model_availability;
-- 查看路由
SELECT COUNT(*) FROM token_routes;
```
File diff suppressed because one or more lines are too long
BIN
View File
Binary file not shown.
-298
View File
@@ -1,298 +0,0 @@
"""
BoosAPI Custom Nodes for ComfyUI
==================================
Call local BoosAPI endpoints directly from ComfyUI workflows:
- Text generation (gpt-5.5)
- Image generation (gpt-image-2)
- Text-to-Speech
- Video generation (async)
Default: http://127.0.0.1:4000/v1 (same container)
Override via env:
BOOSAPI_BASE = https://api.boos.lat/v1 (standalone ComfyUI)
BOOSAPI_KEY=***
"""
import os
import json
import time
import requests
import torch
import numpy as np
from PIL import Image
from io import BytesIO
import folder_paths
# ── Config ──────────────────────────────────────────────────────────────────
# When running inside the same container as BoosAPI, use localhost.
# For standalone ComfyUI installs, set BOOSAPI_BASE env to https://api.boos.lat/v1
BOOSAPI_BASE = os.getenv("BOOSAPI_BASE", "https://api.boos.lat/v1")
BOOSAPI_KEY = os.getenv("BOOSAPI_KEY", "sk-boos4721")
HEADERS = {
"Authorization": f"Bearer {BOOSAPI_KEY}",
"Content-Type": "application/json",
}
def _call_api(endpoint: str, payload: dict, timeout: int = 120) -> dict:
url = f"{BOOSAPI_BASE}/{endpoint.lstrip('/')}"
resp = requests.post(url, headers=HEADERS, json=payload, timeout=timeout)
if not resp.ok:
raise RuntimeError(f"BoosAPI {endpoint} failed HTTP {resp.status_code}: {resp.text}")
return resp.json()
# ── Shared helpers ──────────────────────────────────────────────────────────
def _pil_to_tensor(img: Image.Image) -> torch.Tensor:
"""PIL -> ComfyUI IMAGE (1, H, W, 3) float32 0-1."""
img = img.convert("RGB")
arr = np.array(img).astype(np.float32) / 255.0
return torch.from_numpy(arr).unsqueeze(0)
def _tensor_to_pil(tensor: torch.Tensor) -> Image.Image:
"""ComfyUI IMAGE -> PIL."""
arr = (tensor.squeeze(0).cpu().numpy() * 255).astype(np.uint8)
return Image.fromarray(arr)
# ── Node: Text Generation (gpt-5.5) ─────────────────────────────────────────
class BoosAPITextGen:
"""Call gpt-5.5 chat completion. Useful for prompt engineering within workflows."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"prompt": ("STRING", {"multiline": True, "default": ""}),
"model": ("STRING", {"default": "gpt-5.5"}),
"temperature": ("FLOAT", {"default": 0.7, "min": 0, "max": 2, "step": 0.1}),
"max_tokens": ("INT", {"default": 2048, "min": 1, "max": 32768}),
},
"optional": {
"system_prompt": ("STRING", {"multiline": True, "default": ""}),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("text",)
FUNCTION = "generate"
CATEGORY = "BoosAPI"
def generate(self, prompt, model, temperature, max_tokens, system_prompt=""):
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
messages.append({"role": "user", "content": prompt})
data = _call_api("chat/completions", {
"model": model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
})
text = data["choices"][0]["message"]["content"]
return (text,)
# ── Node: Image Generation (gpt-image-2) ────────────────────────────────────
class BoosAPIImageGen:
"""Generate image via BoosAPI. Replaces CheckpointLoader+KSampler+VAE chain."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"prompt": ("STRING", {"multiline": True, "default": ""}),
"model": (["gpt-image-2", "dall-e-3"], {"default": "gpt-image-2"}),
"size": (["1024x1024", "1792x1024", "1024x1792"], {"default": "1024x1024"}),
},
"optional": {
"negative_prompt": ("STRING", {"multiline": True, "default": ""}),
"n": ("INT", {"default": 1, "min": 1, "max": 4}),
},
}
RETURN_TYPES = ("IMAGE", "STRING")
RETURN_NAMES = ("image", "image_url")
FUNCTION = "generate"
CATEGORY = "BoosAPI"
def generate(self, prompt, model, size, negative_prompt="", n=1):
payload = {
"model": model,
"prompt": prompt,
"n": n,
"size": size,
}
if negative_prompt:
payload["negative_prompt"] = negative_prompt
data = _call_api("images/generations", payload)
# Download first image
image_url = data["data"][0]["url"]
img_resp = requests.get(image_url, timeout=60)
img = Image.open(BytesIO(img_resp.content))
tensor = _pil_to_tensor(img)
return (tensor, image_url)
# ── Node: Text-to-Speech ────────────────────────────────────────────────────
class BoosAPITTS:
"""Generate speech audio from text via BoosAPI TTS."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": ("STRING", {"multiline": True, "default": ""}),
"voice": (["alloy", "echo", "fable", "onyx", "nova", "shimmer"], {"default": "alloy"}),
},
"optional": {
"speed": ("FLOAT", {"default": 1.0, "min": 0.25, "max": 4.0, "step": 0.25}),
},
}
RETURN_TYPES = ("STRING",) # file path to generated audio
RETURN_NAMES = ("audio_path",)
FUNCTION = "generate"
CATEGORY = "BoosAPI"
OUTPUT_NODE = True
def generate(self, text, voice, speed=1.0):
url = f"{BOOSAPI_BASE}/audio/speech"
resp = requests.post(
url, headers=HEADERS,
json={"model": "tts-1", "input": text, "voice": voice, "speed": speed},
timeout=120,
)
if not resp.ok:
raise RuntimeError(f"TTS failed HTTP {resp.status_code}: {resp.text}")
# Save to ComfyUI output directory
output_dir = folder_paths.get_output_directory()
os.makedirs(output_dir, exist_ok=True)
filename = f"boosapi_tts_{int(time.time())}.mp3"
filepath = os.path.join(output_dir, filename)
with open(filepath, "wb") as f:
f.write(resp.content)
print(f"[BoosAPI] TTS saved: {filepath}")
return (filepath,)
# ── Node: Video Generation (async) ─────────────────────────────────────────
class BoosAPIVideoGen:
"""Generate video via BoosAPI (async). Returns task ID for polling."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"prompt": ("STRING", {"multiline": True, "default": ""}),
"model": ("STRING", {"default": "cogvideo-5b"}),
"size": (["1024x1024", "1024x576", "576x1024", "1920x1080"], {"default": "1024x576"}),
},
"optional": {
"negative_prompt": ("STRING", {"multiline": True, "default": ""}),
"duration": ("INT", {"default": 5, "min": 2, "max": 30}),
"fps": ("INT", {"default": 16, "min": 8, "max": 30}),
},
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("task_id", "status_url")
FUNCTION = "generate"
CATEGORY = "BoosAPI"
def generate(self, prompt, model, size, negative_prompt="", duration=5, fps=16):
width, height = size.split("x")
payload = {
"model": model,
"prompt": prompt,
"width": int(width),
"height": int(height),
"duration": duration,
"fps": fps,
}
if negative_prompt:
payload["negative_prompt"] = negative_prompt
data = _call_api("videos", payload)
task_id = data.get("id", "")
status_url = f"{BOOSAPI_BASE}/videos/{task_id}"
return (task_id, status_url)
class BoosAPIVideoStatus:
"""Poll video generation status. Returns download URL when done."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"task_id": ("STRING", {"default": ""}),
},
"optional": {
"wait": ("BOOLEAN", {"default": True, "label_on": "Wait until done", "label_off": "Check once"}),
"poll_interval": ("INT", {"default": 5, "min": 1, "max": 60}),
"max_polls": ("INT", {"default": 60, "min": 1, "max": 600}),
},
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("status", "video_url")
FUNCTION = "poll"
CATEGORY = "BoosAPI"
def poll(self, task_id, wait=True, poll_interval=5, max_polls=60):
url = f"{BOOSAPI_BASE}/videos/{task_id}"
for attempt in range(max_polls):
resp = requests.get(url, headers=HEADERS, timeout=30)
if not resp.ok:
return ("error", f"HTTP {resp.status_code}")
data = resp.json()
status = data.get("status", "unknown")
if status == "completed":
video_url = data.get("video_url") or data.get("output", {}).get("video_url", "")
return ("completed", video_url)
elif status in ("failed", "error"):
error_msg = data.get("error", str(data))
return (f"failed: {error_msg}", "")
if not wait:
return (status, "")
time.sleep(poll_interval)
return ("timeout", "")
# ── Registration ────────────────────────────────────────────────────────────
NODE_CLASS_MAPPINGS = {
"BoosAPITextGen": BoosAPITextGen,
"BoosAPIImageGen": BoosAPIImageGen,
"BoosAPITTS": BoosAPITTS,
"BoosAPIVideoGen": BoosAPIVideoGen,
"BoosAPIVideoStatus": BoosAPIVideoStatus,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"BoosAPITextGen": "BoosAPI Text Gen (gpt-5.5)",
"BoosAPIImageGen": "BoosAPI Image Gen (gpt-image-2)",
"BoosAPITTS": "BoosAPI Text-to-Speech",
"BoosAPIVideoGen": "BoosAPI Video Gen (async)",
"BoosAPIVideoStatus": "BoosAPI Video Status",
}
+625
View File
@@ -0,0 +1,625 @@
#!/usr/bin/env bash
# 编译前后端并启动服务
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT_DIR"
# ── 工具函数 ──────────────────────────────────────────────────────────────────
log() { echo "[$(date '+%H:%M:%S')] $*"; }
ok() { echo "[$(date '+%H:%M:%S')] ✓ $*"; }
fail() { echo "[$(date '+%H:%M:%S')] ✗ $*" >&2; exit 1; }
# ── 参数 ──────────────────────────────────────────────────────────────────────
BUILD_PROFILE="${BOOSAPI_BUILD_PROFILE:-dev}"
FRONTEND_ONLY=0
BACKEND_ONLY=0
CHECK_ONLY=0
NO_RUN=0
FORCE_FRONTEND=0
SKIP_FRONTEND=0
PRODUCTION_BUNDLE=0
PRODUCTION_DIR="${BOOSAPI_PRODUCTION_DIR:-$ROOT_DIR/dist/production}"
CARGO_FEATURES="${BOOSAPI_CARGO_FEATURES:-}"
CARGO_JOBS="${BOOSAPI_CARGO_JOBS:-}"
FAST_LINKER="${BOOSAPI_FAST_LINKER:-auto}"
FRONTEND_PM="${BOOSAPI_FRONTEND_PM:-auto}"
BUILD_TIMINGS=()
usage() {
cat <<'EOF'
用法: ./build-and-run.sh [选项]
选项:
--dev 使用 dev 模式编译后端(默认,最快)
--release 使用 release 模式编译后端(更慢但性能好)
--fast-release 使用 fast-release 模式编译后端(比 release 快,性能接近生产)
--profile <name> 指定构建 profile:dev / release / fast-release
--production 生产编译:先构建前端并嵌入二进制,再输出 dist/production/{boosapi,config.toml}
--prod 同 --production
--production-dir <dir>
指定生产产物目录(默认:dist/production)
--check 只执行 cargo check,不构建前端、不启动服务(最快验证)
--backend-check 同 --check
--frontend-only 只构建前端
--backend-only 只构建后端
--force-frontend 强制重建前端,忽略缓存
--skip-frontend 跳过前端构建(沿用现有 dist/web)
--bun 主前端使用 Bun 执行 Vite 构建(Workflow UI 子模块会自动准备 pnpm)
--no-run 构建后不启动服务
BOOSAPI_BUILD_PROFILE 可设置默认 profile(dev / release / fast-release)
BOOSAPI_CARGO_FEATURES 可覆盖后端 Cargo features(默认:空)
BOOSAPI_CARGO_JOBS 可设置 cargo 并发任务数,例如 8
BOOSAPI_FAST_LINKER 可设置 auto / off / mold / lld(默认:auto,Linux 下优先 mold)
BOOSAPI_FRONTEND_PM 可设置主前端构建器:auto / npm / bun(默认:auto)
BOOSAPI_PRODUCTION_DIR 可设置 --production 默认产物目录
-h, --help 显示帮助
示例:
./build-and-run.sh --release
./build-and-run.sh --fast-release
./build-and-run.sh --check
./build-and-run.sh --bun
./build-and-run.sh --production
./build-and-run.sh --production --production-dir /opt/boosapi-release
BOOSAPI_BUILD_PROFILE=release ./build-and-run.sh
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
--dev) BUILD_PROFILE="dev" ;;
--release) BUILD_PROFILE="release" ;;
--fast-release) BUILD_PROFILE="fast-release" ;;
--production|--prod)
PRODUCTION_BUNDLE=1
BUILD_PROFILE="release"
NO_RUN=1
;;
--production-dir)
shift
[ $# -gt 0 ] || fail "--production-dir 需要目录参数"
PRODUCTION_DIR="$1"
;;
--check|--backend-check)
CHECK_ONLY=1
BACKEND_ONLY=1
NO_RUN=1
;;
--profile)
shift
[ $# -gt 0 ] || fail "--profile 需要参数:dev / release / fast-release"
BUILD_PROFILE="$1"
;;
--frontend-only) FRONTEND_ONLY=1 ;;
--backend-only) BACKEND_ONLY=1 ;;
--force-frontend) FORCE_FRONTEND=1 ;;
--skip-frontend) SKIP_FRONTEND=1 ;;
--bun) FRONTEND_PM="bun" ;;
--no-run) NO_RUN=1 ;;
--rebuild-image) log "--rebuild-image 已废弃:内置 Docker ComfyUI 工作台已移除,忽略该参数" ;;
-h|--help) usage; exit 0 ;;
*) echo "未知参数: $1" >&2; usage >&2; exit 1 ;;
esac
shift
done
if [ "$FRONTEND_ONLY" -eq 1 ] && [ "$BACKEND_ONLY" -eq 1 ]; then
fail "--frontend-only 和 --backend-only 不能同时使用"
fi
if [ "$PRODUCTION_BUNDLE" -eq 1 ] && [ "$FRONTEND_ONLY" -eq 1 ]; then
fail "--production 不能和 --frontend-only 同时使用"
fi
if [ "$PRODUCTION_BUNDLE" -eq 1 ] && [ "$BACKEND_ONLY" -eq 1 ]; then
fail "--production 需要先构建前端以嵌入 web,不能和 --backend-only 同时使用"
fi
if [ "$PRODUCTION_BUNDLE" -eq 1 ] && [ "$CHECK_ONLY" -eq 1 ]; then
fail "--production 不能和 --check 同时使用"
fi
case "$BUILD_PROFILE" in
dev|debug) BUILD_PROFILE="dev" ;;
release|fast-release) ;;
*) fail "未知构建 profile: $BUILD_PROFILE(可选:dev / release / fast-release)" ;;
esac
case "$FAST_LINKER" in
auto|off|none|mold|lld) ;;
*) fail "未知 BOOSAPI_FAST_LINKER: $FAST_LINKER(可选:auto / off / mold / lld)" ;;
esac
timer_start() {
date +%s
}
record_timing() {
local label="$1"
local started="$2"
local ended
ended="$(date +%s)"
BUILD_TIMINGS+=("${label}: $((ended - started))s")
}
print_timing_summary() {
[ "${#BUILD_TIMINGS[@]}" -gt 0 ] || return 0
log "构建耗时:"
local item
for item in "${BUILD_TIMINGS[@]}"; do
log " - $item"
done
}
append_rustflags() {
local flags="$1"
if [[ " ${RUSTFLAGS:-} " != *" $flags "* ]]; then
export RUSTFLAGS="${RUSTFLAGS:+$RUSTFLAGS }$flags"
fi
}
build_cargo_args() {
CARGO_BASE_ARGS=()
CARGO_FEATURE_ARGS=()
if [ -n "$CARGO_JOBS" ]; then
CARGO_BASE_ARGS+=(--jobs "$CARGO_JOBS")
fi
if [ -n "$CARGO_FEATURES" ]; then
CARGO_FEATURE_ARGS=(--features "$CARGO_FEATURES")
fi
}
configure_fast_linker() {
local mode="$FAST_LINKER"
case "$(uname -s 2>/dev/null || true)" in
Linux) ;;
*)
[ "$mode" = "auto" ] && return 0
fail "BOOSAPI_FAST_LINKER=$mode 目前只在 Linux 构建环境自动配置"
;;
esac
if [ "$mode" = "off" ] || [ "$mode" = "none" ]; then
return 0
fi
if [ "$mode" = "auto" ]; then
if command -v mold >/dev/null 2>&1; then
mode="mold"
elif command -v ld.lld >/dev/null 2>&1 || command -v lld >/dev/null 2>&1; then
mode="lld"
else
log "提示: 安装 mold 或 lld 可显著缩短 Rust 链接时间;当前使用系统默认链接器"
return 0
fi
fi
case "$mode" in
mold)
command -v mold >/dev/null 2>&1 || fail "BOOSAPI_FAST_LINKER=mold 但未找到 mold"
if command -v clang >/dev/null 2>&1; then
append_rustflags "-C linker=clang"
elif ! command -v gcc >/dev/null 2>&1 && [ -z "${RUSTC_LINKER:-}" ]; then
fail "启用 mold 需要 clang 或 gcc 作为 linker driver"
fi
append_rustflags "-C link-arg=-fuse-ld=mold"
log "Rust 快速链接器: mold(BOOSAPI_FAST_LINKER=$FAST_LINKER)"
;;
lld)
if ! command -v ld.lld >/dev/null 2>&1 && ! command -v lld >/dev/null 2>&1; then
fail "BOOSAPI_FAST_LINKER=lld 但未找到 ld.lld/lld"
fi
if command -v clang >/dev/null 2>&1; then
append_rustflags "-C linker=clang"
elif ! command -v gcc >/dev/null 2>&1 && [ -z "${RUSTC_LINKER:-}" ]; then
fail "启用 lld 需要 clang 或 gcc 作为 linker driver"
fi
append_rustflags "-C link-arg=-fuse-ld=lld"
log "Rust 快速链接器: lld(BOOSAPI_FAST_LINKER=$FAST_LINKER)"
;;
esac
}
server_port() {
if [ -n "${BOOSAPI_SERVER__PORT:-}" ]; then
printf '%s\n' "$BOOSAPI_SERVER__PORT"
return
fi
awk '
/^\[server\]/ { in_server = 1; next }
/^\[/ { in_server = 0 }
in_server && /^[[:space:]]*port[[:space:]]*=/ {
gsub(/[^0-9]/, "", $0)
print $0
exit
}
' "$ROOT_DIR/config.toml" 2>/dev/null || true
}
is_running_pid() {
local pid="$1"
[ -n "$pid" ] && kill -0 "$pid" 2>/dev/null
}
stop_pids() {
local label="$1"
shift || true
local pids=()
local pid
for pid in "$@"; do
if [ -n "$pid" ] && [ "$pid" != "$$" ] && is_running_pid "$pid"; then
pids+=("$pid")
fi
done
if [ "${#pids[@]}" -eq 0 ]; then
return 0
fi
log "停止旧进程 ${label}: ${pids[*]}"
kill -TERM "${pids[@]}" 2>/dev/null || true
for _ in 1 2 3 4 5; do
local alive=()
for pid in "${pids[@]}"; do
if is_running_pid "$pid"; then
alive+=("$pid")
fi
done
if [ "${#alive[@]}" -eq 0 ]; then
ok "旧进程已停止"
return 0
fi
sleep 1
pids=("${alive[@]}")
done
log "旧进程未及时退出,强制结束: ${pids[*]}"
kill -KILL "${pids[@]}" 2>/dev/null || true
}
stop_previous_server() {
local binary_pids=()
local port_pids=()
local port
while IFS= read -r pid; do
[ -n "$pid" ] && binary_pids+=("$pid")
done < <(pgrep -f "target/(debug|release|fast-release)/boosapi" 2>/dev/null || true)
stop_pids "boosapi binary" "${binary_pids[@]}"
port="$(server_port)"
port="${port:-4000}"
if command -v fuser >/dev/null 2>&1; then
while IFS= read -r pid; do
[ -n "$pid" ] && port_pids+=("$pid")
done < <(fuser "${port}/tcp" 2>/dev/null | tr ' ' '\n' || true)
stop_pids "占用 ${port}/tcp" "${port_pids[@]}"
else
log "未找到 fuser,跳过端口 ${port}/tcp 占用检查"
fi
}
ensure_pnpm() {
if command -v pnpm >/dev/null 2>&1; then
return 0
fi
command -v corepack >/dev/null 2>&1 || fail "未找到 pnpm/corepack,无法自动准备 pnpm。请安装 Node.js 22+ 或 pnpm"
log "未找到 pnpm,尝试通过 corepack 自动启用..."
corepack enable
corepack prepare pnpm@latest --activate
command -v pnpm >/dev/null 2>&1 || fail "pnpm 自动准备失败,请手动安装 pnpm"
}
install_frontend_dependencies() {
local frontend_pm="$1"
if [ "$frontend_pm" = "bun" ]; then
command -v bun >/dev/null 2>&1 || fail "未找到 bun,请安装 Bun 或移除 --bun"
if [ ! -d "$ROOT_DIR/node_modules" ] || [ "$FORCE_FRONTEND" -eq 1 ]; then
log "安装主前端依赖 (bun install)..."
bun install
fi
return 0
fi
command -v node >/dev/null 2>&1 || fail "未找到 node,请先安装 Node.js"
ensure_pnpm
if [ ! -d "$ROOT_DIR/node_modules" ] || [ "$FORCE_FRONTEND" -eq 1 ]; then
log "安装主前端依赖 (pnpm install)..."
pnpm install --frozen-lockfile
fi
}
build_frontend() {
local started
started="$(timer_start)"
if [ "$SKIP_FRONTEND" -eq 1 ]; then
log "跳过前端构建(--skip-frontend)"
record_timing "前端检查" "$started"
return 0
fi
local frontend_pm="$FRONTEND_PM"
case "$frontend_pm" in
auto)
if command -v bun >/dev/null 2>&1; then
frontend_pm="bun"
else
frontend_pm="npm"
fi
;;
npm|bun) ;;
*) fail "未知 BOOSAPI_FRONTEND_PM: $frontend_pm(可选:auto / npm / bun)" ;;
esac
install_frontend_dependencies "$frontend_pm"
local fingerprint_file="$ROOT_DIR/dist/web/.boosapi-build-fingerprint"
local fingerprint
fingerprint="$(
{
printf 'build-script-v2\n'
printf 'frontend_pm=%s\n' "$frontend_pm"
printf 'node='
node --version 2>/dev/null || true
printf 'npx='
npx --version 2>/dev/null || true
printf 'bun='
bun --version 2>/dev/null || true
for path in package.json pnpm-lock.yaml package-lock.json yarn.lock vite.config.ts tsconfig.json tsconfig.web.json tsconfig.transpile.json src/web src/shared; do
[ -e "$ROOT_DIR/$path" ] && find "$ROOT_DIR/$path" -type f -print
done | sort | xargs -r sha256sum
} | sha256sum | awk '{print $1}'
)"
if [ "$FORCE_FRONTEND" -eq 0 ] \
&& [ -f "$fingerprint_file" ] \
&& [ -f "$ROOT_DIR/dist/web/index.html" ] \
&& [ "$(cat "$fingerprint_file" 2>/dev/null || true)" = "$fingerprint" ]; then
ok "前端无变化,跳过 Vite 构建 → dist/web/"
record_timing "前端检查" "$started"
return 0
fi
log "构建前端 (Vite via ${frontend_pm})..."
# 直接调用 vite build;桌面端构建链路已移除。
if [ "$frontend_pm" = "bun" ]; then
bunx --bun vite build
else
pnpm exec vite build
fi
mkdir -p "$ROOT_DIR/dist/web"
printf '%s\n' "$fingerprint" > "$fingerprint_file"
ok "前端构建完成 → dist/web/"
record_timing "前端构建" "$started"
}
install_website_dependencies() {
command -v node >/dev/null 2>&1 || fail "未找到 node,请先安装 Node.js"
if [ ! -d "$ROOT_DIR/website/node_modules" ] || [ "$FORCE_FRONTEND" -eq 1 ]; then
log "安装官网依赖 (npm --prefix website install)..."
npm --prefix "$ROOT_DIR/website" install
fi
}
build_public_website() {
local started
started="$(timer_start)"
if [ "$SKIP_FRONTEND" -eq 1 ]; then
log "跳过官网构建(--skip-frontend)"
record_timing "官网检查" "$started"
return 0
fi
[ -f "$ROOT_DIR/website/package.json" ] || fail "未找到 website/package.json,无法构建官网"
install_website_dependencies
local fingerprint_file="$ROOT_DIR/dist/website/.bossai-website-build-fingerprint"
local fingerprint
fingerprint="$(
{
printf 'website-build-v1\n'
printf 'node='
node --version 2>/dev/null || true
printf 'npm='
npm --version 2>/dev/null || true
for path in website/package.json website/package-lock.json website/next.config.ts website/postcss.config.mjs website/tsconfig.json website/app website/public; do
[ -e "$ROOT_DIR/$path" ] && find "$ROOT_DIR/$path" -type f -print
done | sort | xargs -r sha256sum
} | sha256sum | awk '{print $1}'
)"
if [ "$FORCE_FRONTEND" -eq 0 ] \
&& [ -f "$fingerprint_file" ] \
&& [ -f "$ROOT_DIR/dist/website/index.html" ] \
&& [ "$(cat "$fingerprint_file" 2>/dev/null || true)" = "$fingerprint" ]; then
ok "官网无变化,跳过 Next 构建 → dist/website/"
record_timing "官网检查" "$started"
return 0
fi
log "构建官网 (Next.js static export)..."
npm --prefix "$ROOT_DIR/website" run build
rm -rf "$ROOT_DIR/dist/website"
mkdir -p "$ROOT_DIR/dist/website"
cp -R "$ROOT_DIR/website/out/." "$ROOT_DIR/dist/website/"
printf '%s\n' "$fingerprint" > "$fingerprint_file"
ok "官网构建完成 → dist/website/"
record_timing "官网构建" "$started"
}
ensure_workflow_ui_for_embedding() {
local started
started="$(timer_start)"
if [ "$SKIP_FRONTEND" -eq 1 ]; then
log "跳过 Workflow UI 构建检查(--skip-frontend)"
record_timing "Workflow UI 检查" "$started"
return 0
fi
if [ -f "$ROOT_DIR/dist/workflow-ui-external/index.html" ] \
&& [ -f "$ROOT_DIR/dist/workflow-ui-cloud/index.html" ] \
&& [ -f "$ROOT_DIR/dist/workflow-ui/index.html" ] \
&& [ "$FORCE_FRONTEND" -eq 0 ]; then
ok "Workflow UI 已存在,跳过构建 → dist/workflow-ui-{external,cloud}/"
record_timing "Workflow UI 检查" "$started"
return 0
fi
command -v git >/dev/null 2>&1 || fail "未找到 git,无法构建 Workflow UI"
command -v node >/dev/null 2>&1 || fail "未找到 node,无法构建 Workflow UI"
ensure_pnpm
log "构建 Workflow UI 双前端(external + cloud,用于嵌入二进制)..."
pnpm run workflow-ui:install
ok "Workflow UI 构建完成 → dist/workflow-ui-external/ dist/workflow-ui-cloud/"
record_timing "Workflow UI 构建" "$started"
}
build_backend() {
local started
started="$(timer_start)"
if ! command -v cargo >/dev/null 2>&1; then
fail "未找到 cargo,请先安装 Rust 工具链"
fi
configure_fast_linker
build_cargo_args
if [ "$CHECK_ONLY" -eq 1 ]; then
if [ "$BUILD_PROFILE" = "release" ]; then
log "检查后端 (cargo check --release ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
cargo check "${CARGO_BASE_ARGS[@]}" --release "${CARGO_FEATURE_ARGS[@]}"
elif [ "$BUILD_PROFILE" = "fast-release" ]; then
log "检查后端 (cargo check --profile fast-release ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
cargo check "${CARGO_BASE_ARGS[@]}" --profile fast-release "${CARGO_FEATURE_ARGS[@]}"
else
log "检查后端 (cargo check ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
cargo check "${CARGO_BASE_ARGS[@]}" "${CARGO_FEATURE_ARGS[@]}"
fi
ok "后端检查完成"
record_timing "后端检查" "$started"
return 0
fi
if [ "$BUILD_PROFILE" = "release" ]; then
log "构建后端 (cargo build --release ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
cargo build "${CARGO_BASE_ARGS[@]}" --release "${CARGO_FEATURE_ARGS[@]}"
BINARY="$ROOT_DIR/target/release/boosapi"
ok "后端构建完成 → target/release/boosapi"
elif [ "$BUILD_PROFILE" = "fast-release" ]; then
log "构建后端 (cargo build --profile fast-release ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
cargo build "${CARGO_BASE_ARGS[@]}" --profile fast-release "${CARGO_FEATURE_ARGS[@]}"
BINARY="$ROOT_DIR/target/fast-release/boosapi"
ok "后端构建完成 → target/fast-release/boosapi"
else
log "构建后端 (cargo build ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
cargo build "${CARGO_BASE_ARGS[@]}" "${CARGO_FEATURE_ARGS[@]}"
BINARY="$ROOT_DIR/target/debug/boosapi"
ok "后端构建完成 → target/debug/boosapi"
fi
record_timing "后端构建" "$started"
}
build_production_bundle() {
local started
started="$(timer_start)"
[ -n "${BINARY:-}" ] || fail "生产打包失败:后端二进制路径为空"
[ -f "$BINARY" ] || fail "生产打包失败:未找到二进制 $BINARY"
[ -f "$ROOT_DIR/config.toml" ] || fail "生产打包失败:未找到 config.toml"
rm -rf "$PRODUCTION_DIR"
mkdir -p "$PRODUCTION_DIR"
cp "$BINARY" "$PRODUCTION_DIR/boosapi"
cp "$ROOT_DIR/config.toml" "$PRODUCTION_DIR/config.toml"
chmod +x "$PRODUCTION_DIR/boosapi"
if command -v strip >/dev/null 2>&1; then
log "压缩生产二进制符号表 (strip)..."
strip "$PRODUCTION_DIR/boosapi" 2>/dev/null || log "strip 失败,保留未压缩二进制"
fi
ok "生产产物已生成 → $PRODUCTION_DIR"
log "产物内容:"
(cd "$PRODUCTION_DIR" && ls -lh boosapi config.toml)
log "部署时只需要复制该目录内的 boosapi 和 config.toml;主 Web 前端已嵌入二进制。"
record_timing "生产打包" "$started"
}
cleanup_started_server() {
if [ -n "${STARTED_PID:-}" ] && is_running_pid "$STARTED_PID"; then
log "停止本次启动的后端进程: $STARTED_PID"
kill -TERM "$STARTED_PID" 2>/dev/null || true
wait "$STARTED_PID" 2>/dev/null || true
fi
}
# ── 构建 / 启动 ────────────────────────────────────────────────────────────────
if [ "$FRONTEND_ONLY" -eq 1 ]; then
build_frontend
build_public_website
print_timing_summary
log "跳过启动(--frontend-only)"
exit 0
fi
if [ "$BACKEND_ONLY" -ne 1 ]; then
build_frontend
build_public_website
if [ "$PRODUCTION_BUNDLE" -eq 1 ]; then
ensure_workflow_ui_for_embedding
fi
fi
build_backend
if [ "$CHECK_ONLY" -eq 1 ]; then
print_timing_summary
log "跳过启动(--check)"
exit 0
fi
if [ "$PRODUCTION_BUNDLE" -eq 1 ]; then
build_production_bundle
print_timing_summary
exit 0
fi
if [ "$NO_RUN" -eq 1 ]; then
print_timing_summary
log "跳过启动(--no-run)"
exit 0
fi
print_timing_summary
stop_previous_server
log "启动服务..."
if [ "$BACKEND_ONLY" -eq 1 ]; then
exec "$BINARY"
fi
"$BINARY" &
STARTED_PID=$!
trap cleanup_started_server INT TERM EXIT
ok "后端已启动,PID: $STARTED_PID"
ok "前后端已就绪(profile: $BUILD_PROFILE),按 Ctrl+C 停止服务"
wait "$STARTED_PID"
+97 -11
View File
@@ -14,12 +14,95 @@ import os
import requests
# ── Config ───────────────────────────────────────────────────────────────
BOOSAPI_BASE = os.environ.get("BOOSAPI_BASE", "http://127.0.0.1:4000")
BOOSAPI_BASE_RAW = os.environ.get("BOOSAPI_BASE", "http://127.0.0.1:4000")
BOOSAPI_KEY = os.environ.get("BOOSAPI_KEY", "sk-boos4721")
HEADERS = {
"Authorization": f"Bearer {BOOSAPI_KEY}",
"Content-Type": "application/json",
}
FALLBACK_CHAT_MODELS = ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex", "gpt-5.2"]
FALLBACK_IMAGE_MODELS = ["gpt-image-2", "gpt-image-1", "dall-e-3", "dall-e-2"]
FALLBACK_ALL_MODELS = FALLBACK_IMAGE_MODELS + FALLBACK_CHAT_MODELS
def normalize_boosapi_root(base):
base = str(base or "http://127.0.0.1:4000").strip().rstrip("/")
if base.endswith("/v1"):
base = base[:-3].rstrip("/")
return base or "http://127.0.0.1:4000"
BOOSAPI_ROOT = normalize_boosapi_root(BOOSAPI_BASE_RAW)
BOOSAPI_V1 = f"{BOOSAPI_ROOT}/v1"
def raise_for_status_with_body(resp):
try:
resp.raise_for_status()
except requests.HTTPError as e:
body = (resp.text or "").strip()
if body:
print(f"[BoosAPI] HTTP {resp.status_code} response: {body[:2000]}")
raise requests.HTTPError(f"{e}; response body: {body[:1000]}", response=resp)
raise
def _ordered_unique(values):
seen = set()
result = []
for value in values:
value = str(value or "").strip()
if not value or value in seen:
continue
seen.add(value)
result.append(value)
return result
def fetch_boosapi_models():
"""Fetch models exposed by BoosAPI's own /v1/models route."""
try:
resp = requests.get(f"{BOOSAPI_V1}/models", headers=HEADERS, timeout=8)
resp.raise_for_status()
data = resp.json()
models = []
for item in data.get("data", []):
if isinstance(item, dict):
models.append(item.get("id") or item.get("name"))
elif isinstance(item, str):
models.append(item)
return _ordered_unique(models)
except Exception as e:
print(f"[BoosAPI] model list fallback: {e}")
return None
def model_choices(kind="all"):
models = fetch_boosapi_models()
if models is None:
if kind == "image":
return FALLBACK_IMAGE_MODELS
if kind == "chat":
return FALLBACK_CHAT_MODELS
return FALLBACK_ALL_MODELS
if kind == "image":
image_like = [
model for model in models
if any(token in model.lower() for token in ("image", "dall", "flux", "sd", "midjourney"))
]
return _ordered_unique(image_like + models) or FALLBACK_IMAGE_MODELS
if kind == "chat":
chat_like = [
model for model in models
if not any(token in model.lower() for token in ("image", "dall", "flux", "sd", "midjourney"))
]
return _ordered_unique(chat_like + models) or FALLBACK_CHAT_MODELS
return _ordered_unique(models) or FALLBACK_ALL_MODELS
def default_model(kind="all"):
choices = model_choices(kind)
return choices[0] if choices else FALLBACK_ALL_MODELS[0]
# ── Node Mappings ────────────────────────────────────────────────────────
NODE_CLASS_MAPPINGS = {}
@@ -37,7 +120,7 @@ class BoosAPIImageGen:
return {
"required": {
"prompt": ("STRING", {"multiline": True, "default": ""}),
"model": (["dall-e-3", "dall-e-2", "gpt-image-2"], {"default": "gpt-image-2"}),
"model": (model_choices("image"), {"default": default_model("image")}),
"size": (["1024x1024", "1792x1024", "1024x1792"], {"default": "1024x1024"}),
"quality": (["standard", "hd"], {"default": "standard"}),
"n": ("INT", {"default": 1, "min": 1, "max": 4}),
@@ -50,10 +133,11 @@ class BoosAPIImageGen:
RETURN_TYPES = ("IMAGE", "STRING")
RETURN_NAMES = ("image", "metadata")
FUNCTION = "generate"
OUTPUT_NODE = True
CATEGORY = "BoosAPI"
def generate(self, prompt, model, size, quality, n, style="vivid"):
url = f"{BOOSAPI_BASE}/v1/images/generations"
url = f"{BOOSAPI_V1}/images/generations"
payload = {
"model": model,
"prompt": prompt,
@@ -65,7 +149,7 @@ class BoosAPIImageGen:
}
try:
resp = requests.post(url, headers=HEADERS, json=payload, timeout=120)
resp.raise_for_status()
raise_for_status_with_body(resp)
data = resp.json()
images = data.get("data", [])
# Return first image as b64
@@ -104,7 +188,7 @@ class BoosAPILLM:
return {
"required": {
"prompt": ("STRING", {"multiline": True, "default": ""}),
"model": ("STRING", {"default": "gpt-5.5"}),
"model": (model_choices("chat"), {"default": default_model("chat")}),
"system_prompt": ("STRING", {"multiline": True, "default": ""}),
"temperature": ("FLOAT", {"default": 0.7, "min": 0.0, "max": 2.0}),
"max_tokens": ("INT", {"default": 2048, "min": 1, "max": 16384}),
@@ -114,10 +198,11 @@ class BoosAPILLM:
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("text",)
FUNCTION = "chat"
OUTPUT_NODE = True
CATEGORY = "BoosAPI"
def chat(self, prompt, model, system_prompt, temperature, max_tokens):
url = f"{BOOSAPI_BASE}/v1/chat/completions"
url = f"{BOOSAPI_V1}/chat/completions"
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
@@ -132,10 +217,10 @@ class BoosAPILLM:
}
try:
resp = requests.post(url, headers=HEADERS, json=payload, timeout=120)
resp.raise_for_status()
raise_for_status_with_body(resp)
data = resp.json()
text = data["choices"][0]["message"]["content"]
return (text,)
return {"ui": {"text": (text,)}, "result": (text,)}
except Exception as e:
print(f"[BoosAPI] LLM chat failed: {e}")
raise
@@ -153,7 +238,7 @@ class BoosAPITextToImage:
return {
"required": {
"positive": ("STRING", {"multiline": True, "default": ""}),
"model": (["gpt-image-2", "dall-e-3"], {"default": "gpt-image-2"}),
"model": (model_choices("image"), {"default": default_model("image")}),
},
"optional": {
"negative": ("STRING", {"multiline": True, "default": ""}),
@@ -163,6 +248,7 @@ class BoosAPITextToImage:
RETURN_TYPES = ("IMAGE",)
FUNCTION = "generate"
OUTPUT_NODE = True
CATEGORY = "BoosAPI"
def generate(self, positive, model, negative="", size="1024x1024"):
@@ -171,7 +257,7 @@ class BoosAPITextToImage:
if negative:
prompt = f"{positive}\n避免: {negative}"
url = f"{BOOSAPI_BASE}/v1/images/generations"
url = f"{BOOSAPI_V1}/images/generations"
payload = {
"model": model,
"prompt": prompt,
@@ -181,7 +267,7 @@ class BoosAPITextToImage:
}
try:
resp = requests.post(url, headers=HEADERS, json=payload, timeout=120)
resp.raise_for_status()
raise_for_status_with_body(resp)
data = resp.json()
import base64
+55 -1
View File
@@ -8,11 +8,16 @@ cors_origins = ["*"]
url = "sqlite:boosapi.db?mode=rwc"
max_connections = 10
ssl = false
# PostgreSQL preparation example:
# url = "postgres://boosapi:password@127.0.0.1:5432/boosapi"
# Note: this build can create/migrate PostgreSQL schemas and copy SQLite data
# with `boosapi migrate-sqlite-to-postgres`; full runtime query compatibility
# is completed in a follow-up SQL dialect pass.
[auth]
jwt_secret = "boosapi-dev-secret-change-in-production"
jwt_expiry_hours = 72
auth_token = "change-me-admin-token"
auth_token = "boos4721"
proxy_token = "sk-boos4721"
[proxy]
@@ -20,3 +25,52 @@ upstream_timeout_secs = 300
first_byte_timeout_secs = 60
max_retries = 3
proxy_log_retention_days = 30
# Optional proxy for external template galleries/workflow template assets.
# Supports http://, https:// and socks5:// proxy URLs.
# template_proxy_url = "http://127.0.0.1:7890"
[payment]
enabled = false
# Public HTTPS origin used to build Stripe success/cancel URLs and webhook docs.
# Example: "https://tybossai.com"
public_base_url = ""
product_name_prefix = "BossAI 账户充值"
currency = "usd"
min_amount_usd = 1.0
max_amount_usd = 100000.0
order_timeout_minutes = 30
max_pending_orders = 3
success_path = "/user/recharge?payment=success&order={ORDER_ID}"
cancel_path = "/user/recharge?payment=cancel&order={ORDER_ID}"
# Prefer environment variables for secrets:
# BOOSAPI_PAYMENT__STRIPE_SECRET_KEY=sk_live_...
# BOOSAPI_PAYMENT__STRIPE_WEBHOOK_SECRET=whsec_...
stripe_secret_key = ""
stripe_webhook_secret = ""
[comfyui]
enabled = false
python_bin = "python3"
port = 8188
extra_args = []
max_restarts = 5
# backend modes:
# - "boosapi": use BoosAPI's own Rust Workflow/API node executor.
# - "cloud": proxy to Comfy Cloud and use native ComfyUI /prompt and /ws.
# - "runninghub": proxy to RunningHub's ComfyUI-compatible API with external-compatible frontend.
# - "external": proxy to your own full ComfyUI server.
# - "docker": legacy local Docker ComfyUI mode; not recommended for the current API Workflow path.
backend = "cloud"
# Native Comfy task costs are charged against the user's downstream API Key
# only after successful output archival. Keep 0 while pricing is not finalized.
money_printer_video_cost = 0.0
digital_human_video_cost = 0.0
# docker_image = "boosapi-comfyui:multi-tenant"
# container_name = "boosapi-comfyui-workflow"
# base_url = "https://www.runninghub.cn/proxy-plus/94d1330ea62d4ae980ffb018ecdf82a2"
# For RunningHub, set backend = "runninghub" and use your RunningHub proxy/API URL.
# RunningHub proxy URL already contains the API key. Keep this empty unless
# you switch to a backend that requires Authorization / X-API-Key headers.
base_url = "https://cloud.comfy.org"
api_key = "comfyui-cbe4ebb1e22ee4ef2dab4d9293a76d5272a25e5ec847ac57387ab2b30798d86f"
agent_model = "gpt-5.5"
-27
View File
@@ -1,27 +0,0 @@
FROM python:3.11-slim
WORKDIR /workspace
# Install system deps
RUN apt-get update && apt-get install -y --no-install-recommends \
git curl \
&& rm -rf /var/lib/apt/lists/*
# Clone ComfyUI
RUN git clone https://github.com/comfyanonymous/ComfyUI.git /workspace/ComfyUI
WORKDIR /workspace/ComfyUI
# Python venv + deps (CPU-only, generation goes through BoosAPI)
RUN python3 -m venv venv && \
. venv/bin/activate && \
pip install --upgrade pip && \
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu && \
grep -v kornia_rs requirements.txt | pip install -r /dev/stdin && \
rm -rf ~/.cache/pip
ENV PATH="/workspace/ComfyUI/venv/bin:${PATH}"
EXPOSE 8188
CMD ["python3", "main.py", "--listen", "0.0.0.0", "--port", "8188", "--cpu"]
+33
View File
@@ -0,0 +1,33 @@
FROM boosapi-comfyui:multi-tenant
ARG COMFYUI_TAG=v0.22.2
WORKDIR /workspace/ComfyUI
COPY docker/build-comfyui-src /tmp/ComfyUI-updated
RUN find /workspace/ComfyUI -mindepth 1 \
! -path '/workspace/ComfyUI/venv' \
! -path '/workspace/ComfyUI/venv/*' \
-exec rm -rf {} + \
&& cp -a /tmp/ComfyUI-updated/. /workspace/ComfyUI/ \
&& rm -rf /tmp/ComfyUI-updated \
&& UV_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple \
UV_EXTRA_INDEX_URL=https://mirrors.tuna.tsinghua.edu.cn/pytorch-wheels/cpu/ \
uv pip install --python venv/bin/python -r requirements.txt \
&& UV_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple \
uv pip install --python venv/bin/python six \
&& mkdir -p /workspace/ComfyUI/custom_nodes/boosapi_nodes
COPY comfy/boosapi_comfy_nodes.py /workspace/ComfyUI/custom_nodes/boosapi_nodes/__init__.py
COPY scripts/comfyui/install-runninghub-template-nodes.sh /tmp/install-runninghub-template-nodes.sh
RUN bash /tmp/install-runninghub-template-nodes.sh \
&& rm -f /tmp/install-runninghub-template-nodes.sh
RUN python - <<'PY'
from pathlib import Path
path = Path("/workspace/ComfyUI/custom_nodes/boosapi_nodes/__init__.py")
if not path.exists():
raise SystemExit("BoosAPI custom node missing after ComfyUI update")
PY
-22
View File
@@ -1,22 +0,0 @@
# docker-compose.comfyui.yml
# 开发环境:在 BoosAPI 旁边运行 ComfyUI
# 使用: docker compose -f docker/docker-compose.yml -f docker/docker-compose.comfyui.yml up -d
services:
comfyui:
build:
context: ..
dockerfile: docker/Dockerfile.comfyui
ports:
- "127.0.0.1:8188:8188"
volumes:
- comfyui-data:/workspace
- ../boosapi_comfy_nodes.py:/workspace/ComfyUI/custom_nodes/boosapi_comfy_nodes.py
environment:
- BOOSAPI_BASE=http://host.docker.internal:4000/v1
- BOOSAPI_KEY=${BOOSAPI_KEY:-sk-demo-user}
restart: unless-stopped
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
comfyui-data:
-16
View File
@@ -1,16 +0,0 @@
services:
boosapi:
image: 1467078763/boosapi:latest
ports:
- "127.0.0.1:4000:4000"
volumes:
- ./data:/app/data
environment:
AUTH_TOKEN: ${AUTH_TOKEN:?AUTH_TOKEN is required}
PROXY_TOKEN: ${PROXY_TOKEN:?PROXY_TOKEN is required}
CHECKIN_CRON: "0 8 * * *"
BALANCE_REFRESH_CRON: "0 * * * *"
PORT: ${PORT:-4000}
DATA_DIR: /app/data
TZ: ${TZ:-Asia/Shanghai}
restart: unless-stopped
@@ -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 风格。
-42
View File
@@ -1,42 +0,0 @@
appId: me.cita777.boosapi.desktop
productName: Metapi
artifactName: ${name}-${version}-${os}-${arch}.${ext}
directories:
output: release
electronDist: node_modules/electron/dist
icon: build/desktop-icon.png
asar: false
files:
- dist/**/*
- drizzle/**/*
- package.json
mac:
target:
- dmg
- zip
category: public.app-category.developer-tools
hardenedRuntime: false
gatekeeperAssess: false
entitlementsInherit: null
win:
target:
- nsis
- zip
linux:
target:
- AppImage
- deb
- rpm
maintainer: cita-777 <cita-777@users.noreply.github.com>
publish:
provider: github
owner: cita-777
repo: boosapi
releaseType: release
publishAutoUpdate: true
nsis:
oneClick: false
perMachine: false
allowToChangeInstallationDirectory: true
afterPack: scripts/desktop/afterPack.mjs
afterSign: scripts/desktop/afterSign.mjs
@@ -0,0 +1,33 @@
-- Admin-authored announcements for normal users.
CREATE TABLE IF NOT EXISTS user_announcements (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL,
level TEXT NOT NULL DEFAULT 'info',
status TEXT NOT NULL DEFAULT 'published',
audience TEXT NOT NULL DEFAULT 'all',
starts_at TEXT,
ends_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
published_at TEXT
);
CREATE INDEX IF NOT EXISTS user_announcements_status_created_at_idx
ON user_announcements (status, created_at);
CREATE INDEX IF NOT EXISTS user_announcements_active_window_idx
ON user_announcements (starts_at, ends_at);
CREATE TABLE IF NOT EXISTS user_announcement_reads (
announcement_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
read_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (announcement_id, user_id),
FOREIGN KEY (announcement_id) REFERENCES user_announcements(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS user_announcement_reads_user_id_idx
ON user_announcement_reads (user_id);
@@ -0,0 +1,34 @@
-- Workspace assets: local archive for creative outputs from Picture Agent,
-- ComfyUI Agent, Video Agent and ComfyUI workspaces.
CREATE TABLE IF NOT EXISTS workspace_assets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_id TEXT NOT NULL UNIQUE,
user_id INTEGER,
source TEXT NOT NULL,
kind TEXT NOT NULL,
title TEXT,
prompt TEXT,
model TEXT,
status TEXT NOT NULL DEFAULT 'completed',
mime_type TEXT,
file_name TEXT,
file_path TEXT,
remote_url TEXT,
thumbnail_path TEXT,
size_bytes INTEGER NOT NULL DEFAULT 0,
metadata TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_workspace_assets_user_created
ON workspace_assets(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_workspace_assets_source_created
ON workspace_assets(source, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_workspace_assets_kind_created
ON workspace_assets(kind, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_workspace_assets_deleted
ON workspace_assets(deleted_at);
@@ -0,0 +1,64 @@
-- BoosAPI Workflow Studio core tables.
CREATE TABLE IF NOT EXISTS workflows (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_id TEXT NOT NULL UNIQUE,
user_id INTEGER,
name TEXT NOT NULL,
description TEXT,
graph TEXT NOT NULL DEFAULT '{}',
is_template INTEGER NOT NULL DEFAULT 0,
visibility TEXT NOT NULL DEFAULT 'private',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_workflows_user_updated
ON workflows(user_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_workflows_visibility
ON workflows(visibility);
CREATE INDEX IF NOT EXISTS idx_workflows_deleted
ON workflows(deleted_at);
CREATE TABLE IF NOT EXISTS workflow_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_id TEXT NOT NULL UNIQUE,
workflow_id INTEGER NOT NULL,
user_id INTEGER,
status TEXT NOT NULL,
input_overrides TEXT NOT NULL DEFAULT '{}',
final_outputs TEXT,
error_message TEXT,
started_at TEXT,
completed_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY(workflow_id) REFERENCES workflows(id) ON DELETE CASCADE,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_workflow_runs_workflow_created
ON workflow_runs(workflow_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_workflow_runs_user_created
ON workflow_runs(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_workflow_runs_status
ON workflow_runs(status);
CREATE TABLE IF NOT EXISTS workflow_node_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER NOT NULL,
node_id TEXT NOT NULL,
node_type TEXT NOT NULL,
status TEXT NOT NULL,
inputs TEXT NOT NULL DEFAULT '{}',
outputs TEXT,
error_message TEXT,
started_at TEXT,
completed_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY(run_id) REFERENCES workflow_runs(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_workflow_node_runs_run
ON workflow_node_runs(run_id, id);
@@ -0,0 +1,3 @@
ALTER TABLE proxy_logs ADD COLUMN content_summary TEXT;
ALTER TABLE proxy_logs ADD COLUMN request_content TEXT;
ALTER TABLE proxy_logs ADD COLUMN response_content TEXT;
@@ -0,0 +1,51 @@
-- Performance-oriented projections and indexes for logs/workspace views.
CREATE INDEX IF NOT EXISTS proxy_logs_key_created_status_idx
ON proxy_logs (downstream_api_key_id, created_at DESC, status);
CREATE INDEX IF NOT EXISTS proxy_logs_key_model_created_idx
ON proxy_logs (downstream_api_key_id, model_actual, created_at DESC);
CREATE INDEX IF NOT EXISTS proxy_logs_content_summary_idx
ON proxy_logs (content_summary);
CREATE INDEX IF NOT EXISTS workspace_assets_user_output_created_idx
ON workspace_assets (
user_id,
status,
deleted_at,
source,
kind,
created_at DESC
);
CREATE INDEX IF NOT EXISTS workspace_assets_output_role_created_idx
ON workspace_assets (
json_extract(metadata, '$.workspaceAsset.role'),
created_at DESC
);
CREATE INDEX IF NOT EXISTS workspace_assets_workflow_output_created_idx
ON workspace_assets (
json_extract(metadata, '$.workflowUi.type'),
created_at DESC
);
CREATE TABLE IF NOT EXISTS workspace_asset_daily (
local_day TEXT NOT NULL,
user_id INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL,
kind TEXT NOT NULL,
asset_count INTEGER NOT NULL DEFAULT 0,
local_file_count INTEGER NOT NULL DEFAULT 0,
total_size_bytes INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(local_day, user_id, source, kind)
);
CREATE INDEX IF NOT EXISTS workspace_asset_daily_user_day_idx
ON workspace_asset_daily (user_id, local_day DESC);
CREATE INDEX IF NOT EXISTS workspace_asset_daily_source_day_idx
ON workspace_asset_daily (source, local_day DESC);
@@ -0,0 +1,27 @@
CREATE TABLE IF NOT EXISTS user_recharge_records (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
public_id TEXT NOT NULL,
user_id INTEGER NOT NULL,
option_id TEXT,
label TEXT,
title TEXT,
amount_usd REAL,
provider TEXT NOT NULL DEFAULT 'comfy_checkout',
checkout_url TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
note TEXT,
metadata TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
completed_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS user_recharge_records_public_id_unique
ON user_recharge_records (public_id);
CREATE INDEX IF NOT EXISTS user_recharge_records_user_created_idx
ON user_recharge_records (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS user_recharge_records_user_status_idx
ON user_recharge_records (user_id, status);
@@ -0,0 +1,39 @@
CREATE TABLE IF NOT EXISTS payment_orders (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
public_id TEXT NOT NULL,
user_id INTEGER NOT NULL,
recharge_record_id INTEGER,
provider TEXT NOT NULL,
payment_method TEXT NOT NULL DEFAULT 'stripe',
provider_session_id TEXT,
provider_payment_id TEXT,
amount_usd REAL NOT NULL,
amount_minor INTEGER NOT NULL,
currency TEXT NOT NULL DEFAULT 'usd',
status TEXT NOT NULL DEFAULT 'pending',
checkout_url TEXT,
success_url TEXT,
cancel_url TEXT,
note TEXT,
metadata TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
paid_at TEXT,
completed_at TEXT,
expires_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (recharge_record_id) REFERENCES user_recharge_records(id) ON DELETE SET NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS payment_orders_public_id_unique
ON payment_orders (public_id);
CREATE UNIQUE INDEX IF NOT EXISTS payment_orders_provider_session_unique
ON payment_orders (provider, provider_session_id)
WHERE provider_session_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS payment_orders_user_created_idx
ON payment_orders (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS payment_orders_status_idx
ON payment_orders (status);
+868
View File
@@ -0,0 +1,868 @@
-- BoosAPI initial schema
-- Generated from the existing SQLite database (hub.db)
-- This migration creates all tables, indexes, and constraints.
-- ============================================================
-- SITES
-- ============================================================
CREATE TABLE IF NOT EXISTS sites (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
url TEXT NOT NULL,
platform TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
api_key TEXT,
proxy_url TEXT,
use_system_proxy BOOLEAN DEFAULT FALSE,
custom_headers TEXT,
external_checkin_url TEXT,
global_weight DOUBLE PRECISION DEFAULT 1,
post_refresh_probe_enabled BOOLEAN DEFAULT FALSE,
post_refresh_probe_model TEXT DEFAULT '',
post_refresh_probe_scope TEXT DEFAULT 'single',
post_refresh_probe_latency_threshold_ms BIGINT DEFAULT 0,
is_pinned BOOLEAN DEFAULT FALSE,
sort_order BIGINT DEFAULT 0,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE UNIQUE INDEX IF NOT EXISTS sites_platform_url_unique
ON sites (platform, url);
CREATE INDEX IF NOT EXISTS sites_status_idx
ON sites (status);
-- ============================================================
-- USERS
-- ============================================================
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
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 DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE UNIQUE INDEX IF NOT EXISTS users_email_unique
ON users (email);
CREATE INDEX IF NOT EXISTS users_status_idx
ON users (status);
-- ============================================================
-- TOKEN ROUTES
-- ============================================================
CREATE TABLE IF NOT EXISTS token_routes (
id BIGSERIAL PRIMARY KEY,
model_pattern TEXT NOT NULL,
model_mapping TEXT,
display_name TEXT,
display_icon TEXT,
decision_snapshot TEXT,
decision_refreshed_at TEXT,
routing_strategy TEXT DEFAULT 'weighted',
route_mode TEXT DEFAULT 'pattern',
enabled BOOLEAN DEFAULT TRUE,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE INDEX IF NOT EXISTS token_routes_model_pattern_idx
ON token_routes (model_pattern);
CREATE INDEX IF NOT EXISTS token_routes_enabled_idx
ON token_routes (enabled);
-- ============================================================
-- ACCOUNTS
-- ============================================================
CREATE TABLE IF NOT EXISTS accounts (
id BIGSERIAL PRIMARY KEY,
site_id BIGINT NOT NULL,
username TEXT,
access_token TEXT NOT NULL,
api_token TEXT,
balance DOUBLE PRECISION DEFAULT 0,
balance_used DOUBLE PRECISION DEFAULT 0,
quota DOUBLE PRECISION DEFAULT 0,
unit_cost DOUBLE PRECISION,
value_score DOUBLE PRECISION DEFAULT 0,
status TEXT DEFAULT 'active',
checkin_enabled BOOLEAN DEFAULT TRUE,
last_checkin_at TEXT,
last_balance_refresh TEXT,
extra_config TEXT,
is_pinned BOOLEAN DEFAULT FALSE,
sort_order BIGINT DEFAULT 0,
oauth_provider TEXT,
oauth_account_key TEXT,
oauth_project_id TEXT,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS accounts_site_id_idx
ON accounts (site_id);
CREATE INDEX IF NOT EXISTS accounts_status_idx
ON accounts (status);
CREATE INDEX IF NOT EXISTS accounts_site_status_idx
ON accounts (site_id, status);
CREATE INDEX IF NOT EXISTS accounts_oauth_provider_idx
ON accounts (oauth_provider);
CREATE INDEX IF NOT EXISTS accounts_oauth_identity_idx
ON accounts (oauth_provider, oauth_account_key, oauth_project_id);
-- ============================================================
-- DOWNSTREAM API KEYS
-- ============================================================
CREATE TABLE IF NOT EXISTS downstream_api_keys (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
key TEXT NOT NULL,
description TEXT,
group_name TEXT,
tags TEXT,
enabled BOOLEAN DEFAULT TRUE,
expires_at TEXT,
max_cost DOUBLE PRECISION,
used_cost DOUBLE PRECISION DEFAULT 0,
max_requests BIGINT,
used_requests BIGINT DEFAULT 0,
supported_models TEXT,
allowed_route_ids TEXT,
site_weight_multipliers TEXT,
excluded_site_ids TEXT,
excluded_credential_refs TEXT,
last_used_at TEXT,
user_id BIGINT REFERENCES users(id),
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE UNIQUE INDEX IF NOT EXISTS downstream_api_keys_key_unique
ON downstream_api_keys (key);
CREATE INDEX IF NOT EXISTS downstream_api_keys_name_idx
ON downstream_api_keys (name);
CREATE INDEX IF NOT EXISTS downstream_api_keys_enabled_idx
ON downstream_api_keys (enabled);
CREATE INDEX IF NOT EXISTS downstream_api_keys_expires_at_idx
ON downstream_api_keys (expires_at);
-- ============================================================
-- SITE DISABLED MODELS
-- ============================================================
CREATE TABLE IF NOT EXISTS site_disabled_models (
id BIGSERIAL PRIMARY KEY,
site_id BIGINT NOT NULL,
model_name TEXT NOT NULL,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS site_disabled_models_site_model_unique
ON site_disabled_models (site_id, model_name);
CREATE INDEX IF NOT EXISTS site_disabled_models_site_id_idx
ON site_disabled_models (site_id);
-- ============================================================
-- SITE API ENDPOINTS
-- ============================================================
CREATE TABLE IF NOT EXISTS site_api_endpoints (
id BIGSERIAL PRIMARY KEY,
site_id BIGINT NOT NULL,
url TEXT NOT NULL,
enabled BOOLEAN DEFAULT TRUE,
sort_order BIGINT DEFAULT 0,
cooldown_until TEXT,
last_selected_at TEXT,
last_failed_at TEXT,
last_failure_reason TEXT,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS site_api_endpoints_site_url_unique
ON site_api_endpoints (site_id, url);
CREATE INDEX IF NOT EXISTS site_api_endpoints_site_enabled_sort_idx
ON site_api_endpoints (site_id, enabled, sort_order);
CREATE INDEX IF NOT EXISTS site_api_endpoints_site_cooldown_idx
ON site_api_endpoints (site_id, cooldown_until);
-- ============================================================
-- ACCOUNT TOKENS
-- ============================================================
CREATE TABLE IF NOT EXISTS account_tokens (
id BIGSERIAL PRIMARY KEY,
account_id BIGINT NOT NULL,
name TEXT NOT NULL,
token TEXT NOT NULL,
source TEXT DEFAULT 'manual',
token_group TEXT,
value_status TEXT NOT NULL DEFAULT 'ready',
enabled BOOLEAN DEFAULT TRUE,
is_default BOOLEAN DEFAULT FALSE,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS account_tokens_account_id_idx
ON account_tokens (account_id);
CREATE INDEX IF NOT EXISTS account_tokens_account_enabled_idx
ON account_tokens (account_id, enabled);
CREATE INDEX IF NOT EXISTS account_tokens_enabled_idx
ON account_tokens (enabled);
-- ============================================================
-- CHECKIN LOGS
-- ============================================================
CREATE TABLE IF NOT EXISTS checkin_logs (
id BIGSERIAL PRIMARY KEY,
account_id BIGINT NOT NULL,
status TEXT NOT NULL,
message TEXT,
reward TEXT,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS checkin_logs_account_created_at_idx
ON checkin_logs (account_id, created_at);
CREATE INDEX IF NOT EXISTS checkin_logs_created_at_idx
ON checkin_logs (created_at);
CREATE INDEX IF NOT EXISTS checkin_logs_status_idx
ON checkin_logs (status);
-- ============================================================
-- MODEL AVAILABILITY
-- ============================================================
CREATE TABLE IF NOT EXISTS model_availability (
id BIGSERIAL PRIMARY KEY,
account_id BIGINT NOT NULL,
model_name TEXT NOT NULL,
available BIGINT,
latency_ms BIGINT,
checked_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
is_manual BOOLEAN DEFAULT FALSE,
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS model_availability_account_model_unique
ON model_availability (account_id, model_name);
CREATE INDEX IF NOT EXISTS model_availability_account_available_idx
ON model_availability (account_id, available);
CREATE INDEX IF NOT EXISTS model_availability_model_name_idx
ON model_availability (model_name);
-- ============================================================
-- TOKEN MODEL AVAILABILITY
-- ============================================================
CREATE TABLE IF NOT EXISTS token_model_availability (
id BIGSERIAL PRIMARY KEY,
token_id BIGINT NOT NULL,
model_name TEXT NOT NULL,
available BIGINT,
latency_ms BIGINT,
checked_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (token_id) REFERENCES account_tokens(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS token_model_availability_token_model_unique
ON token_model_availability (token_id, model_name);
CREATE INDEX IF NOT EXISTS token_model_availability_token_available_idx
ON token_model_availability (token_id, available);
CREATE INDEX IF NOT EXISTS token_model_availability_model_name_idx
ON token_model_availability (model_name);
CREATE INDEX IF NOT EXISTS token_model_availability_available_idx
ON token_model_availability (available);
-- ============================================================
-- ROUTE CHANNELS
-- ============================================================
CREATE TABLE IF NOT EXISTS route_channels (
id BIGSERIAL PRIMARY KEY,
route_id BIGINT NOT NULL,
account_id BIGINT NOT NULL,
token_id BIGINT,
oauth_route_unit_id BIGINT,
source_model TEXT,
priority BIGINT DEFAULT 0,
weight BIGINT DEFAULT 10,
enabled BOOLEAN DEFAULT TRUE,
manual_override BOOLEAN DEFAULT FALSE,
success_count BIGINT DEFAULT 0,
fail_count BIGINT DEFAULT 0,
total_latency_ms BIGINT DEFAULT 0,
total_cost DOUBLE PRECISION DEFAULT 0,
last_used_at TEXT,
last_selected_at TEXT,
last_fail_at TEXT,
consecutive_fail_count BIGINT NOT NULL DEFAULT 0,
cooldown_level BIGINT NOT NULL DEFAULT 0,
cooldown_until TEXT,
FOREIGN KEY (route_id) REFERENCES token_routes(id) ON DELETE CASCADE,
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE,
FOREIGN KEY (token_id) REFERENCES account_tokens(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS route_channels_route_id_idx
ON route_channels (route_id);
CREATE INDEX IF NOT EXISTS route_channels_account_id_idx
ON route_channels (account_id);
CREATE INDEX IF NOT EXISTS route_channels_token_id_idx
ON route_channels (token_id);
CREATE INDEX IF NOT EXISTS route_channels_route_enabled_idx
ON route_channels (route_id, enabled);
CREATE INDEX IF NOT EXISTS route_channels_route_token_idx
ON route_channels (route_id, token_id);
CREATE INDEX IF NOT EXISTS route_channels_oauth_route_unit_id_idx
ON route_channels (oauth_route_unit_id);
-- ============================================================
-- ROUTE GROUP SOURCES
-- ============================================================
CREATE TABLE IF NOT EXISTS route_group_sources (
id BIGSERIAL PRIMARY KEY,
group_route_id BIGINT NOT NULL,
source_route_id BIGINT NOT NULL,
FOREIGN KEY (group_route_id) REFERENCES token_routes(id) ON DELETE CASCADE,
FOREIGN KEY (source_route_id) REFERENCES token_routes(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS route_group_sources_group_source_unique
ON route_group_sources (group_route_id, source_route_id);
CREATE INDEX IF NOT EXISTS route_group_sources_source_route_id_idx
ON route_group_sources (source_route_id);
-- ============================================================
-- PROXY LOGS
-- ============================================================
CREATE TABLE IF NOT EXISTS proxy_logs (
id BIGSERIAL PRIMARY KEY,
route_id BIGINT,
channel_id BIGINT,
account_id BIGINT,
downstream_api_key_id BIGINT,
model_requested TEXT,
model_actual TEXT,
status TEXT,
http_status BIGINT,
latency_ms BIGINT,
first_byte_latency_ms BIGINT,
prompt_tokens BIGINT,
completion_tokens BIGINT,
total_tokens BIGINT,
estimated_cost DOUBLE PRECISION,
error_message TEXT,
retry_count BIGINT DEFAULT 0,
is_stream BIGINT,
client_family TEXT,
client_app_id TEXT,
client_app_name TEXT,
client_confidence TEXT,
billing_details TEXT,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE INDEX IF NOT EXISTS proxy_logs_created_at_idx
ON proxy_logs (created_at);
CREATE INDEX IF NOT EXISTS proxy_logs_account_created_at_idx
ON proxy_logs (account_id, created_at);
CREATE INDEX IF NOT EXISTS proxy_logs_status_created_at_idx
ON proxy_logs (status, created_at);
CREATE INDEX IF NOT EXISTS proxy_logs_model_actual_created_at_idx
ON proxy_logs (model_actual, created_at);
CREATE INDEX IF NOT EXISTS proxy_logs_downstream_api_key_created_at_idx
ON proxy_logs (downstream_api_key_id, created_at);
CREATE INDEX IF NOT EXISTS proxy_logs_client_app_id_created_at_idx
ON proxy_logs (client_app_id, created_at);
CREATE INDEX IF NOT EXISTS proxy_logs_client_family_created_at_idx
ON proxy_logs (client_family, created_at);
-- ============================================================
-- PROXY VIDEO TASKS
-- ============================================================
CREATE TABLE IF NOT EXISTS proxy_video_tasks (
id BIGSERIAL PRIMARY KEY,
public_id TEXT NOT NULL,
upstream_video_id TEXT NOT NULL,
site_url TEXT NOT NULL,
token_value TEXT NOT NULL,
requested_model TEXT,
actual_model TEXT,
channel_id BIGINT,
account_id BIGINT,
status_snapshot TEXT,
upstream_response_meta TEXT,
last_upstream_status BIGINT,
last_polled_at TEXT,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE UNIQUE INDEX IF NOT EXISTS proxy_video_tasks_public_id_unique
ON proxy_video_tasks (public_id);
CREATE INDEX IF NOT EXISTS proxy_video_tasks_upstream_video_id_idx
ON proxy_video_tasks (upstream_video_id);
CREATE INDEX IF NOT EXISTS proxy_video_tasks_created_at_idx
ON proxy_video_tasks (created_at);
-- ============================================================
-- PROXY FILES
-- ============================================================
CREATE TABLE IF NOT EXISTS proxy_files (
id BIGSERIAL PRIMARY KEY,
public_id TEXT NOT NULL,
owner_type TEXT NOT NULL,
owner_id TEXT NOT NULL,
filename TEXT NOT NULL,
mime_type TEXT NOT NULL,
purpose TEXT,
byte_size BIGINT NOT NULL,
sha256 TEXT NOT NULL,
content_base64 TEXT NOT NULL,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
deleted_at TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS proxy_files_public_id_unique
ON proxy_files (public_id);
CREATE INDEX IF NOT EXISTS proxy_files_owner_lookup_idx
ON proxy_files (owner_type, owner_id, deleted_at);
-- ============================================================
-- PROXY DEBUG TRACES
-- ============================================================
CREATE TABLE IF NOT EXISTS proxy_debug_traces (
id BIGSERIAL PRIMARY KEY,
downstream_path TEXT NOT NULL,
client_kind TEXT,
session_id TEXT,
trace_hint TEXT,
requested_model TEXT,
downstream_api_key_id BIGINT,
request_headers_json TEXT,
request_body_json TEXT,
sticky_session_key TEXT,
sticky_hit_channel_id BIGINT,
selected_channel_id BIGINT,
selected_route_id BIGINT,
selected_account_id BIGINT,
selected_site_id BIGINT,
selected_site_platform TEXT,
endpoint_candidates_json TEXT,
endpoint_runtime_state_json TEXT,
decision_summary_json TEXT,
final_status TEXT,
final_http_status BIGINT,
final_upstream_path TEXT,
final_response_headers_json TEXT,
final_response_body_json TEXT,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE INDEX IF NOT EXISTS proxy_debug_traces_created_at_idx
ON proxy_debug_traces (created_at);
CREATE INDEX IF NOT EXISTS proxy_debug_traces_session_created_at_idx
ON proxy_debug_traces (session_id, created_at);
CREATE INDEX IF NOT EXISTS proxy_debug_traces_model_created_at_idx
ON proxy_debug_traces (requested_model, created_at);
CREATE INDEX IF NOT EXISTS proxy_debug_traces_final_status_created_at_idx
ON proxy_debug_traces (final_status, created_at);
-- ============================================================
-- PROXY DEBUG ATTEMPTS
-- ============================================================
CREATE TABLE IF NOT EXISTS proxy_debug_attempts (
id BIGSERIAL PRIMARY KEY,
trace_id BIGINT NOT NULL,
attempt_index BIGINT NOT NULL,
endpoint TEXT NOT NULL,
request_path TEXT NOT NULL,
target_url TEXT NOT NULL,
runtime_executor TEXT,
request_headers_json TEXT,
request_body_json TEXT,
response_status BIGINT,
response_headers_json TEXT,
response_body_json TEXT,
raw_error_text TEXT,
recover_applied BOOLEAN DEFAULT FALSE,
downgrade_decision BOOLEAN DEFAULT FALSE,
downgrade_reason TEXT,
memory_write_json TEXT,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (trace_id) REFERENCES proxy_debug_traces(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS proxy_debug_attempts_trace_attempt_unique
ON proxy_debug_attempts (trace_id, attempt_index);
CREATE INDEX IF NOT EXISTS proxy_debug_attempts_trace_created_at_idx
ON proxy_debug_attempts (trace_id, created_at);
-- ============================================================
-- EVENTS
-- ============================================================
CREATE TABLE IF NOT EXISTS events (
id BIGSERIAL PRIMARY KEY,
type TEXT NOT NULL,
title TEXT NOT NULL,
message TEXT,
level TEXT NOT NULL DEFAULT 'info',
"read" BOOLEAN DEFAULT FALSE,
related_id BIGINT,
related_type TEXT,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE INDEX IF NOT EXISTS events_read_created_at_idx
ON events ("read", created_at);
CREATE INDEX IF NOT EXISTS events_type_created_at_idx
ON events (type, created_at);
CREATE INDEX IF NOT EXISTS events_created_at_idx
ON events (created_at);
-- ============================================================
-- SITE ANNOUNCEMENTS
-- ============================================================
CREATE TABLE IF NOT EXISTS site_announcements (
id BIGSERIAL PRIMARY KEY,
site_id BIGINT NOT NULL,
platform TEXT NOT NULL,
source_key TEXT NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL,
level TEXT NOT NULL DEFAULT 'info',
source_url TEXT,
starts_at TEXT,
ends_at TEXT,
upstream_created_at TEXT,
upstream_updated_at TEXT,
first_seen_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
last_seen_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
read_at TEXT,
dismissed_at TEXT,
raw_payload TEXT,
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS site_announcements_site_source_key_unique
ON site_announcements (site_id, source_key);
CREATE INDEX IF NOT EXISTS site_announcements_site_id_first_seen_at_idx
ON site_announcements (site_id, first_seen_at);
CREATE INDEX IF NOT EXISTS site_announcements_read_at_idx
ON site_announcements (read_at);
-- ============================================================
-- OAUTH ROUTE UNITS
-- ============================================================
CREATE TABLE IF NOT EXISTS oauth_route_units (
id BIGSERIAL PRIMARY KEY,
site_id BIGINT NOT NULL,
provider TEXT NOT NULL,
name TEXT NOT NULL,
strategy TEXT NOT NULL DEFAULT 'round_robin',
enabled BOOLEAN DEFAULT TRUE,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS oauth_route_units_site_provider_idx
ON oauth_route_units (site_id, provider);
CREATE INDEX IF NOT EXISTS oauth_route_units_enabled_idx
ON oauth_route_units (enabled);
-- ============================================================
-- OAUTH ROUTE UNIT MEMBERS
-- ============================================================
CREATE TABLE IF NOT EXISTS oauth_route_unit_members (
id BIGSERIAL PRIMARY KEY,
unit_id BIGINT NOT NULL,
account_id BIGINT NOT NULL,
sort_order BIGINT DEFAULT 0,
success_count BIGINT DEFAULT 0,
fail_count BIGINT DEFAULT 0,
total_latency_ms BIGINT DEFAULT 0,
total_cost DOUBLE PRECISION DEFAULT 0,
last_used_at TEXT,
last_selected_at TEXT,
last_fail_at TEXT,
consecutive_fail_count BIGINT NOT NULL DEFAULT 0,
cooldown_level BIGINT NOT NULL DEFAULT 0,
cooldown_until TEXT,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (unit_id) REFERENCES oauth_route_units(id) ON DELETE CASCADE,
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS oauth_route_unit_members_unit_account_unique
ON oauth_route_unit_members (unit_id, account_id);
CREATE UNIQUE INDEX IF NOT EXISTS oauth_route_unit_members_account_unique
ON oauth_route_unit_members (account_id);
CREATE INDEX IF NOT EXISTS oauth_route_unit_members_unit_sort_idx
ON oauth_route_unit_members (unit_id, sort_order);
CREATE INDEX IF NOT EXISTS oauth_route_unit_members_unit_cooldown_idx
ON oauth_route_unit_members (unit_id, cooldown_until);
-- ============================================================
-- SETTINGS
-- ============================================================
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY NOT NULL,
value TEXT
);
-- ============================================================
-- ADMIN SNAPSHOTS
-- ============================================================
CREATE TABLE IF NOT EXISTS admin_snapshots (
id BIGSERIAL PRIMARY KEY,
namespace TEXT NOT NULL,
snapshot_key TEXT NOT NULL,
payload TEXT NOT NULL,
generated_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
stale_until TEXT NOT NULL,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE UNIQUE INDEX IF NOT EXISTS admin_snapshots_namespace_key_unique
ON admin_snapshots (namespace, snapshot_key);
CREATE INDEX IF NOT EXISTS admin_snapshots_expires_at_idx
ON admin_snapshots (expires_at);
CREATE INDEX IF NOT EXISTS admin_snapshots_stale_until_idx
ON admin_snapshots (stale_until);
-- ============================================================
-- ANALYTICS PROJECTION CHECKPOINTS
-- ============================================================
CREATE TABLE IF NOT EXISTS analytics_projection_checkpoints (
projector_key TEXT PRIMARY KEY NOT NULL,
time_zone TEXT NOT NULL DEFAULT 'Local',
last_proxy_log_id BIGINT NOT NULL DEFAULT 0,
watermark_created_at TEXT,
recompute_from_id BIGINT,
recompute_requested_at TEXT,
recompute_reason TEXT,
recompute_started_at TEXT,
recompute_completed_at TEXT,
last_projected_at TEXT,
last_successful_at TEXT,
last_error TEXT,
lease_owner TEXT,
lease_token TEXT,
lease_expires_at TEXT,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE INDEX IF NOT EXISTS analytics_projection_checkpoints_recompute_from_id_idx
ON analytics_projection_checkpoints (recompute_from_id);
CREATE INDEX IF NOT EXISTS analytics_projection_checkpoints_lease_expires_at_idx
ON analytics_projection_checkpoints (lease_expires_at);
-- ============================================================
-- MODEL DAY USAGE
-- ============================================================
CREATE TABLE IF NOT EXISTS model_day_usage (
id BIGSERIAL PRIMARY KEY,
local_day TEXT NOT NULL,
site_id BIGINT NOT NULL,
model TEXT NOT NULL,
total_calls BIGINT NOT NULL DEFAULT 0,
success_calls BIGINT NOT NULL DEFAULT 0,
failed_calls BIGINT NOT NULL DEFAULT 0,
total_tokens BIGINT NOT NULL DEFAULT 0,
total_spend DOUBLE PRECISION NOT NULL DEFAULT 0,
total_latency_ms BIGINT NOT NULL DEFAULT 0,
latency_count BIGINT NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
CONSTRAINT model_day_usage_non_negative CHECK (
total_calls >= 0
AND success_calls >= 0
AND failed_calls >= 0
AND total_tokens >= 0
AND total_spend >= 0
AND total_latency_ms >= 0
AND latency_count >= 0
)
);
CREATE UNIQUE INDEX IF NOT EXISTS model_day_usage_day_site_model_unique
ON model_day_usage (local_day, site_id, model);
CREATE INDEX IF NOT EXISTS model_day_usage_day_idx
ON model_day_usage (local_day);
CREATE INDEX IF NOT EXISTS model_day_usage_site_id_idx
ON model_day_usage (site_id);
CREATE INDEX IF NOT EXISTS model_day_usage_model_idx
ON model_day_usage (model);
-- ============================================================
-- SITE DAY USAGE
-- ============================================================
CREATE TABLE IF NOT EXISTS site_day_usage (
id BIGSERIAL PRIMARY KEY,
local_day TEXT NOT NULL,
site_id BIGINT NOT NULL,
total_calls BIGINT NOT NULL DEFAULT 0,
success_calls BIGINT NOT NULL DEFAULT 0,
failed_calls BIGINT NOT NULL DEFAULT 0,
total_tokens BIGINT NOT NULL DEFAULT 0,
total_summary_spend DOUBLE PRECISION NOT NULL DEFAULT 0,
total_site_spend DOUBLE PRECISION NOT NULL DEFAULT 0,
total_latency_ms BIGINT NOT NULL DEFAULT 0,
latency_count BIGINT NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
CONSTRAINT site_day_usage_non_negative CHECK (
total_calls >= 0
AND success_calls >= 0
AND failed_calls >= 0
AND total_tokens >= 0
AND total_summary_spend >= 0
AND total_site_spend >= 0
AND total_latency_ms >= 0
AND latency_count >= 0
)
);
CREATE UNIQUE INDEX IF NOT EXISTS site_day_usage_day_site_unique
ON site_day_usage (local_day, site_id);
CREATE INDEX IF NOT EXISTS site_day_usage_day_idx
ON site_day_usage (local_day);
CREATE INDEX IF NOT EXISTS site_day_usage_site_id_idx
ON site_day_usage (site_id);
-- ============================================================
-- SITE HOUR USAGE
-- ============================================================
CREATE TABLE IF NOT EXISTS site_hour_usage (
id BIGSERIAL PRIMARY KEY,
bucket_start_utc TEXT NOT NULL,
site_id BIGINT NOT NULL,
total_calls BIGINT NOT NULL DEFAULT 0,
success_calls BIGINT NOT NULL DEFAULT 0,
failed_calls BIGINT NOT NULL DEFAULT 0,
total_tokens BIGINT NOT NULL DEFAULT 0,
total_summary_spend DOUBLE PRECISION NOT NULL DEFAULT 0,
total_site_spend DOUBLE PRECISION NOT NULL DEFAULT 0,
total_latency_ms BIGINT NOT NULL DEFAULT 0,
latency_count BIGINT NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
CONSTRAINT site_hour_usage_non_negative CHECK (
total_calls >= 0
AND success_calls >= 0
AND failed_calls >= 0
AND total_tokens >= 0
AND total_summary_spend >= 0
AND total_site_spend >= 0
AND total_latency_ms >= 0
AND latency_count >= 0
)
);
CREATE UNIQUE INDEX IF NOT EXISTS site_hour_usage_hour_site_unique
ON site_hour_usage (bucket_start_utc, site_id);
CREATE INDEX IF NOT EXISTS site_hour_usage_hour_idx
ON site_hour_usage (bucket_start_utc);
CREATE INDEX IF NOT EXISTS site_hour_usage_site_id_idx
ON site_hour_usage (site_id);
@@ -0,0 +1,24 @@
-- Background tasks tracking
CREATE TABLE IF NOT EXISTS background_tasks (
id TEXT PRIMARY KEY NOT NULL,
task_type TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
message TEXT DEFAULT '',
error TEXT,
dedupe_key TEXT,
success_title TEXT,
failure_title TEXT,
notify_on_success BOOLEAN DEFAULT TRUE,
notify_on_failure BOOLEAN DEFAULT TRUE,
result_json TEXT,
progress BIGINT DEFAULT 0,
progress_total BIGINT DEFAULT 0,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
started_at TEXT,
finished_at TEXT
);
CREATE INDEX IF NOT EXISTS background_tasks_status_idx
ON background_tasks (status);
@@ -0,0 +1,53 @@
-- Organizations, members, and invitations for multi-tenant support
CREATE TABLE IF NOT EXISTS organizations (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
plan TEXT NOT NULL DEFAULT 'free',
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE UNIQUE INDEX IF NOT EXISTS org_slug_idx
ON organizations (slug);
CREATE INDEX IF NOT EXISTS org_plan_idx
ON organizations (plan);
CREATE TABLE IF NOT EXISTS organization_members (
id BIGSERIAL PRIMARY KEY,
org_id BIGINT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member',
status TEXT NOT NULL DEFAULT 'active',
joined_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE UNIQUE INDEX IF NOT EXISTS org_member_unique
ON organization_members (org_id, user_id);
CREATE INDEX IF NOT EXISTS org_members_org_id_idx
ON organization_members (org_id);
CREATE INDEX IF NOT EXISTS org_members_user_id_idx
ON organization_members (user_id);
CREATE TABLE IF NOT EXISTS invitations (
id BIGSERIAL PRIMARY KEY,
org_id BIGINT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
email TEXT NOT NULL,
token TEXT NOT NULL UNIQUE,
role TEXT NOT NULL DEFAULT 'member',
invited_by BIGINT REFERENCES users(id),
status TEXT NOT NULL DEFAULT 'pending',
expires_at TEXT NOT NULL,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE UNIQUE INDEX IF NOT EXISTS invitations_token_unique
ON invitations (token);
CREATE INDEX IF NOT EXISTS invitations_status_idx
ON invitations (status);
@@ -0,0 +1,37 @@
-- Usage tracking tables migrated from the Node.js backend.
CREATE TABLE IF NOT EXISTS usage_logs (
id BIGSERIAL PRIMARY KEY,
downstream_key_id BIGINT,
model TEXT,
prompt_tokens BIGINT DEFAULT 0,
completion_tokens BIGINT DEFAULT 0,
total_tokens BIGINT DEFAULT 0,
cost DOUBLE PRECISION DEFAULT 0.0,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (downstream_key_id) REFERENCES downstream_api_keys(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS usage_logs_downstream_key_created_idx
ON usage_logs (downstream_key_id, created_at);
CREATE INDEX IF NOT EXISTS usage_logs_created_idx
ON usage_logs (created_at);
CREATE TABLE IF NOT EXISTS usage_daily_snapshots (
id BIGSERIAL PRIMARY KEY,
local_day TEXT NOT NULL,
downstream_key_id BIGINT,
request_count BIGINT NOT NULL DEFAULT 0,
prompt_tokens BIGINT NOT NULL DEFAULT 0,
completion_tokens BIGINT NOT NULL DEFAULT 0,
total_tokens BIGINT NOT NULL DEFAULT 0,
total_cost DOUBLE PRECISION NOT NULL DEFAULT 0.0,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
UNIQUE(),
FOREIGN KEY (downstream_key_id) REFERENCES downstream_api_keys(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS usage_daily_snapshots_day_idx
ON usage_daily_snapshots (local_day);
@@ -0,0 +1,33 @@
-- Admin-authored announcements for normal users.
CREATE TABLE IF NOT EXISTS user_announcements (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
level TEXT NOT NULL DEFAULT 'info',
status TEXT NOT NULL DEFAULT 'published',
audience TEXT NOT NULL DEFAULT 'all',
starts_at TEXT,
ends_at TEXT,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
published_at TEXT
);
CREATE INDEX IF NOT EXISTS user_announcements_status_created_at_idx
ON user_announcements (status, created_at);
CREATE INDEX IF NOT EXISTS user_announcements_active_window_idx
ON user_announcements (starts_at, ends_at);
CREATE TABLE IF NOT EXISTS user_announcement_reads (
announcement_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
read_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
PRIMARY KEY (announcement_id, user_id),
FOREIGN KEY (announcement_id) REFERENCES user_announcements(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS user_announcement_reads_user_id_idx
ON user_announcement_reads (user_id);
@@ -0,0 +1,34 @@
-- Workspace assets: local archive for creative outputs from Picture Agent,
-- ComfyUI Agent, Video Agent and ComfyUI workspaces.
CREATE TABLE IF NOT EXISTS workspace_assets (
id BIGSERIAL PRIMARY KEY,
public_id TEXT NOT NULL UNIQUE,
user_id BIGINT,
source TEXT NOT NULL,
kind TEXT NOT NULL,
title TEXT,
prompt TEXT,
model TEXT,
status TEXT NOT NULL DEFAULT 'completed',
mime_type TEXT,
file_name TEXT,
file_path TEXT,
remote_url TEXT,
thumbnail_path TEXT,
size_bytes BIGINT NOT NULL DEFAULT 0,
metadata TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
deleted_at TEXT,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_workspace_assets_user_created
ON workspace_assets(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_workspace_assets_source_created
ON workspace_assets(source, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_workspace_assets_kind_created
ON workspace_assets(kind, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_workspace_assets_deleted
ON workspace_assets(deleted_at);
@@ -0,0 +1,64 @@
-- BoosAPI Workflow Studio core tables.
CREATE TABLE IF NOT EXISTS workflows (
id BIGSERIAL PRIMARY KEY,
public_id TEXT NOT NULL UNIQUE,
user_id BIGINT,
name TEXT NOT NULL,
description TEXT,
graph TEXT NOT NULL DEFAULT '{}',
is_template BIGINT NOT NULL DEFAULT 0,
visibility TEXT NOT NULL DEFAULT 'private',
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
deleted_at TEXT,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_workflows_user_updated
ON workflows(user_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_workflows_visibility
ON workflows(visibility);
CREATE INDEX IF NOT EXISTS idx_workflows_deleted
ON workflows(deleted_at);
CREATE TABLE IF NOT EXISTS workflow_runs (
id BIGSERIAL PRIMARY KEY,
public_id TEXT NOT NULL UNIQUE,
workflow_id BIGINT NOT NULL,
user_id BIGINT,
status TEXT NOT NULL,
input_overrides TEXT NOT NULL DEFAULT '{}',
final_outputs TEXT,
error_message TEXT,
started_at TEXT,
completed_at TEXT,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY(workflow_id) REFERENCES workflows(id) ON DELETE CASCADE,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_workflow_runs_workflow_created
ON workflow_runs(workflow_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_workflow_runs_user_created
ON workflow_runs(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_workflow_runs_status
ON workflow_runs(status);
CREATE TABLE IF NOT EXISTS workflow_node_runs (
id BIGSERIAL PRIMARY KEY,
run_id BIGINT NOT NULL,
node_id TEXT NOT NULL,
node_type TEXT NOT NULL,
status TEXT NOT NULL,
inputs TEXT NOT NULL DEFAULT '{}',
outputs TEXT,
error_message TEXT,
started_at TEXT,
completed_at TEXT,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY(run_id) REFERENCES workflow_runs(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_workflow_node_runs_run
ON workflow_node_runs(run_id, id);
@@ -0,0 +1,3 @@
ALTER TABLE proxy_logs ADD COLUMN IF NOT EXISTS content_summary TEXT;
ALTER TABLE proxy_logs ADD COLUMN IF NOT EXISTS request_content TEXT;
ALTER TABLE proxy_logs ADD COLUMN IF NOT EXISTS response_content TEXT;
@@ -0,0 +1,45 @@
-- Performance-oriented projections and indexes for logs/workspace views.
CREATE INDEX IF NOT EXISTS proxy_logs_key_created_status_idx
ON proxy_logs (downstream_api_key_id, created_at DESC, status);
CREATE INDEX IF NOT EXISTS proxy_logs_key_model_created_idx
ON proxy_logs (downstream_api_key_id, model_actual, created_at DESC);
CREATE INDEX IF NOT EXISTS proxy_logs_content_summary_idx
ON proxy_logs (content_summary);
CREATE INDEX IF NOT EXISTS workspace_assets_user_output_created_idx
ON workspace_assets (
user_id,
status,
deleted_at,
source,
kind,
created_at DESC
);
CREATE INDEX IF NOT EXISTS workspace_assets_output_role_created_idx
ON workspace_assets (((metadata::jsonb #>> '{workspaceAsset,role}')), created_at DESC);
CREATE INDEX IF NOT EXISTS workspace_assets_workflow_output_created_idx
ON workspace_assets (((metadata::jsonb #>> '{workflowUi,type}')), created_at DESC);
CREATE TABLE IF NOT EXISTS workspace_asset_daily (
local_day TEXT NOT NULL,
user_id BIGINT NOT NULL DEFAULT 0,
source TEXT NOT NULL,
kind TEXT NOT NULL,
asset_count BIGINT NOT NULL DEFAULT 0,
local_file_count BIGINT NOT NULL DEFAULT 0,
total_size_bytes BIGINT NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
UNIQUE(local_day, user_id, source, kind)
);
CREATE INDEX IF NOT EXISTS workspace_asset_daily_user_day_idx
ON workspace_asset_daily (user_id, local_day DESC);
CREATE INDEX IF NOT EXISTS workspace_asset_daily_source_day_idx
ON workspace_asset_daily (source, local_day DESC);
+18 -72
View File
@@ -2,12 +2,12 @@
"name": "boosapi",
"version": "1.3.0",
"description": "Meta-layer management and unified proxy for AI API aggregation platforms",
"main": "dist/desktop/main.js",
"main": "dist/web/index.html",
"keywords": [
"ai",
"api-gateway",
"proxy",
"fastify",
"rust",
"openai",
"claude",
"aggregator"
@@ -22,97 +22,45 @@
},
"license": "MIT",
"engines": {
"node": ">=25.0.0"
"node": ">=22.0.0"
},
"type": "module",
"scripts": {
"dev:server": "tsx scripts/dev/run-server.ts --watch",
"dev": "concurrently \"npm run dev:server\" \"vite\"",
"dev:desktop": "concurrently \"npm run dev:server\" \"vite\" \"tsc -p tsconfig.desktop.json --watch --preserveWatchOutput\" \"wait-on tcp:127.0.0.1:4000 http://127.0.0.1:5173 file:dist/desktop/main.js && cross-env METAPI_DESKTOP_DEV_SERVER_URL=http://127.0.0.1:5173 METAPI_DESKTOP_EXTERNAL_SERVER_URL=http://127.0.0.1:4000 electron dist/desktop/main.js\"",
"desktop:icons": "node scripts/desktop/generate-icons.mjs",
"build:web": "npm run desktop:icons && vite build",
"build:server": "tsc -p tsconfig.server.json && tsx scripts/dev/copy-runtime-db-generated.ts",
"build:desktop": "tsc -p tsconfig.desktop.json",
"build": "npm run build:web && npm run build:server && npm run build:desktop",
"dev": "vite",
"build:web": "vite build",
"build": "npm run build:web",
"typecheck:web": "tsc -p tsconfig.web.json",
"typecheck:web:test": "tsc -p tsconfig.web.test.json",
"typecheck:server": "tsc --noEmit -p tsconfig.server.json",
"typecheck:desktop": "tsc --noEmit -p tsconfig.desktop.json",
"typecheck": "npm run typecheck:web && npm run typecheck:web:test && npm run typecheck:server && npm run typecheck:desktop",
"dist:desktop": "npm run build && electron-builder --config electron-builder.yml --publish never",
"dist:desktop:mac:intel": "npm run build && electron-builder --config electron-builder.yml --publish never --mac --x64",
"package:desktop": "npm run dist:desktop",
"package:desktop:mac:intel": "npm run dist:desktop:mac:intel",
"typecheck": "npm run typecheck:web && npm run typecheck:web:test",
"website:dev": "npm --prefix website run dev",
"website:build": "npm --prefix website run build",
"website:start": "npm --prefix website run start",
"website:typecheck": "npm --prefix website run typecheck",
"docs:dev": "vitepress dev docs --host 0.0.0.0 --port 4173",
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs --host 0.0.0.0 --port 4173",
"start": "node dist/server/index.js",
"start:deploy-helper": "node dist/server/update-helper/index.js",
"start:desktop": "electron dist/desktop/main.js",
"comfyui:install": "node scripts/comfyui/install.mjs",
"comfyui:start": "node scripts/comfyui/start.mjs",
"comfyui:stop": "node scripts/comfyui/stop.mjs",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx src/server/db/migrate.ts",
"schema:contract": "tsx scripts/dev/generate-schema-contract.ts",
"schema:generate": "npm run db:generate && npm run schema:contract",
"repo:drift-check": "tsx scripts/dev/repo-drift-check.ts",
"readme:contributors": "tsx scripts/dev/update-readme-contributors.ts",
"test:schema:unit": "vitest run --root . src/server/db/schemaContract.test.ts src/server/db/schemaArtifactGenerator.test.ts src/server/db/schemaIntrospection.test.ts src/server/db/schemaParity.test.ts",
"test:schema:parity": "vitest run --root . src/server/db/schemaParity.live.test.ts",
"test:schema:upgrade": "vitest run --root . src/server/db/schemaUpgrade.live.test.ts",
"test:schema:runtime": "vitest run --root . src/server/db/runtimeSchemaBootstrap.live.test.ts",
"smoke:db": "tsx scripts/dev/db-smoke.ts",
"smoke:db:sqlite": "tsx scripts/dev/db-smoke.ts --db-type sqlite",
"smoke:db:mysql": "tsx scripts/dev/db-smoke.ts --db-type mysql",
"smoke:db:postgres": "tsx scripts/dev/db-smoke.ts --db-type postgres",
"test": "vitest run --root .",
"test:watch": "vitest --root ."
"workflow-ui:install": "node scripts/workflow-ui/install.mjs all",
"workflow-ui:install:external": "node scripts/workflow-ui/install.mjs external",
"workflow-ui:install:cloud": "node scripts/workflow-ui/install.mjs cloud",
"test": "vitest run --root src/web",
"test:watch": "vitest --root src/web"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fastify/cors": "^11.2.0",
"@fastify/static": "^9.0.0",
"@visactor/react-vchart": "^2.0.21",
"better-sqlite3": "^12.8.0",
"dotenv": "^17.4.1",
"drizzle-orm": "^0.45.2",
"electron-log": "^5.2.4",
"electron-updater": "^6.6.2",
"fastify": "^5.8.4",
"get-port": "^7.2.0",
"gsap": "^3.15.0",
"marked": "^17.0.5",
"minimatch": "^10.2.5",
"minimist": "^1.2.8",
"mysql2": "^3.20.0",
"node-cron": "^4.2.1",
"nodemailer": "^8.0.4",
"pg": "^8.16.3",
"rate-limiter-flexible": "^11.0.0",
"socks": "^2.8.7",
"undici": "^6.24.1",
"ws": "^8.20.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@electron/notarize": "^3.1.0",
"@tailwindcss/vite": "^4.2.2",
"@types/better-sqlite3": "^7.6.11",
"@types/node": "^22.10.1",
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^8.0.0",
"@types/pg": "^8.20.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/react-test-renderer": "^19.1.0",
"@vitejs/plugin-react": "^4.3.4",
"concurrently": "^9.1.0",
"cross-env": "^7.0.3",
"drizzle-kit": "^0.31.10",
"electron": "^41.1.1",
"electron-builder": "^26.0.12",
"jsdom": "^29.0.2",
"mermaid": "^11.14.0",
"react": "^18.3.1",
@@ -121,13 +69,11 @@
"react-test-renderer": "^18.3.1",
"sharp": "^0.34.5",
"tailwindcss": "^4.0.0",
"tsx": "^4.19.2",
"typescript": "^6.0.2",
"vite": "^6.0.3",
"vitepress": "^1.6.4",
"vitepress-plugin-mermaid": "^2.0.17",
"vitest": "^2.1.8",
"wait-on": "^9.0.4"
"vitest": "^2.1.8"
},
"overrides": {
"minimist": "^1.2.8"
+82 -3768
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.
@@ -0,0 +1,147 @@
#!/usr/bin/env node
import fs from 'fs/promises';
const DEFAULT_PACKS = [
['ComfyUI-Manager', 'https://github.com/Comfy-Org/ComfyUI-Manager.git'],
['christian-byrne/audio-separation-nodes-comfyui', 'https://github.com/christian-byrne/audio-separation-nodes-comfyui.git'],
['yvann-ba/ComfyUI_Yvann-Nodes', 'https://github.com/yvann-ba/ComfyUI_Yvann-Nodes.git'],
['yolain/ComfyUI-Easy-Use', 'https://github.com/yolain/ComfyUI-Easy-Use.git'],
['wildminder/ComfyUI-VoxCPM', 'https://github.com/wildminder/ComfyUI-VoxCPM.git'],
['kijai/ComfyUI-WanVideoWrapper', 'https://github.com/kijai/ComfyUI-WanVideoWrapper.git'],
['civen-cn/ComfyUI-Whisper-Translator', 'https://github.com/civen-cn/ComfyUI-Whisper-Translator.git'],
['kijai/ComfyUI-KJNodes', 'https://github.com/kijai/ComfyUI-KJNodes.git'],
];
const args = new Set(process.argv.slice(2));
const configPath = valueArg('--config') || 'config.toml';
const baseUrl = trimSlash(
process.env.BOOSAPI_COMFYUI__BASE_URL
|| process.env.COMFYUI_CLOUD_BASE_URL
|| (await readComfyConfig(configPath)).base_url
|| 'https://cloud.comfy.org'
);
const apiKey = process.env.BOOSAPI_COMFYUI__API_KEY
|| process.env.COMFYUI_CLOUD_API_KEY
|| (await readComfyConfig(configPath)).api_key
|| '';
const dryRun = args.has('--dry-run');
if (!baseUrl) {
fail('Missing Cloud base URL. Set BOOSAPI_COMFYUI__BASE_URL or [comfyui].base_url.');
}
if (!apiKey) {
fail('Missing Cloud API key. Set BOOSAPI_COMFYUI__API_KEY or [comfyui].api_key.');
}
console.log(`Cloud backend: ${baseUrl}`);
console.log(`Packs: ${DEFAULT_PACKS.length}`);
if (dryRun) {
for (const [id, repository] of DEFAULT_PACKS) {
console.log(`[dry-run] ${id} <- ${repository}`);
}
process.exit(0);
}
await request('GET', '/api/manager/queue/start').catch((error) => {
console.warn(`manager queue start warning: ${error.message}`);
});
const results = [];
for (const [id, repository] of DEFAULT_PACKS) {
const payload = {
id,
version: 'nightly',
selected_version: 'nightly',
repository,
mode: 'remote',
channel: 'default',
skip_post_install: false,
};
try {
await request('POST', '/api/manager/queue/install', payload);
console.log(`queued: ${id}`);
results.push({ id, ok: true });
} catch (error) {
console.error(`failed: ${id}: ${error.message}`);
results.push({ id, ok: false, error: error.message });
}
}
await request('GET', '/api/manager/queue/status')
.then((status) => console.log(`queue status: ${JSON.stringify(status)}`))
.catch((error) => console.warn(`queue status warning: ${error.message}`));
const failed = results.filter((item) => !item.ok);
if (failed.length > 0) {
console.error(`\n${failed.length} pack(s) failed to queue. If Cloud returned 403/404, the hosted backend does not allow Manager installs for this workspace/API key.`);
process.exit(1);
}
console.log('\nAll install jobs were queued. Restart the Cloud ComfyUI runtime if Manager asks for it.');
async function request(method, path, body) {
const response = await fetch(`${baseUrl}${path}`, {
method,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Bearer ${apiKey}`,
'X-API-Key': apiKey,
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await response.text();
const data = text ? safeJson(text) : null;
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}: ${text.slice(0, 500)}`);
}
return data;
}
function safeJson(text) {
try {
return JSON.parse(text);
} catch {
return { body: text };
}
}
async function readComfyConfig(path) {
const result = {};
let section = '';
let text = '';
try {
text = await fs.readFile(path, 'utf8');
} catch {
return result;
}
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const sectionMatch = line.match(/^\[([^\]]+)\]$/);
if (sectionMatch) {
section = sectionMatch[1];
continue;
}
if (section !== 'comfyui') continue;
const match = line.match(/^([A-Za-z0-9_]+)\s*=\s*"(.*)"\s*$/);
if (match) result[match[1]] = match[2];
}
return result;
}
function trimSlash(value) {
return String(value || '').trim().replace(/\/+$/, '');
}
function valueArg(name) {
const prefix = `${name}=`;
const found = process.argv.slice(2).find((arg) => arg.startsWith(prefix));
return found ? found.slice(prefix.length) : '';
}
function fail(message) {
console.error(message);
process.exit(2);
}
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
set -euo pipefail
COMFYUI_DIR="${COMFYUI_DIR:-/workspace/ComfyUI}"
CUSTOM_NODES_DIR="${CUSTOM_NODES_DIR:-$COMFYUI_DIR/custom_nodes}"
PYTHON_BIN="${PYTHON_BIN:-$COMFYUI_DIR/venv/bin/python}"
if [ ! -d "$COMFYUI_DIR" ]; then
echo "ComfyUI directory not found: $COMFYUI_DIR" >&2
exit 1
fi
if [ ! -x "$PYTHON_BIN" ]; then
if command -v python3 >/dev/null 2>&1; then
PYTHON_BIN="$(command -v python3)"
else
echo "Python not found. Set PYTHON_BIN=/path/to/python" >&2
exit 1
fi
fi
mkdir -p "$CUSTOM_NODES_DIR"
install_node() {
local name="$1"
local repo="$2"
local ref="${3:-}"
local dir="$CUSTOM_NODES_DIR/$name"
if [ -d "$dir/.git" ]; then
echo "==> Updating $name"
git -C "$dir" fetch --depth 1 origin "${ref:-HEAD}" || git -C "$dir" fetch origin
else
echo "==> Installing $name"
git clone --depth 1 "$repo" "$dir"
fi
if [ -n "$ref" ]; then
git -C "$dir" checkout --quiet "$ref"
fi
if [ -f "$dir/requirements.txt" ]; then
echo "==> Installing Python requirements for $name"
if command -v uv >/dev/null 2>&1; then
UV_INDEX_URL="${UV_INDEX_URL:-https://pypi.tuna.tsinghua.edu.cn/simple}" \
uv pip install --python "$PYTHON_BIN" -r "$dir/requirements.txt"
else
"$PYTHON_BIN" -m pip install -r "$dir/requirements.txt"
fi
fi
if [ -f "$dir/install.py" ]; then
echo "==> Running install.py for $name"
"$PYTHON_BIN" "$dir/install.py" || true
fi
}
# ComfyUI-Manager backend API. The v1.46.x cloud frontend already contains the
# Manager UI; this backend node provides /manager/* and /customnode/* endpoints
# for local/docker/external ComfyUI instances.
install_node "ComfyUI-Manager" "https://github.com/Comfy-Org/ComfyUI-Manager.git"
# Nodes used by common RunningHub digital-human / video templates.
install_node "audio-separation-nodes-comfyui" "https://github.com/christian-byrne/audio-separation-nodes-comfyui.git"
install_node "ComfyUI_Yvann-Nodes" "https://github.com/yvann-ba/ComfyUI_Yvann-Nodes.git"
install_node "ComfyUI-Easy-Use" "https://github.com/yolain/ComfyUI-Easy-Use.git"
install_node "ComfyUI-VoxCPM" "https://github.com/wildminder/ComfyUI-VoxCPM.git"
install_node "ComfyUI-WanVideoWrapper" "https://github.com/kijai/ComfyUI-WanVideoWrapper.git"
install_node "ComfyUI-Whisper-Translator" "https://github.com/civen-cn/ComfyUI-Whisper-Translator.git"
install_node "ComfyUI-KJNodes" "https://github.com/kijai/ComfyUI-KJNodes.git"
cat <<'EOF'
RunningHub template nodes installed.
Notes:
- Install system packages such as ffmpeg/libsndfile/rubberband separately if a
node requires them at runtime.
- The screenshot also references a "soundflow" package. I did not find a stable
public repository for that exact package name; install it through
ComfyUI-Manager or add its repository manually when you have the source URL.
- Missing model files, for example Wan/InfiniteTalk checkpoints, are not custom
nodes. Put those models into the matching ComfyUI models folders or use a
backend where RunningHub/Cloud already provides them.
EOF
+260
View File
@@ -0,0 +1,260 @@
#!/usr/bin/env bash
# Build the BossAI production binary, package Alpine/musl runtime libraries,
# and deploy the runnable bundle to a Debian server.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
log() { echo "[$(date '+%H:%M:%S')] $*"; }
ok() { echo "[$(date '+%H:%M:%S')] ✓ $*"; }
fail() { echo "[$(date '+%H:%M:%S')] ✗ $*" >&2; exit 1; }
REMOTE_HOST="${BOSSAI_DEBIAN_HOST:-129.211.30.104}"
REMOTE_USER="${BOSSAI_DEBIAN_USER:-root}"
REMOTE_DIR="${BOSSAI_DEBIAN_DIR:-/root/BossAI}"
SSH_KEY="${BOSSAI_DEBIAN_SSH_KEY:-$HOME/.ssh/id_rsa_mbp}"
BOSSAI_SOURCE_DIR="${BOSSAI_SOURCE_DIR:-/root/BossAI-build/main}"
BOSSAI_REMOTE="${BOSSAI_REMOTE:-bossai}"
BOSSAI_REF="${BOSSAI_REF:-main}"
BOSSAI_WORKFLOW_UI_SOURCE="${BOSSAI_WORKFLOW_UI_SOURCE:-$ROOT_DIR/vendor/ComfyUI_frontend}"
PRODUCTION_DIR="${BOSSAI_PRODUCTION_DIR:-$BOSSAI_SOURCE_DIR/dist/bossai-production}"
BUNDLE_DIR="${BOSSAI_DEBIAN_BUNDLE_DIR:-$BOSSAI_SOURCE_DIR/dist/bossai-debian-bundle}"
BUNDLE_ARCHIVE="${BOSSAI_DEBIAN_BUNDLE_ARCHIVE:-$BOSSAI_SOURCE_DIR/dist/bossai-debian-bundle.tar.gz}"
FRONTEND_PM="${BOOSAPI_FRONTEND_PM:-auto}"
SKIP_BUILD=0
SKIP_VERIFY=0
SKIP_SOURCE_UPDATE=0
usage() {
cat <<'EOF'
Usage: scripts/deploy-bossai-debian.sh [options]
Build BossAI from the BossAI source tree, bundle the musl runtime libraries
needed by the Alpine-built binary, upload it to a Debian host, extract it,
and run a config check.
Options:
--host <host> Debian host/IP. Default: 129.211.30.104
--user <user> SSH user. Default: root
--dir <remote-dir> Remote deploy directory. Default: /root/BossAI
--key <ssh-key> SSH private key. Default: ~/.ssh/id_rsa_mbp
--source-dir <dir> BossAI source worktree. Default: /root/BossAI-build/main
--remote <name> Git remote for BossAI. Default: bossai
--ref <ref> BossAI branch/ref to build. Default: main
--skip-build Reuse dist/bossai-production from a previous build
--skip-source-update Do not fetch/reset the BossAI source worktree
--skip-verify Skip remote ./run-boosapi.sh --check-config
-h, --help Show this help
Environment overrides:
BOSSAI_DEBIAN_HOST
BOSSAI_DEBIAN_USER
BOSSAI_DEBIAN_DIR
BOSSAI_DEBIAN_SSH_KEY
BOSSAI_SOURCE_DIR
BOSSAI_REMOTE
BOSSAI_REF
BOSSAI_WORKFLOW_UI_SOURCE
BOSSAI_PRODUCTION_DIR
BOSSAI_DEBIAN_BUNDLE_DIR
BOSSAI_DEBIAN_BUNDLE_ARCHIVE
BOOSAPI_FRONTEND_PM auto / bun / npm, forwarded to build-and-run.sh
Remote start command after deploy:
cd /root/BossAI && ./run-boosapi.sh
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
--host)
shift
[ $# -gt 0 ] || fail "--host requires a value"
REMOTE_HOST="$1"
;;
--user)
shift
[ $# -gt 0 ] || fail "--user requires a value"
REMOTE_USER="$1"
;;
--dir)
shift
[ $# -gt 0 ] || fail "--dir requires a value"
REMOTE_DIR="$1"
;;
--key)
shift
[ $# -gt 0 ] || fail "--key requires a value"
SSH_KEY="$1"
;;
--source-dir)
shift
[ $# -gt 0 ] || fail "--source-dir requires a value"
BOSSAI_SOURCE_DIR="$1"
PRODUCTION_DIR="${BOSSAI_PRODUCTION_DIR:-$BOSSAI_SOURCE_DIR/dist/bossai-production}"
BUNDLE_DIR="${BOSSAI_DEBIAN_BUNDLE_DIR:-$BOSSAI_SOURCE_DIR/dist/bossai-debian-bundle}"
BUNDLE_ARCHIVE="${BOSSAI_DEBIAN_BUNDLE_ARCHIVE:-$BOSSAI_SOURCE_DIR/dist/bossai-debian-bundle.tar.gz}"
;;
--remote)
shift
[ $# -gt 0 ] || fail "--remote requires a value"
BOSSAI_REMOTE="$1"
;;
--ref)
shift
[ $# -gt 0 ] || fail "--ref requires a value"
BOSSAI_REF="$1"
;;
--skip-build)
SKIP_BUILD=1
;;
--skip-source-update)
SKIP_SOURCE_UPDATE=1
;;
--skip-verify)
SKIP_VERIFY=1
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 1
;;
esac
shift
done
require_file() {
local path="$1"
[ -f "$path" ] || fail "Missing required file: $path"
}
require_command() {
command -v "$1" >/dev/null 2>&1 || fail "Missing required command: $1"
}
ensure_bossai_source() {
require_command git
if [ "$SKIP_SOURCE_UPDATE" -eq 1 ]; then
log "Skipping BossAI source update; using $BOSSAI_SOURCE_DIR"
[ -d "$BOSSAI_SOURCE_DIR" ] || fail "BossAI source directory does not exist: $BOSSAI_SOURCE_DIR"
return 0
fi
log "Preparing BossAI source from $BOSSAI_REMOTE/$BOSSAI_REF → $BOSSAI_SOURCE_DIR"
git fetch "$BOSSAI_REMOTE" "$BOSSAI_REF"
if [ -d "$BOSSAI_SOURCE_DIR" ] && git -C "$BOSSAI_SOURCE_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
git -C "$BOSSAI_SOURCE_DIR" reset --hard "$BOSSAI_REMOTE/$BOSSAI_REF"
git -C "$BOSSAI_SOURCE_DIR" clean -fd
else
rm -rf "$BOSSAI_SOURCE_DIR"
mkdir -p "$(dirname "$BOSSAI_SOURCE_DIR")"
git worktree add "$BOSSAI_SOURCE_DIR" "$BOSSAI_REMOTE/$BOSSAI_REF"
fi
require_file "$BOSSAI_SOURCE_DIR/build-and-run.sh"
ok "BossAI source ready: $(git -C "$BOSSAI_SOURCE_DIR" rev-parse --short HEAD)"
}
prepare_workflow_ui_source() {
if [ -n "${BOOSAPI_WORKFLOW_UI_REPO:-}" ]; then
log "Using BOOSAPI_WORKFLOW_UI_REPO=$BOOSAPI_WORKFLOW_UI_REPO"
return 0
fi
if [ ! -d "$BOSSAI_WORKFLOW_UI_SOURCE/.git" ] && [ ! -f "$BOSSAI_WORKFLOW_UI_SOURCE/.git" ]; then
log "No local Workflow UI source at $BOSSAI_WORKFLOW_UI_SOURCE; install script may fetch from GitHub"
return 0
fi
log "Using local ComfyUI_frontend source: $BOSSAI_WORKFLOW_UI_SOURCE"
export BOOSAPI_WORKFLOW_UI_REPO="$BOSSAI_WORKFLOW_UI_SOURCE"
}
runtime_lib() {
local path="$1"
require_file "$path"
cp "$path" "$BUNDLE_DIR/lib/"
}
write_runner() {
cat > "$BUNDLE_DIR/run-boosapi.sh" <<'EOF'
#!/usr/bin/env sh
set -eu
DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
exec "$DIR/lib/ld-musl-x86_64.so.1" --library-path "$DIR/lib" "$DIR/boosapi" "$@"
EOF
chmod +x "$BUNDLE_DIR/run-boosapi.sh"
}
package_bundle() {
require_file "$PRODUCTION_DIR/boosapi"
require_file "$PRODUCTION_DIR/config.toml"
rm -rf "$BUNDLE_DIR" "$BUNDLE_ARCHIVE"
mkdir -p "$BUNDLE_DIR/lib"
cp "$PRODUCTION_DIR/boosapi" "$BUNDLE_DIR/boosapi"
cp "$PRODUCTION_DIR/config.toml" "$BUNDLE_DIR/config.toml"
chmod +x "$BUNDLE_DIR/boosapi"
runtime_lib /lib/ld-musl-x86_64.so.1
runtime_lib /usr/lib/libssl.so.3
runtime_lib /usr/lib/libcrypto.so.3
runtime_lib /usr/lib/libgcc_s.so.1
write_runner
tar -C "$BUNDLE_DIR" -czf "$BUNDLE_ARCHIVE" .
ok "Bundle ready: $BUNDLE_ARCHIVE"
sha256sum "$BUNDLE_ARCHIVE" "$BUNDLE_DIR/boosapi"
ls -lh "$BUNDLE_ARCHIVE"
}
deploy_bundle() {
require_file "$SSH_KEY"
require_command ssh
require_command scp
local remote="${REMOTE_USER}@${REMOTE_HOST}"
local ssh_opts=(-i "$SSH_KEY" -o StrictHostKeyChecking=accept-new)
log "Preparing remote directory: $remote:$REMOTE_DIR"
ssh "${ssh_opts[@]}" "$remote" "mkdir -p '$REMOTE_DIR'"
log "Uploading bundle..."
scp "${ssh_opts[@]}" "$BUNDLE_ARCHIVE" "$remote:$REMOTE_DIR/bossai-debian-bundle.tar.gz"
log "Extracting bundle on Debian host..."
ssh "${ssh_opts[@]}" "$remote" "cd '$REMOTE_DIR' && tar -xzf bossai-debian-bundle.tar.gz && chmod +x boosapi run-boosapi.sh && sha256sum bossai-debian-bundle.tar.gz boosapi"
if [ "$SKIP_VERIFY" -eq 0 ]; then
log "Checking remote config..."
ssh "${ssh_opts[@]}" "$remote" "cd '$REMOTE_DIR' && ./run-boosapi.sh --check-config"
fi
ok "Deployed BossAI to $remote:$REMOTE_DIR"
log "Start with: cd $REMOTE_DIR && ./run-boosapi.sh"
}
if [ "$SKIP_BUILD" -eq 0 ]; then
ensure_bossai_source
prepare_workflow_ui_source
log "Building BossAI production bundle..."
BOOSAPI_FRONTEND_PM="$FRONTEND_PM" "$BOSSAI_SOURCE_DIR/build-and-run.sh" \
--production \
--production-dir "$PRODUCTION_DIR"
else
[ -d "$BOSSAI_SOURCE_DIR" ] || fail "BossAI source directory does not exist: $BOSSAI_SOURCE_DIR"
log "Skipping build; using existing $PRODUCTION_DIR"
fi
package_bundle
deploy_bundle
+138
View File
@@ -0,0 +1,138 @@
import fs from 'fs/promises';
import path from 'path';
import { spawn } from 'child_process';
const ROOT = process.cwd();
const REPO = process.env.BOOSAPI_WORKFLOW_UI_REPO || '';
const VARIANT = (process.env.BOOSAPI_WORKFLOW_UI_VARIANT || process.argv[2] || 'all').toLowerCase();
const DEFAULT_EXTERNAL_REF = 'v1.14.6';
const DEFAULT_CLOUD_REF = 'v1.46.2';
const SUBMODULE_DIR = path.join(ROOT, 'vendor', 'ComfyUI_frontend');
const CACHE_DIR = path.join(ROOT, '.cache', 'comfyui-frontend');
const DEFAULT_DIST = path.join(ROOT, 'dist', 'workflow-ui');
// External/RunningHub currently targets ComfyUI 0.3.27, where v1.14.x is the
// most stable match. Cloud tracks the newer ComfyUI_frontend release line.
const variants = {
external: {
ref: process.env.BOOSAPI_WORKFLOW_UI_EXTERNAL_REF
|| process.env.BOOSAPI_WORKFLOW_UI_REF
|| DEFAULT_EXTERNAL_REF,
dist: process.env.BOOSAPI_WORKFLOW_UI_EXTERNAL_DIST
|| path.join(ROOT, 'dist', 'workflow-ui-external'),
description: 'External/RunningHub compatible frontend',
},
cloud: {
ref: process.env.BOOSAPI_WORKFLOW_UI_CLOUD_REF
|| process.env.BOOSAPI_WORKFLOW_UI_REF
|| DEFAULT_CLOUD_REF,
dist: process.env.BOOSAPI_WORKFLOW_UI_CLOUD_DIST
|| path.join(ROOT, 'dist', 'workflow-ui-cloud'),
description: 'Comfy Cloud frontend',
},
};
function run(command, args, options = {}) {
return new Promise((resolve, reject) => {
const env = { ...process.env, ...(options.env || {}) };
if (options.cleanPackageEnv) {
Object.keys(env)
.filter((key) => key.startsWith('npm_package_'))
.forEach((key) => {
delete env[key];
});
Object.assign(env, options.env || {});
}
const child = spawn(command, args, {
cwd: options.cwd || ROOT,
env,
stdio: 'inherit',
shell: process.platform === 'win32',
});
child.on('error', reject);
child.on('exit', (code) => {
if (code === 0) resolve();
else reject(new Error(`${command} ${args.join(' ')} exited with ${code}`));
});
});
}
function selectedVariants() {
if (VARIANT === 'all') return Object.entries(variants);
if (variants[VARIANT]) return [[VARIANT, variants[VARIANT]]];
throw new Error(`Unknown workflow-ui variant: ${VARIANT}. Use external, cloud, or all.`);
}
async function readPackageInfo(dir) {
const raw = await fs.readFile(path.join(dir, 'package.json'), 'utf8');
return JSON.parse(raw);
}
async function buildVariant(name, config) {
const ref = config.ref;
const dist = config.dist;
const dir = path.join(CACHE_DIR, `${name}-${ref.replace(/[^a-zA-Z0-9._-]/g, '_')}`);
console.log(`\n==> Building workflow-ui:${name} (${config.description}) from ${ref}`);
await fs.mkdir(path.dirname(CACHE_DIR), { recursive: true });
await fs.rm(dir, { recursive: true, force: true });
if (REPO) {
await run('git', ['clone', '--depth', '1', '--branch', ref, REPO, dir]);
} else {
const hasSubmodule = await fs.access(path.join(SUBMODULE_DIR, 'package.json')).then(() => true, () => false);
if (!hasSubmodule) {
await run('git', ['submodule', 'update', '--init', '--recursive', 'vendor/ComfyUI_frontend']);
}
await run('git', ['fetch', '--tags', 'origin', ref], { cwd: SUBMODULE_DIR });
await run('git', ['clone', '--shared', SUBMODULE_DIR, dir]);
await run('git', ['-c', 'advice.detachedHead=false', 'checkout', '--quiet', ref], { cwd: dir });
await run('git', ['submodule', 'update', '--init', '--recursive'], { cwd: dir });
}
const packageInfo = await readPackageInfo(dir);
const packageEnv = {
npm_package_name: packageInfo.name || '',
npm_package_version: packageInfo.version || ref.replace(/^v/, ''),
};
const hasPnpmLock = await fs.access(path.join(dir, 'pnpm-lock.yaml')).then(() => true, () => false);
if (hasPnpmLock) {
await run('pnpm', ['install', '--frozen-lockfile'], { cwd: dir });
await run('pnpm', ['exec', 'vite', 'build', '--config', 'vite.config.mts'], {
cwd: dir,
cleanPackageEnv: true,
env: packageEnv,
});
} else {
await run('npm', ['ci'], { cwd: dir });
await run('npm', ['run', 'build'], {
cwd: dir,
cleanPackageEnv: true,
env: packageEnv,
});
}
await fs.rm(dist, { recursive: true, force: true });
await fs.cp(path.join(dir, 'dist'), dist, { recursive: true });
await fs.writeFile(
path.join(dist, 'boosapi-workflow-ui.json'),
`${JSON.stringify({
variant: name,
ref,
packageName: packageInfo.name || null,
packageVersion: packageInfo.version || null,
}, null, 2)}\n`,
);
console.log(`workflow-ui:${name} installed to ${dist}`);
if (name === 'cloud') {
await fs.rm(DEFAULT_DIST, { recursive: true, force: true });
await fs.cp(dist, DEFAULT_DIST, { recursive: true });
console.log(`workflow-ui default compatibility copy installed to ${DEFAULT_DIST}`);
}
}
for (const [name, config] of selectedVariants()) {
await buildVariant(name, config);
}
+2471
View File
File diff suppressed because it is too large Load Diff
+235 -1
View File
@@ -13,6 +13,10 @@ pub struct AppConfig {
pub proxy: ProxyConfig,
#[serde(default)]
pub comfyui: ComfyUIConfig,
#[serde(default)]
pub pixelle_video: PixelleVideoConfig,
#[serde(default)]
pub payment: PaymentConfig,
}
#[derive(Debug, Deserialize, Clone)]
@@ -47,11 +51,25 @@ pub struct ProxyConfig {
pub first_byte_timeout_secs: u64,
pub max_retries: u32,
pub proxy_log_retention_days: u32,
#[serde(default)]
pub template_proxy_url: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct ComfyUIConfig {
pub enabled: bool,
#[serde(default)]
pub backend: ComfyUIBackend,
#[serde(default)]
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")]
pub container_name: String,
pub install_dir: Option<PathBuf>,
pub python_bin: String,
pub port: u16,
@@ -59,13 +77,116 @@ pub struct ComfyUIConfig {
pub max_restarts: u32,
pub external_url: Option<String>,
pub base_url: Option<String>,
pub api_key: Option<String>,
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,
pub min_amount_usd: f64,
pub max_amount_usd: Option<f64>,
pub daily_limit_usd: Option<f64>,
pub order_timeout_minutes: i64,
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)]
#[serde(rename_all = "snake_case")]
pub enum ComfyUIBackend {
#[default]
Boosapi,
External,
Cloud,
Runninghub,
Docker,
}
fn default_comfyui_docker_image() -> String {
"boosapi-comfyui:multi-tenant".to_string()
}
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 {
enabled: false,
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,
python_bin: "python3".to_string(),
port: 8188,
@@ -73,12 +194,121 @@ impl Default for ComfyUIConfig {
max_restarts: 5,
external_url: None,
base_url: Some("http://127.0.0.1:8188".to_string()),
api_key: None,
agent_model: "gpt-4o".to_string(),
}
}
}
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(),
min_amount_usd: 1.0,
max_amount_usd: Some(100_000.0),
daily_limit_usd: None,
order_timeout_minutes: 30,
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(),
}
}
}
impl ComfyUIConfig {
pub fn uses_native_backend(&self) -> bool {
matches!(
self.backend,
ComfyUIBackend::External | ComfyUIBackend::Cloud | ComfyUIBackend::Runninghub
)
}
pub fn uses_cloud_backend(&self) -> bool {
matches!(self.backend, ComfyUIBackend::Cloud)
}
pub fn uses_runninghub_backend(&self) -> bool {
matches!(self.backend, ComfyUIBackend::Runninghub)
}
pub fn uses_docker_backend(&self) -> bool {
matches!(self.backend, ComfyUIBackend::Docker)
}
pub fn target_base_url(&self) -> String {
self.external_url
.as_deref()
@@ -93,9 +323,13 @@ impl ComfyUIConfig {
impl AppConfig {
pub fn load() -> anyhow::Result<Self> {
Self::load_from_file("config.toml")
}
pub fn load_from_file(path: &str) -> anyhow::Result<Self> {
dotenvy::dotenv().ok();
Ok(Figment::new()
.merge(Toml::file("config.toml"))
.merge(Toml::file(path))
.merge(Env::prefixed("BOOSAPI_").split("__"))
.extract()?)
}
+118 -1
View File
@@ -1,12 +1,129 @@
use sqlx::sqlite::{SqlitePool, SqlitePoolOptions};
use anyhow::{bail, Context};
use serde::Serialize;
use sqlx::{
any::{install_default_drivers, AnyPoolOptions},
postgres::{PgPool, PgPoolOptions},
sqlite::{SqlitePool, SqlitePoolOptions},
};
pub mod models;
pub mod queries;
pub mod schema_compat;
pub mod sqlite_to_postgres;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum DbKind {
Sqlite,
Postgres,
}
impl DbKind {
pub fn from_url(database_url: &str) -> anyhow::Result<Self> {
let normalized = database_url.trim().to_ascii_lowercase();
if normalized.starts_with("sqlite:") {
Ok(Self::Sqlite)
} else if normalized.starts_with("postgres://") || normalized.starts_with("postgresql://") {
Ok(Self::Postgres)
} else {
bail!("unsupported database URL; expected sqlite:, postgres:// or postgresql://")
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Sqlite => "sqlite",
Self::Postgres => "postgres",
}
}
}
pub async fn create_pool(database_url: &str, max_connections: u32) -> anyhow::Result<SqlitePool> {
if DbKind::from_url(database_url)? != DbKind::Sqlite {
bail!(
"runtime PostgreSQL query support is not enabled yet; use migrate-sqlite-to-postgres to prepare PostgreSQL data"
);
}
let pool = SqlitePoolOptions::new()
.max_connections(max_connections)
.after_connect(|conn, _meta| {
Box::pin(async move {
sqlx::query("PRAGMA journal_mode = WAL")
.execute(&mut *conn)
.await?;
sqlx::query("PRAGMA synchronous = NORMAL")
.execute(&mut *conn)
.await?;
sqlx::query("PRAGMA busy_timeout = 5000")
.execute(&mut *conn)
.await?;
sqlx::query("PRAGMA foreign_keys = ON")
.execute(&mut *conn)
.await?;
Ok(())
})
})
.connect(database_url)
.await?;
Ok(pool)
}
pub async fn create_pg_pool(database_url: &str, max_connections: u32) -> anyhow::Result<PgPool> {
let pool = PgPoolOptions::new()
.max_connections(max_connections)
.connect(database_url)
.await
.context("connect PostgreSQL")?;
Ok(pool)
}
pub async fn test_connection(database_url: &str, max_connections: u32) -> anyhow::Result<DbKind> {
match DbKind::from_url(database_url)? {
DbKind::Sqlite => {
SqlitePoolOptions::new()
.max_connections(max_connections)
.connect(database_url)
.await
.context("connect SQLite")?
.close()
.await;
Ok(DbKind::Sqlite)
}
DbKind::Postgres => {
create_pg_pool(database_url, max_connections)
.await?
.close()
.await;
Ok(DbKind::Postgres)
}
}
}
pub async fn run_migrations(
kind: DbKind,
sqlite_pool: Option<&SqlitePool>,
pg_pool: Option<&PgPool>,
) -> anyhow::Result<()> {
match kind {
DbKind::Sqlite => {
let pool = sqlite_pool.context("SQLite pool required for SQLite migrations")?;
sqlx::migrate!("./migrations").run(pool).await?;
}
DbKind::Postgres => {
let pool = pg_pool.context("PostgreSQL pool required for PostgreSQL migrations")?;
sqlx::migrate!("./migrations_pg").run(pool).await?;
}
}
Ok(())
}
pub async fn create_any_pool(
database_url: &str,
max_connections: u32,
) -> anyhow::Result<sqlx::AnyPool> {
install_default_drivers();
Ok(AnyPoolOptions::new()
.max_connections(max_connections)
.connect(database_url)
.await?)
}
+2
View File
@@ -3,6 +3,7 @@ use sqlx::FromRow;
/// Represents an account bound to a site.
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct Account {
pub id: i64,
pub site_id: i64,
@@ -30,6 +31,7 @@ pub struct Account {
/// Represents an account token (from account_tokens table).
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct AccountToken {
pub id: i64,
pub account_id: i64,
+1
View File
@@ -3,6 +3,7 @@ use sqlx::FromRow;
/// Background task for tracking async operations (deployments, syncs, etc.)
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct BackgroundTask {
pub id: String,
pub task_type: String,
+1
View File
@@ -3,6 +3,7 @@ use sqlx::FromRow;
/// Represents a downstream API key for external consumers.
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct DownstreamApiKey {
pub id: i64,
pub name: String,
+1
View File
@@ -3,6 +3,7 @@ use sqlx::FromRow;
/// System event for admin notifications.
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct Event {
pub id: i64,
#[sqlx(rename = "type")]
+3
View File
@@ -3,6 +3,7 @@ use sqlx::FromRow;
/// Organization for multi-tenant setup.
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct Organization {
pub id: i64,
pub name: String,
@@ -15,6 +16,7 @@ pub struct Organization {
/// Member of an organization.
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct OrganizationMember {
pub id: i64,
pub org_id: i64,
@@ -26,6 +28,7 @@ pub struct OrganizationMember {
/// Invitation to join an organization.
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct Invitation {
pub id: i64,
pub org_id: i64,
+1
View File
@@ -3,6 +3,7 @@ use sqlx::FromRow;
/// Represents a route channel linking routes, accounts, and tokens.
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct RouteChannel {
pub id: i64,
pub route_id: i64,
+1
View File
@@ -3,6 +3,7 @@ use sqlx::FromRow;
/// Represents a site (AI provider endpoint).
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct Site {
pub id: i64,
pub name: String,
+1
View File
@@ -3,6 +3,7 @@ use sqlx::FromRow;
/// Site-level announcements (notifications from upstream providers).
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct SiteAnnouncement {
pub id: i64,
pub site_id: i64,
+1
View File
@@ -3,6 +3,7 @@ use sqlx::FromRow;
/// Represents a token route (model routing rule).
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct TokenRoute {
pub id: i64,
pub model_pattern: String,
+1
View File
@@ -3,6 +3,7 @@ use sqlx::FromRow;
/// A platform user (admin or regular).
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct User {
pub id: i64,
pub username: String,
+383
View File
@@ -0,0 +1,383 @@
use anyhow::Context;
use sqlx::{Row, SqlitePool};
use std::collections::HashSet;
pub async fn ensure_sqlite_upstream_compat(pool: &SqlitePool) -> anyhow::Result<()> {
ensure_columns(
pool,
"sites",
&[
("post_refresh_probe_enabled", "INTEGER DEFAULT false"),
("post_refresh_probe_model", "TEXT DEFAULT ''"),
("post_refresh_probe_scope", "TEXT DEFAULT 'single'"),
(
"post_refresh_probe_latency_threshold_ms",
"INTEGER DEFAULT 0",
),
],
)
.await?;
ensure_columns(
pool,
"downstream_api_keys",
&[
("excluded_site_ids", "TEXT"),
("excluded_credential_refs", "TEXT"),
],
)
.await?;
ensure_columns(
pool,
"route_channels",
&[("oauth_route_unit_id", "INTEGER")],
)
.await?;
ensure_columns(
pool,
"proxy_logs",
&[
("client_family", "TEXT"),
("client_app_id", "TEXT"),
("client_app_name", "TEXT"),
("client_confidence", "TEXT"),
("is_stream", "INTEGER"),
("first_byte_latency_ms", "INTEGER"),
("billing_details", "TEXT"),
("content_summary", "TEXT"),
("request_content", "TEXT"),
("response_content", "TEXT"),
],
)
.await?;
run_compat_ddl(pool).await?;
Ok(())
}
async fn ensure_columns(
pool: &SqlitePool,
table: &str,
columns: &[(&str, &str)],
) -> anyhow::Result<()> {
let existing = table_columns(pool, table).await?;
if existing.is_empty() {
return Ok(());
}
for (name, definition) in columns {
if existing.contains(*name) {
continue;
}
let sql = format!("ALTER TABLE {table} ADD {name} {definition}");
sqlx::query(&sql)
.execute(pool)
.await
.with_context(|| format!("add column {table}.{name}"))?;
}
Ok(())
}
async fn table_columns(pool: &SqlitePool, table: &str) -> anyhow::Result<HashSet<String>> {
let sql = format!("PRAGMA table_info({table})");
let rows = sqlx::query(&sql).fetch_all(pool).await?;
Ok(rows
.into_iter()
.filter_map(|row| row.try_get::<String, _>("name").ok())
.collect())
}
async fn run_compat_ddl(pool: &SqlitePool) -> anyhow::Result<()> {
for statement in SQLITE_COMPAT_DDL {
sqlx::query(statement)
.execute(pool)
.await
.with_context(|| format!("run SQLite compat DDL: {statement}"))?;
}
Ok(())
}
const SQLITE_COMPAT_DDL: &[&str] = &[
"CREATE INDEX IF NOT EXISTS proxy_logs_client_app_id_created_at_idx
ON proxy_logs (client_app_id, created_at)",
"CREATE INDEX IF NOT EXISTS proxy_logs_client_family_created_at_idx
ON proxy_logs (client_family, created_at)",
"CREATE INDEX IF NOT EXISTS proxy_logs_key_created_status_idx
ON proxy_logs (downstream_api_key_id, created_at DESC, status)",
"CREATE INDEX IF NOT EXISTS proxy_logs_key_model_created_idx
ON proxy_logs (downstream_api_key_id, model_actual, created_at DESC)",
"CREATE INDEX IF NOT EXISTS proxy_logs_content_summary_idx
ON proxy_logs (content_summary)",
"CREATE INDEX IF NOT EXISTS workspace_assets_user_output_created_idx
ON workspace_assets (user_id, status, deleted_at, source, kind, created_at DESC)",
"CREATE INDEX IF NOT EXISTS workspace_assets_output_role_created_idx
ON workspace_assets (json_extract(metadata, '$.workspaceAsset.role'), created_at DESC)",
"CREATE INDEX IF NOT EXISTS workspace_assets_workflow_output_created_idx
ON workspace_assets (json_extract(metadata, '$.workflowUi.type'), created_at DESC)",
"CREATE TABLE IF NOT EXISTS workspace_asset_daily (
local_day TEXT NOT NULL,
user_id INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL,
kind TEXT NOT NULL,
asset_count INTEGER NOT NULL DEFAULT 0,
local_file_count INTEGER NOT NULL DEFAULT 0,
total_size_bytes INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(local_day, user_id, source, kind)
)",
"CREATE INDEX IF NOT EXISTS workspace_asset_daily_user_day_idx
ON workspace_asset_daily (user_id, local_day DESC)",
"CREATE INDEX IF NOT EXISTS workspace_asset_daily_source_day_idx
ON workspace_asset_daily (source, local_day DESC)",
"CREATE INDEX IF NOT EXISTS route_channels_oauth_route_unit_id_idx
ON route_channels (oauth_route_unit_id)",
"CREATE TABLE IF NOT EXISTS site_api_endpoints (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
site_id INTEGER NOT NULL,
url TEXT NOT NULL,
enabled INTEGER DEFAULT true,
sort_order INTEGER DEFAULT 0,
cooldown_until TEXT,
last_selected_at TEXT,
last_failed_at TEXT,
last_failure_reason TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
)",
"CREATE UNIQUE INDEX IF NOT EXISTS site_api_endpoints_site_url_unique
ON site_api_endpoints (site_id, url)",
"CREATE INDEX IF NOT EXISTS site_api_endpoints_site_enabled_sort_idx
ON site_api_endpoints (site_id, enabled, sort_order)",
"CREATE INDEX IF NOT EXISTS site_api_endpoints_site_cooldown_idx
ON site_api_endpoints (site_id, cooldown_until)",
"CREATE TABLE IF NOT EXISTS proxy_debug_traces (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
downstream_path TEXT NOT NULL,
client_kind TEXT,
session_id TEXT,
trace_hint TEXT,
requested_model TEXT,
downstream_api_key_id INTEGER,
request_headers_json TEXT,
request_body_json TEXT,
sticky_session_key TEXT,
sticky_hit_channel_id INTEGER,
selected_channel_id INTEGER,
selected_route_id INTEGER,
selected_account_id INTEGER,
selected_site_id INTEGER,
selected_site_platform TEXT,
endpoint_candidates_json TEXT,
endpoint_runtime_state_json TEXT,
decision_summary_json TEXT,
final_status TEXT,
final_http_status INTEGER,
final_upstream_path TEXT,
final_response_headers_json TEXT,
final_response_body_json TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)",
"CREATE INDEX IF NOT EXISTS proxy_debug_traces_created_at_idx
ON proxy_debug_traces (created_at)",
"CREATE INDEX IF NOT EXISTS proxy_debug_traces_session_created_at_idx
ON proxy_debug_traces (session_id, created_at)",
"CREATE INDEX IF NOT EXISTS proxy_debug_traces_model_created_at_idx
ON proxy_debug_traces (requested_model, created_at)",
"CREATE INDEX IF NOT EXISTS proxy_debug_traces_final_status_created_at_idx
ON proxy_debug_traces (final_status, created_at)",
"CREATE TABLE IF NOT EXISTS proxy_debug_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
trace_id INTEGER NOT NULL,
attempt_index INTEGER NOT NULL,
endpoint TEXT NOT NULL,
request_path TEXT NOT NULL,
target_url TEXT NOT NULL,
runtime_executor TEXT,
request_headers_json TEXT,
request_body_json TEXT,
response_status INTEGER,
response_headers_json TEXT,
response_body_json TEXT,
raw_error_text TEXT,
recover_applied INTEGER DEFAULT false,
downgrade_decision INTEGER DEFAULT false,
downgrade_reason TEXT,
memory_write_json TEXT,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (trace_id) REFERENCES proxy_debug_traces(id) ON DELETE CASCADE
)",
"CREATE UNIQUE INDEX IF NOT EXISTS proxy_debug_attempts_trace_attempt_unique
ON proxy_debug_attempts (trace_id, attempt_index)",
"CREATE INDEX IF NOT EXISTS proxy_debug_attempts_trace_created_at_idx
ON proxy_debug_attempts (trace_id, created_at)",
"CREATE TABLE IF NOT EXISTS oauth_route_units (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
site_id INTEGER NOT NULL,
provider TEXT NOT NULL,
name TEXT NOT NULL,
strategy TEXT NOT NULL DEFAULT 'round_robin',
enabled INTEGER DEFAULT true,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
)",
"CREATE INDEX IF NOT EXISTS oauth_route_units_site_provider_idx
ON oauth_route_units (site_id, provider)",
"CREATE INDEX IF NOT EXISTS oauth_route_units_enabled_idx
ON oauth_route_units (enabled)",
"CREATE TABLE IF NOT EXISTS oauth_route_unit_members (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
unit_id INTEGER NOT NULL,
account_id INTEGER NOT NULL,
sort_order INTEGER DEFAULT 0,
success_count INTEGER DEFAULT 0,
fail_count INTEGER DEFAULT 0,
total_latency_ms INTEGER DEFAULT 0,
total_cost REAL DEFAULT 0,
last_used_at TEXT,
last_selected_at TEXT,
last_fail_at TEXT,
consecutive_fail_count INTEGER NOT NULL DEFAULT 0,
cooldown_level INTEGER NOT NULL DEFAULT 0,
cooldown_until TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (unit_id) REFERENCES oauth_route_units(id) ON DELETE CASCADE,
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
)",
"CREATE UNIQUE INDEX IF NOT EXISTS oauth_route_unit_members_unit_account_unique
ON oauth_route_unit_members (unit_id, account_id)",
"CREATE UNIQUE INDEX IF NOT EXISTS oauth_route_unit_members_account_unique
ON oauth_route_unit_members (account_id)",
"CREATE INDEX IF NOT EXISTS oauth_route_unit_members_unit_sort_idx
ON oauth_route_unit_members (unit_id, sort_order)",
"CREATE INDEX IF NOT EXISTS oauth_route_unit_members_unit_cooldown_idx
ON oauth_route_unit_members (unit_id, cooldown_until)",
"CREATE TABLE IF NOT EXISTS admin_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
namespace TEXT NOT NULL,
snapshot_key TEXT NOT NULL,
payload TEXT NOT NULL,
generated_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
stale_until TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)",
"CREATE UNIQUE INDEX IF NOT EXISTS admin_snapshots_namespace_key_unique
ON admin_snapshots (namespace, snapshot_key)",
"CREATE INDEX IF NOT EXISTS admin_snapshots_expires_at_idx
ON admin_snapshots (expires_at)",
"CREATE INDEX IF NOT EXISTS admin_snapshots_stale_until_idx
ON admin_snapshots (stale_until)",
"CREATE TABLE IF NOT EXISTS analytics_projection_checkpoints (
projector_key TEXT PRIMARY KEY NOT NULL,
time_zone TEXT NOT NULL DEFAULT 'Local',
last_proxy_log_id INTEGER NOT NULL DEFAULT 0,
watermark_created_at TEXT,
recompute_from_id INTEGER,
recompute_requested_at TEXT,
recompute_reason TEXT,
recompute_started_at TEXT,
recompute_completed_at TEXT,
last_projected_at TEXT,
last_successful_at TEXT,
last_error TEXT,
lease_owner TEXT,
lease_token TEXT,
lease_expires_at TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)",
"CREATE INDEX IF NOT EXISTS analytics_projection_checkpoints_recompute_from_id_idx
ON analytics_projection_checkpoints (recompute_from_id)",
"CREATE INDEX IF NOT EXISTS analytics_projection_checkpoints_lease_expires_at_idx
ON analytics_projection_checkpoints (lease_expires_at)",
"CREATE TABLE IF NOT EXISTS model_day_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
local_day TEXT NOT NULL,
site_id INTEGER NOT NULL,
model TEXT NOT NULL,
total_calls INTEGER NOT NULL DEFAULT 0,
success_calls INTEGER NOT NULL DEFAULT 0,
failed_calls INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
total_spend REAL NOT NULL DEFAULT 0,
total_latency_ms INTEGER NOT NULL DEFAULT 0,
latency_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
CHECK (
total_calls >= 0 AND success_calls >= 0 AND failed_calls >= 0
AND total_tokens >= 0 AND total_spend >= 0
AND total_latency_ms >= 0 AND latency_count >= 0
)
)",
"CREATE UNIQUE INDEX IF NOT EXISTS model_day_usage_day_site_model_unique
ON model_day_usage (local_day, site_id, model)",
"CREATE INDEX IF NOT EXISTS model_day_usage_day_idx
ON model_day_usage (local_day)",
"CREATE INDEX IF NOT EXISTS model_day_usage_site_id_idx
ON model_day_usage (site_id)",
"CREATE INDEX IF NOT EXISTS model_day_usage_model_idx
ON model_day_usage (model)",
"CREATE TABLE IF NOT EXISTS site_day_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
local_day TEXT NOT NULL,
site_id INTEGER NOT NULL,
total_calls INTEGER NOT NULL DEFAULT 0,
success_calls INTEGER NOT NULL DEFAULT 0,
failed_calls INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
total_summary_spend REAL NOT NULL DEFAULT 0,
total_site_spend REAL NOT NULL DEFAULT 0,
total_latency_ms INTEGER NOT NULL DEFAULT 0,
latency_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
CHECK (
total_calls >= 0 AND success_calls >= 0 AND failed_calls >= 0
AND total_tokens >= 0 AND total_summary_spend >= 0
AND total_site_spend >= 0 AND total_latency_ms >= 0
AND latency_count >= 0
)
)",
"CREATE UNIQUE INDEX IF NOT EXISTS site_day_usage_day_site_unique
ON site_day_usage (local_day, site_id)",
"CREATE INDEX IF NOT EXISTS site_day_usage_day_idx
ON site_day_usage (local_day)",
"CREATE INDEX IF NOT EXISTS site_day_usage_site_id_idx
ON site_day_usage (site_id)",
"CREATE TABLE IF NOT EXISTS site_hour_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
bucket_start_utc TEXT NOT NULL,
site_id INTEGER NOT NULL,
total_calls INTEGER NOT NULL DEFAULT 0,
success_calls INTEGER NOT NULL DEFAULT 0,
failed_calls INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
total_summary_spend REAL NOT NULL DEFAULT 0,
total_site_spend REAL NOT NULL DEFAULT 0,
total_latency_ms INTEGER NOT NULL DEFAULT 0,
latency_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
CHECK (
total_calls >= 0 AND success_calls >= 0 AND failed_calls >= 0
AND total_tokens >= 0 AND total_summary_spend >= 0
AND total_site_spend >= 0 AND total_latency_ms >= 0
AND latency_count >= 0
)
)",
"CREATE UNIQUE INDEX IF NOT EXISTS site_hour_usage_hour_site_unique
ON site_hour_usage (bucket_start_utc, site_id)",
"CREATE INDEX IF NOT EXISTS site_hour_usage_hour_idx
ON site_hour_usage (bucket_start_utc)",
"CREATE INDEX IF NOT EXISTS site_hour_usage_site_id_idx
ON site_hour_usage (site_id)",
];
+657
View File
@@ -0,0 +1,657 @@
use anyhow::{bail, Context};
use serde::Serialize;
use sqlx::{
postgres::PgPool,
sqlite::{SqlitePool, SqliteRow},
Row,
};
use std::collections::HashMap;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MigrationSummary {
pub tables: Vec<TableMigrationSummary>,
pub total_rows: i64,
pub force_truncate: bool,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TableMigrationSummary {
pub table: &'static str,
pub rows: i64,
}
#[derive(Debug, Clone, Copy)]
enum ColumnKind {
I64,
F64,
Bool,
Text,
}
#[derive(Debug, Clone, Copy)]
struct ColumnSpec {
name: &'static str,
kind: ColumnKind,
}
#[derive(Debug, Clone, Copy)]
struct TableSpec {
name: &'static str,
columns: &'static [ColumnSpec],
}
macro_rules! col {
($name:literal, i64) => {
ColumnSpec {
name: $name,
kind: ColumnKind::I64,
}
};
($name:literal, f64) => {
ColumnSpec {
name: $name,
kind: ColumnKind::F64,
}
};
($name:literal, bool) => {
ColumnSpec {
name: $name,
kind: ColumnKind::Bool,
}
};
($name:literal, text) => {
ColumnSpec {
name: $name,
kind: ColumnKind::Text,
}
};
}
const TABLES: &[TableSpec] = &[
TableSpec {
name: "users",
columns: &[
col!("id", i64),
col!("username", text),
col!("email", text),
col!("password_hash", text),
col!("role", text),
col!("status", text),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "sites",
columns: &[
col!("id", i64),
col!("name", text),
col!("url", text),
col!("platform", text),
col!("status", text),
col!("api_key", text),
col!("proxy_url", text),
col!("use_system_proxy", bool),
col!("custom_headers", text),
col!("external_checkin_url", text),
col!("global_weight", f64),
col!("post_refresh_probe_enabled", bool),
col!("post_refresh_probe_model", text),
col!("post_refresh_probe_scope", text),
col!("post_refresh_probe_latency_threshold_ms", i64),
col!("is_pinned", bool),
col!("sort_order", i64),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "accounts",
columns: &[
col!("id", i64),
col!("site_id", i64),
col!("username", text),
col!("access_token", text),
col!("api_token", text),
col!("balance", f64),
col!("balance_used", f64),
col!("quota", f64),
col!("unit_cost", f64),
col!("value_score", f64),
col!("status", text),
col!("checkin_enabled", bool),
col!("last_checkin_at", text),
col!("last_balance_refresh", text),
col!("extra_config", text),
col!("is_pinned", bool),
col!("sort_order", i64),
col!("oauth_provider", text),
col!("oauth_account_key", text),
col!("oauth_project_id", text),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "account_tokens",
columns: &[
col!("id", i64),
col!("account_id", i64),
col!("name", text),
col!("token", text),
col!("source", text),
col!("token_group", text),
col!("value_status", text),
col!("enabled", bool),
col!("is_default", bool),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "downstream_api_keys",
columns: &[
col!("id", i64),
col!("name", text),
col!("key", text),
col!("description", text),
col!("group_name", text),
col!("tags", text),
col!("enabled", bool),
col!("expires_at", text),
col!("max_cost", f64),
col!("used_cost", f64),
col!("max_requests", i64),
col!("used_requests", i64),
col!("supported_models", text),
col!("allowed_route_ids", text),
col!("site_weight_multipliers", text),
col!("excluded_site_ids", text),
col!("excluded_credential_refs", text),
col!("last_used_at", text),
col!("user_id", i64),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "token_routes",
columns: &[
col!("id", i64),
col!("model_pattern", text),
col!("model_mapping", text),
col!("display_name", text),
col!("display_icon", text),
col!("decision_snapshot", text),
col!("decision_refreshed_at", text),
col!("routing_strategy", text),
col!("route_mode", text),
col!("enabled", bool),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "route_channels",
columns: &[
col!("id", i64),
col!("route_id", i64),
col!("account_id", i64),
col!("token_id", i64),
col!("oauth_route_unit_id", i64),
col!("source_model", text),
col!("priority", i64),
col!("weight", i64),
col!("enabled", bool),
col!("manual_override", bool),
col!("success_count", i64),
col!("fail_count", i64),
col!("total_latency_ms", i64),
col!("total_cost", f64),
col!("last_used_at", text),
col!("last_selected_at", text),
col!("last_fail_at", text),
col!("consecutive_fail_count", i64),
col!("cooldown_level", i64),
col!("cooldown_until", text),
],
},
TableSpec {
name: "proxy_logs",
columns: &[
col!("id", i64),
col!("route_id", i64),
col!("channel_id", i64),
col!("account_id", i64),
col!("downstream_api_key_id", i64),
col!("model_requested", text),
col!("model_actual", text),
col!("status", text),
col!("http_status", i64),
col!("latency_ms", i64),
col!("first_byte_latency_ms", i64),
col!("prompt_tokens", i64),
col!("completion_tokens", i64),
col!("total_tokens", i64),
col!("estimated_cost", f64),
col!("error_message", text),
col!("retry_count", i64),
col!("is_stream", bool),
col!("client_family", text),
col!("client_app_id", text),
col!("client_app_name", text),
col!("client_confidence", text),
col!("billing_details", text),
col!("created_at", text),
],
},
TableSpec {
name: "settings",
columns: &[col!("key", text), col!("value", text)],
},
TableSpec {
name: "workspace_assets",
columns: &[
col!("id", i64),
col!("public_id", text),
col!("user_id", i64),
col!("source", text),
col!("kind", text),
col!("title", text),
col!("prompt", text),
col!("model", text),
col!("status", text),
col!("mime_type", text),
col!("file_name", text),
col!("file_path", text),
col!("remote_url", text),
col!("thumbnail_path", text),
col!("size_bytes", i64),
col!("metadata", text),
col!("created_at", text),
col!("updated_at", text),
col!("deleted_at", text),
],
},
TableSpec {
name: "user_announcements",
columns: &[
col!("id", i64),
col!("title", text),
col!("content", text),
col!("level", text),
col!("status", text),
col!("audience", text),
col!("starts_at", text),
col!("ends_at", text),
col!("created_at", text),
col!("updated_at", text),
col!("published_at", text),
],
},
TableSpec {
name: "user_announcement_reads",
columns: &[
col!("announcement_id", i64),
col!("user_id", i64),
col!("read_at", text),
],
},
TableSpec {
name: "site_api_endpoints",
columns: &[
col!("id", i64),
col!("site_id", i64),
col!("url", text),
col!("enabled", bool),
col!("sort_order", i64),
col!("cooldown_until", text),
col!("last_selected_at", text),
col!("last_failed_at", text),
col!("last_failure_reason", text),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "oauth_route_units",
columns: &[
col!("id", i64),
col!("site_id", i64),
col!("provider", text),
col!("name", text),
col!("strategy", text),
col!("enabled", bool),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "oauth_route_unit_members",
columns: &[
col!("id", i64),
col!("unit_id", i64),
col!("account_id", i64),
col!("sort_order", i64),
col!("success_count", i64),
col!("fail_count", i64),
col!("total_latency_ms", i64),
col!("total_cost", f64),
col!("last_used_at", text),
col!("last_selected_at", text),
col!("last_fail_at", text),
col!("consecutive_fail_count", i64),
col!("cooldown_level", i64),
col!("cooldown_until", text),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "proxy_debug_traces",
columns: &[
col!("id", i64),
col!("downstream_path", text),
col!("client_kind", text),
col!("session_id", text),
col!("trace_hint", text),
col!("requested_model", text),
col!("downstream_api_key_id", i64),
col!("request_headers_json", text),
col!("request_body_json", text),
col!("sticky_session_key", text),
col!("sticky_hit_channel_id", i64),
col!("selected_channel_id", i64),
col!("selected_route_id", i64),
col!("selected_account_id", i64),
col!("selected_site_id", i64),
col!("selected_site_platform", text),
col!("endpoint_candidates_json", text),
col!("endpoint_runtime_state_json", text),
col!("decision_summary_json", text),
col!("final_status", text),
col!("final_http_status", i64),
col!("final_upstream_path", text),
col!("final_response_headers_json", text),
col!("final_response_body_json", text),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "proxy_debug_attempts",
columns: &[
col!("id", i64),
col!("trace_id", i64),
col!("attempt_index", i64),
col!("endpoint", text),
col!("request_path", text),
col!("target_url", text),
col!("runtime_executor", text),
col!("request_headers_json", text),
col!("request_body_json", text),
col!("response_status", i64),
col!("response_headers_json", text),
col!("response_body_json", text),
col!("raw_error_text", text),
col!("recover_applied", bool),
col!("downgrade_decision", bool),
col!("downgrade_reason", text),
col!("memory_write_json", text),
col!("created_at", text),
],
},
TableSpec {
name: "admin_snapshots",
columns: &[
col!("id", i64),
col!("namespace", text),
col!("snapshot_key", text),
col!("payload", text),
col!("generated_at", text),
col!("expires_at", text),
col!("stale_until", text),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "analytics_projection_checkpoints",
columns: &[
col!("projector_key", text),
col!("time_zone", text),
col!("last_proxy_log_id", i64),
col!("watermark_created_at", text),
col!("recompute_from_id", i64),
col!("recompute_requested_at", text),
col!("recompute_reason", text),
col!("recompute_started_at", text),
col!("recompute_completed_at", text),
col!("last_projected_at", text),
col!("last_successful_at", text),
col!("last_error", text),
col!("lease_owner", text),
col!("lease_token", text),
col!("lease_expires_at", text),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "model_day_usage",
columns: &[
col!("id", i64),
col!("local_day", text),
col!("site_id", i64),
col!("model", text),
col!("total_calls", i64),
col!("success_calls", i64),
col!("failed_calls", i64),
col!("total_tokens", i64),
col!("total_spend", f64),
col!("total_latency_ms", i64),
col!("latency_count", i64),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "site_day_usage",
columns: &[
col!("id", i64),
col!("local_day", text),
col!("site_id", i64),
col!("total_calls", i64),
col!("success_calls", i64),
col!("failed_calls", i64),
col!("total_tokens", i64),
col!("total_summary_spend", f64),
col!("total_site_spend", f64),
col!("total_latency_ms", i64),
col!("latency_count", i64),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "site_hour_usage",
columns: &[
col!("id", i64),
col!("bucket_start_utc", text),
col!("site_id", i64),
col!("total_calls", i64),
col!("success_calls", i64),
col!("failed_calls", i64),
col!("total_tokens", i64),
col!("total_summary_spend", f64),
col!("total_site_spend", f64),
col!("total_latency_ms", i64),
col!("latency_count", i64),
col!("created_at", text),
col!("updated_at", text),
],
},
];
pub async fn migrate(
sqlite: &SqlitePool,
pg: &PgPool,
force_truncate: bool,
) -> anyhow::Result<MigrationSummary> {
ensure_target_is_empty(pg, force_truncate).await?;
if force_truncate {
truncate_target(pg).await?;
}
let mut summaries = Vec::new();
let mut total_rows = 0;
for table in TABLES {
let rows = copy_table(sqlite, pg, table).await?;
summaries.push(TableMigrationSummary {
table: table.name,
rows,
});
total_rows += rows;
}
sync_sequences(pg).await?;
Ok(MigrationSummary {
tables: summaries,
total_rows,
force_truncate,
})
}
async fn ensure_target_is_empty(pg: &PgPool, force_truncate: bool) -> anyhow::Result<()> {
if force_truncate {
return Ok(());
}
let mut non_empty = Vec::new();
for table in TABLES {
let sql = format!("SELECT COUNT(*) FROM {}", table.name);
let count: i64 = sqlx::query_scalar(&sql).fetch_one(pg).await.unwrap_or(0);
if count > 0 {
non_empty.push(format!("{} ({})", table.name, count));
}
}
if !non_empty.is_empty() {
bail!(
"target PostgreSQL database is not empty: {}; rerun with --force-truncate to overwrite",
non_empty.join(", ")
);
}
Ok(())
}
async fn truncate_target(pg: &PgPool) -> anyhow::Result<()> {
let table_names = TABLES
.iter()
.rev()
.map(|table| table.name)
.collect::<Vec<_>>()
.join(", ");
let sql = format!("TRUNCATE TABLE {} RESTART IDENTITY CASCADE", table_names);
sqlx::query(&sql).execute(pg).await?;
Ok(())
}
async fn copy_table(sqlite: &SqlitePool, pg: &PgPool, table: &TableSpec) -> anyhow::Result<i64> {
let available = sqlite_columns(sqlite, table.name).await?;
let columns = table
.columns
.iter()
.copied()
.filter(|column| available.contains_key(column.name))
.collect::<Vec<_>>();
if columns.is_empty() {
return Ok(0);
}
let select_sql = format!(
"SELECT {} FROM {} ORDER BY {}",
columns
.iter()
.map(|column| column.name)
.collect::<Vec<_>>()
.join(", "),
table.name,
if table.name == "settings" {
"key"
} else {
columns[0].name
}
);
let rows = sqlx::query(&select_sql).fetch_all(sqlite).await?;
let insert_sql = format!(
"INSERT INTO {} ({}) VALUES ({})",
table.name,
columns
.iter()
.map(|column| quote_ident(column.name))
.collect::<Vec<_>>()
.join(", "),
(1..=columns.len())
.map(|index| format!("${index}"))
.collect::<Vec<_>>()
.join(", ")
);
for row in &rows {
let mut query = sqlx::query(&insert_sql);
for column in &columns {
query = bind_column(query, row, column)?;
}
query
.execute(pg)
.await
.with_context(|| format!("insert into {}", table.name))?;
}
Ok(rows.len() as i64)
}
fn bind_column<'q>(
query: sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>,
row: &SqliteRow,
column: &ColumnSpec,
) -> anyhow::Result<sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>> {
Ok(match column.kind {
ColumnKind::I64 => query.bind(row.try_get::<Option<i64>, _>(column.name)?),
ColumnKind::F64 => query.bind(row.try_get::<Option<f64>, _>(column.name)?),
ColumnKind::Bool => query.bind(row.try_get::<Option<bool>, _>(column.name)?),
ColumnKind::Text => query.bind(row.try_get::<Option<String>, _>(column.name)?),
})
}
async fn sqlite_columns(sqlite: &SqlitePool, table: &str) -> anyhow::Result<HashMap<String, ()>> {
let rows = sqlx::query(&format!("PRAGMA table_info({table})"))
.fetch_all(sqlite)
.await?;
Ok(rows
.into_iter()
.filter_map(|row| row.try_get::<String, _>("name").ok())
.map(|name| (name, ()))
.collect())
}
async fn sync_sequences(pg: &PgPool) -> anyhow::Result<()> {
for table in TABLES
.iter()
.filter(|table| table.columns.iter().any(|column| column.name == "id"))
{
let sql = format!(
"SELECT setval(pg_get_serial_sequence('{table}', 'id'), COALESCE((SELECT MAX(id) FROM {table}), 1), (SELECT COUNT(*) FROM {table}) > 0)",
table = table.name
);
let _ = sqlx::query(&sql).execute(pg).await;
}
Ok(())
}
fn quote_ident(name: &str) -> String {
if name == "read" {
"\"read\"".to_string()
} else {
name.to_string()
}
}
+34 -1
View File
@@ -24,6 +24,18 @@ pub enum AppError {
#[error("Rate limited")]
RateLimited,
#[error("Rate limited: {message}")]
RateLimitedWithRetry {
message: String,
retry_after_secs: u64,
},
#[error("Service unavailable: {0}")]
ServiceUnavailable(String),
#[error("Gateway timeout: {0}")]
GatewayTimeout(String),
#[error("Internal error: {0}")]
Internal(#[from] anyhow::Error),
@@ -40,16 +52,37 @@ impl IntoResponse for AppError {
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
AppError::UpstreamError(msg) => (StatusCode::BAD_GATEWAY, msg.clone()),
AppError::RateLimited => (StatusCode::TOO_MANY_REQUESTS, "rate_limited".into()),
AppError::RateLimitedWithRetry { message, .. } => {
(StatusCode::TOO_MANY_REQUESTS, message.clone())
}
AppError::ServiceUnavailable(msg) => (StatusCode::SERVICE_UNAVAILABLE, msg.clone()),
AppError::GatewayTimeout(msg) => (StatusCode::GATEWAY_TIMEOUT, msg.clone()),
AppError::Internal(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
AppError::Database(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
};
let retry_after_secs = match &self {
AppError::RateLimitedWithRetry {
retry_after_secs, ..
} => Some(*retry_after_secs),
_ => None,
};
let billing = match &self {
AppError::RateLimited | AppError::RateLimitedWithRetry { .. } => Some(json!({
"reason": "insufficient_quota",
"rechargePath": "/user/recharge",
"usagePath": "/user/usage",
})),
_ => None,
};
(
status,
Json(json!({
"error": {
"message": message,
"type": status.canonical_reason(),
}
},
"retry_after_secs": retry_after_secs,
"billing": billing,
})),
)
.into_response()
+40 -4
View File
@@ -6,36 +6,72 @@ pub mod proxy;
pub mod routes;
pub mod services;
pub mod shared;
pub mod template_http;
pub mod transformers;
pub mod workflow;
use sqlx::SqlitePool;
use std::sync::Arc;
use std::{sync::Arc, time::Duration};
#[derive(Clone)]
pub struct AppState {
pub config: config::AppConfig,
pub db: Arc<SqlitePool>,
pub http: reqwest::Client,
pub comfyui: Arc<services::comfyui_process::ComfyUIProcess>,
pub builtin_keys: Arc<services::builtin_keys::BuiltinKeys>,
pub runtime_auth: Arc<services::runtime_auth::RuntimeAuthSettings>,
pub register_captcha: Arc<services::register_captcha::RegisterCaptchaStore>,
pub registration_limiter: Arc<services::registration_limiter::RegistrationLimiter>,
}
impl AppState {
pub fn new(config: config::AppConfig, db: SqlitePool) -> Self {
pub fn new(
config: config::AppConfig,
db: SqlitePool,
builtin_keys: services::builtin_keys::BuiltinKeys,
runtime_auth: services::runtime_auth::RuntimeAuthSettings,
) -> Self {
let boosapi_host = if config.server.host == "0.0.0.0" || config.server.host == "::" {
"127.0.0.1".to_string()
} else {
config.server.host.clone()
};
let boosapi_url = format!("http://{}:{}", boosapi_host, config.server.port);
let comfyui_agent_secret = builtin_keys.comfyui_agent.1.clone();
let comfyui = services::comfyui_process::ComfyUIProcess::new(
config.comfyui.clone(),
boosapi_url,
config.auth.proxy_token.clone(),
boosapi_url.clone(),
runtime_auth.proxy_token(),
comfyui_agent_secret.clone(),
);
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(config.proxy.upstream_timeout_secs.max(30)))
.connect_timeout(Duration::from_secs(15))
.pool_idle_timeout(Duration::from_secs(90))
.pool_max_idle_per_host(64)
.tcp_nodelay(true)
.build()
.unwrap_or_else(|err| {
tracing::warn!(%err, "failed to build shared HTTP client; falling back to reqwest default");
reqwest::Client::new()
});
Self {
config,
db: Arc::new(db),
http,
comfyui,
builtin_keys: Arc::new(builtin_keys),
runtime_auth: Arc::new(runtime_auth),
register_captcha: Arc::new(services::register_captcha::RegisterCaptchaStore::new()),
registration_limiter: Arc::new(
services::registration_limiter::RegistrationLimiter::new(),
),
}
}
pub fn runtime_proxy_token(&self) -> String {
self.runtime_auth.proxy_token()
}
}
+153 -11
View File
@@ -1,30 +1,158 @@
use anyhow::Context;
use axum::serve;
use boosapi::{config::AppConfig, db, AppState};
use boosapi::{
config::AppConfig,
db,
services::{builtin_keys, runtime_auth},
AppState,
};
use clap::{Parser, Subcommand};
use std::ffi::OsString;
use std::net::SocketAddr;
use tokio::signal;
use tracing_subscriber::EnvFilter;
use tracing_subscriber::{prelude::*, EnvFilter};
#[derive(Parser, Debug)]
#[command(
name = "boosapi",
version,
author,
about = "AI API Gateway — Rust rewrite of BoosAPI"
)]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
/// Config file path
#[arg(short, long, default_value = "config.toml")]
config: String,
/// Host to bind to
#[arg(long)]
host: Option<String>,
/// Port to listen on
#[arg(short, long)]
port: Option<u16>,
/// Log level (trace, debug, info, warn, error)
#[arg(long)]
log_level: Option<String>,
/// Only run database migrations and exit
#[arg(long)]
migrate_only: bool,
/// Load and validate configuration, then exit
#[arg(long)]
check_config: bool,
}
#[derive(Subcommand, Debug)]
enum Commands {
/// Copy core data from SQLite into PostgreSQL and exit
MigrateSqliteToPostgres {
/// Source SQLite URL, e.g. sqlite:boosapi.db?mode=rwc
#[arg(long)]
sqlite_url: String,
/// Target PostgreSQL URL, e.g. postgres://user:pass@host:5432/boosapi
#[arg(long)]
postgres_url: String,
/// Truncate target business tables before importing
#[arg(long)]
force_truncate: bool,
},
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Initialize tracing
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.init();
let args = Cli::parse_from(normalize_legacy_args());
if let Some(Commands::MigrateSqliteToPostgres {
sqlite_url,
postgres_url,
force_truncate,
}) = &args.command
{
let sqlite = db::create_pool(sqlite_url, 1).await?;
let pg = db::create_pg_pool(postgres_url, 2).await?;
db::run_migrations(db::DbKind::Postgres, None, Some(&pg)).await?;
let summary = db::sqlite_to_postgres::migrate(&sqlite, &pg, *force_truncate).await?;
println!("{}", serde_json::to_string_pretty(&summary)?);
return Ok(());
}
// Load config
let config = AppConfig::load()?;
tracing::info!("Loaded configuration");
let mut config = AppConfig::load_from_file(&args.config)?;
// Apply CLI overrides
if let Some(host) = args.host {
config.server.host = host;
}
if let Some(port) = args.port {
config.server.port = port;
}
if let Some(level) = args.log_level {
config.server.log_level = level;
}
if args.check_config {
println!("Configuration is valid.");
println!("Server: {}:{}", config.server.host, config.server.port);
println!("Database URL: {}", config.db.url);
return Ok(());
}
// Initialize tracing
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new(&config.server.log_level));
tracing_subscriber::registry()
.with(filter)
.with(tracing_subscriber::fmt::layer())
.with(boosapi::services::runtime_logs::RuntimeLogLayer)
.init();
tracing::info!("Loaded configuration from {}", args.config);
let db_kind = db::DbKind::from_url(&config.db.url)?;
// Create database pool
let pool = db::create_pool(&config.db.url, config.db.max_connections).await?;
tracing::info!("Database connected");
// Run migrations
sqlx::migrate!("./migrations").run(&pool).await?;
db::run_migrations(db_kind, Some(&pool), None).await?;
db::schema_compat::ensure_sqlite_upstream_compat(&pool).await?;
tracing::info!("Database migrations applied");
if args.migrate_only {
tracing::info!("Migration only mode requested. Exiting.");
return Ok(());
}
// Bootstrap built-in system keys (idempotent).
let comfyui_agent_key = builtin_keys::ensure_comfyui_agent_key(&pool)
.await
.context("failed to bootstrap built-in ComfyUI Agent key")?;
tracing::info!(
key_id = comfyui_agent_key.0,
"Built-in ComfyUI Agent downstream key ready"
);
let builtin = builtin_keys::BuiltinKeys {
comfyui_agent: comfyui_agent_key,
};
let runtime_auth = runtime_auth::RuntimeAuthSettings::load(
&pool,
config.auth.proxy_token.clone(),
)
.await
.context("failed to load runtime auth settings")?;
// Build app state
let state = AppState::new(config.clone(), pool);
let state = AppState::new(config.clone(), pool, builtin, runtime_auth);
if let Err(err) = state.comfyui.start().await {
tracing::warn!(%err, "ComfyUI embedded process did not start");
}
@@ -39,7 +167,7 @@ async fn main() -> anyhow::Result<()> {
serve(
tokio::net::TcpListener::bind(addr).await?,
app.into_make_service(),
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown(shutdown_signal())
.await?;
@@ -51,6 +179,20 @@ async fn main() -> anyhow::Result<()> {
Ok(())
}
fn normalize_legacy_args() -> Vec<OsString> {
std::env::args_os()
.map(|arg| {
if arg == "-help" {
OsString::from("--help")
} else if arg == "-version" {
OsString::from("--version")
} else {
arg
}
})
.collect()
}
async fn shutdown_signal() {
let ctrl_c = async {
signal::ctrl_c()
+1
View File
@@ -267,6 +267,7 @@ mod tests {
route_id: Some(10),
site_id: 20,
account_id: Some(30),
account_unit_cost: None,
base_url: "https://example.test/v1".into(),
api_key: "key".into(),
model: "gpt-5.5".into(),
+1
View File
@@ -11,6 +11,7 @@ pub struct SelectedChannel {
pub route_id: Option<i64>,
pub site_id: i64,
pub account_id: Option<i64>,
pub account_unit_cost: Option<f64>,
pub base_url: String,
pub api_key: String,
pub model: String,
+1017 -55
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+297 -7
View File
@@ -27,6 +27,26 @@ pub fn routes() -> Router<AppState> {
"/api/account-tokens/sync-all",
post(sync_all_account_tokens),
)
.route(
"/api/account-tokens/batch",
post(batch_update_account_tokens),
)
.route(
"/api/account-tokens/groups/{account_id}",
get(list_account_token_groups),
)
.route(
"/api/account-tokens/sync/{account_id}",
post(sync_account_tokens),
)
.route(
"/api/account-tokens/{id}/default",
post(set_default_account_token),
)
.route(
"/api/account-tokens/{id}/value",
get(get_account_token_value),
)
.route(
"/api/account-tokens/{id}",
get(get_account_token)
@@ -48,10 +68,15 @@ pub struct CreateAccountTokenRequest {
pub name: Option<String>,
pub token: Option<String>,
pub source: Option<String>,
#[serde(alias = "group")]
pub token_group: Option<String>,
pub value_status: Option<String>,
pub enabled: Option<bool>,
pub is_default: Option<bool>,
pub unlimited_quota: Option<bool>,
pub remain_quota: Option<i64>,
pub expired_time: Option<String>,
pub allow_ips: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -61,12 +86,20 @@ pub struct UpdateAccountTokenRequest {
pub name: Option<String>,
pub token: Option<String>,
pub source: Option<String>,
#[serde(alias = "group")]
pub token_group: Option<String>,
pub value_status: Option<String>,
pub enabled: Option<bool>,
pub is_default: Option<bool>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BatchAccountTokenRequest {
pub ids: Vec<i64>,
pub action: String,
}
/// GET /api/account-tokens?accountId=
pub async fn list_account_tokens(
State(state): State<AppState>,
@@ -119,6 +152,19 @@ pub async fn create_account_token(
.map(|status| status.trim().to_string())
.filter(|status| !status.is_empty())
.unwrap_or_else(|| "valid".to_string());
let token_group = normalize_token_group(payload.token_group);
let source = payload
.source
.map(|source| source.trim().to_string())
.filter(|source| !source.is_empty())
.unwrap_or_else(|| "manual".to_string());
let quota_note = build_token_source_note(
&source,
payload.unlimited_quota,
payload.remain_quota,
payload.expired_time,
payload.allow_ips,
);
let now = Utc::now().to_rfc3339();
@@ -130,8 +176,8 @@ pub async fn create_account_token(
.bind(payload.account_id)
.bind(&name)
.bind(&token)
.bind(&payload.source)
.bind(&payload.token_group)
.bind(&quota_note)
.bind(&token_group)
.bind(&value_status)
.bind(payload.enabled.unwrap_or(true))
.bind(payload.is_default.unwrap_or(false))
@@ -209,6 +255,13 @@ pub async fn update_account_token(
.map(|status| status.trim().to_string())
.filter(|status| !status.is_empty())
.unwrap_or(existing.value_status);
let token_group = payload
.token_group
.map(|group| normalize_token_group(Some(group)));
let source = payload
.source
.map(|source| source.trim().to_string())
.filter(|source| !source.is_empty());
let now = Utc::now().to_rfc3339();
@@ -221,8 +274,8 @@ pub async fn update_account_token(
.bind(account_id)
.bind(&name)
.bind(&token)
.bind(payload.source.or(existing.source))
.bind(payload.token_group.or(existing.token_group))
.bind(source.or(existing.source))
.bind(token_group.or(existing.token_group))
.bind(&value_status)
.bind(payload.enabled.or(existing.enabled))
.bind(payload.is_default.or(existing.is_default))
@@ -263,14 +316,209 @@ pub async fn delete_account_token(
Ok(Json(json!({ "success": true })))
}
/// POST /api/account-tokens/batch
pub async fn batch_update_account_tokens(
State(state): State<AppState>,
Json(payload): Json<BatchAccountTokenRequest>,
) -> AppResult<Json<Value>> {
let action = payload.action.trim().to_ascii_lowercase();
if !matches!(action.as_str(), "enable" | "disable" | "delete") {
return Err(AppError::BadRequest(format!(
"unsupported batch action: {}",
payload.action
)));
}
let mut success_ids = Vec::new();
let mut failed_items = Vec::new();
let now = Utc::now().to_rfc3339();
for id in payload.ids.into_iter().filter(|id| *id > 0) {
let result = match action.as_str() {
"enable" => {
sqlx::query("UPDATE account_tokens SET enabled = 1, updated_at = ? WHERE id = ?")
.bind(&now)
.bind(id)
.execute(&*state.db)
.await
}
"disable" => {
sqlx::query("UPDATE account_tokens SET enabled = 0, updated_at = ? WHERE id = ?")
.bind(&now)
.bind(id)
.execute(&*state.db)
.await
}
"delete" => {
sqlx::query("DELETE FROM account_tokens WHERE id = ?")
.bind(id)
.execute(&*state.db)
.await
}
_ => unreachable!(),
};
match result {
Ok(done) if done.rows_affected() > 0 => success_ids.push(id),
Ok(_) => failed_items.push(json!({
"id": id,
"message": "token not found"
})),
Err(err) => failed_items.push(json!({
"id": id,
"message": err.to_string()
})),
}
}
Ok(Json(json!({
"success": failed_items.is_empty(),
"successIds": success_ids,
"failedItems": failed_items
})))
}
/// GET /api/account-tokens/groups/{account_id}
pub async fn list_account_token_groups(
State(state): State<AppState>,
Path(account_id): Path<i64>,
) -> AppResult<Json<Value>> {
ensure_account_exists(&state, account_id).await?;
let rows: Vec<(Option<String>,)> = sqlx::query_as(
"SELECT DISTINCT token_group FROM account_tokens WHERE account_id = ? ORDER BY token_group",
)
.bind(account_id)
.fetch_all(&*state.db)
.await?;
let mut groups = vec!["default".to_string()];
for (group,) in rows {
let group = normalize_token_group(group);
if !groups.iter().any(|existing| existing == &group) {
groups.push(group);
}
}
Ok(Json(json!({ "groups": groups })))
}
/// POST /api/account-tokens/{id}/default
pub async fn set_default_account_token(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Value>> {
let token: AccountToken =
sqlx::query_as::<_, AccountToken>("SELECT * FROM account_tokens WHERE id = ?")
.bind(id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound(format!("Account token with id {} not found", id)))?;
let group = normalize_token_group(token.token_group.clone());
let now = Utc::now().to_rfc3339();
sqlx::query(
"UPDATE account_tokens SET is_default = 0, updated_at = ? \
WHERE account_id = ? AND COALESCE(NULLIF(TRIM(token_group), ''), 'default') = ?",
)
.bind(&now)
.bind(token.account_id)
.bind(&group)
.execute(&*state.db)
.await?;
sqlx::query("UPDATE account_tokens SET is_default = 1, updated_at = ? WHERE id = ?")
.bind(&now)
.bind(id)
.execute(&*state.db)
.await?;
let updated: AccountToken =
sqlx::query_as::<_, AccountToken>("SELECT * FROM account_tokens WHERE id = ?")
.bind(id)
.fetch_one(&*state.db)
.await?;
Ok(Json(json!({ "data": updated, "success": true })))
}
/// GET /api/account-tokens/{id}/value
pub async fn get_account_token_value(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Value>> {
let token: AccountToken =
sqlx::query_as::<_, AccountToken>("SELECT * FROM account_tokens WHERE id = ?")
.bind(id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound(format!("Account token with id {} not found", id)))?;
if is_masked_token(&token.token) || token.value_status.eq_ignore_ascii_case("masked") {
return Err(AppError::BadRequest(
"当前仅保存了脱敏令牌,无法展开/复制。请在站点重新生成并同步,或手动更新为完整令牌。"
.into(),
));
}
let token_value = token.token;
Ok(Json(json!({
"id": id,
"token": token_value.clone(),
"value": token_value
})))
}
/// POST /api/account-tokens/sync/{account_id}
pub async fn sync_account_tokens(
State(state): State<AppState>,
Path(account_id): Path<i64>,
) -> AppResult<Json<Value>> {
ensure_account_exists(&state, account_id).await?;
Ok(Json(json!({
"status": "skipped",
"accountId": account_id,
"created": 0,
"updated": 0,
"message": "当前 Rust 后端暂未接入上游站点 Token 拉取;可手动新增或编辑 API Token。"
})))
}
/// POST /api/account-tokens/sync-all
pub async fn sync_all_account_tokens() -> (StatusCode, Json<Value>) {
pub async fn sync_all_account_tokens(State(state): State<AppState>) -> (StatusCode, Json<Value>) {
info!("Sync all account tokens requested");
let rows: Vec<(i64, String)> =
sqlx::query_as("SELECT id, COALESCE(name, '') FROM accounts ORDER BY id")
.fetch_all(&*state.db)
.await
.unwrap_or_default();
let results: Vec<Value> = rows
.into_iter()
.map(|(account_id, name)| {
json!({
"status": "skipped",
"accountId": account_id,
"accountName": name,
"created": 0,
"updated": 0,
"message": "当前 Rust 后端暂未接入上游站点 Token 拉取;可手动新增或编辑 API Token。"
})
})
.collect();
(
StatusCode::NOT_IMPLEMENTED,
StatusCode::OK,
Json(json!({
"error": "not implemented"
"status": "skipped",
"results": results,
"created": 0,
"updated": 0,
"message": "当前 Rust 后端暂未接入上游站点 Token 拉取;可手动新增或编辑 API Token。"
})),
)
}
@@ -297,3 +545,45 @@ fn normalize_create_token(token: Option<String>) -> String {
.filter(|token| !token.is_empty())
.unwrap_or_else(|| Uuid::new_v4().to_string())
}
fn normalize_token_group(group: Option<String>) -> String {
group
.map(|group| group.trim().to_string())
.filter(|group| !group.is_empty())
.unwrap_or_else(|| "default".to_string())
}
fn build_token_source_note(
source: &str,
unlimited_quota: Option<bool>,
remain_quota: Option<i64>,
expired_time: Option<String>,
allow_ips: Option<String>,
) -> String {
let mut details = vec![source.trim().to_string()];
if let Some(unlimited) = unlimited_quota {
details.push(format!("unlimited_quota={}", unlimited));
}
if let Some(quota) = remain_quota {
details.push(format!("remain_quota={}", quota));
}
if let Some(expired) = expired_time
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
{
details.push(format!("expired_time={}", expired));
}
if let Some(ips) = allow_ips
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
{
details.push(format!("allow_ips={}", ips));
}
details.join(";")
}
fn is_masked_token(token: &str) -> bool {
let trimmed = token.trim();
trimmed.contains('•') || trimmed.contains('*')
}
File diff suppressed because it is too large Load Diff
+2535 -84
View File
File diff suppressed because it is too large Load Diff
+348 -9
View File
@@ -4,7 +4,7 @@ use crate::{
AppState,
};
use axum::{
extract::{Path, State},
extract::{Path, Query, State},
http::StatusCode,
Json,
};
@@ -13,6 +13,44 @@ use serde_json::{json, Value};
use tracing::info;
use uuid::Uuid;
/// Sentinel name for the built-in ComfyUI Agent downstream key.
///
/// The system row is uniquely identified by `name = SYSTEM_KEY_NAME AND
/// user_id IS NULL` — see `services::builtin_keys::ensure_comfyui_agent_key`.
const SYSTEM_KEY_NAME: &str = "comfyui-agent";
fn is_system_key(name: &str, user_id: Option<i64>) -> bool {
name == SYSTEM_KEY_NAME && user_id.is_none()
}
/// Mask a key down to `••••••••<last4>`. Operates on chars so non-ASCII
/// keys (unlikely but possible) do not panic on byte slicing.
fn mask_key(key: &str) -> String {
let chars: Vec<char> = key.chars().collect();
let last4: String = chars[chars.len().saturating_sub(4)..].iter().collect();
format!("••••••••{}", last4)
}
/// Project a `DownstreamApiKey` row to its admin-list JSON shape, masking the
/// secret and tagging `system: true` for the built-in `comfyui-agent` row.
fn key_to_response(key: DownstreamApiKey) -> Value {
let system = is_system_key(&key.name, key.user_id);
let mut value = serde_json::to_value(&key).unwrap_or_else(|_| json!({}));
if system {
if let Value::Object(ref mut map) = value {
map.insert("system".to_string(), Value::Bool(true));
map.insert("key".to_string(), Value::String(mask_key(&key.key)));
}
}
value
}
#[derive(Debug, Deserialize)]
pub struct ListKeysQuery {
pub q: Option<String>,
pub user_id: Option<i64>,
}
#[derive(Debug, Deserialize)]
pub struct CreateDownstreamKeyRequest {
pub name: String,
@@ -25,6 +63,7 @@ pub struct CreateDownstreamKeyRequest {
pub max_requests: Option<i64>,
pub supported_models: Option<Vec<String>>,
pub allowed_route_ids: Option<String>,
pub user_id: Option<i64>,
}
#[derive(Debug, Deserialize)]
@@ -39,6 +78,7 @@ pub struct UpdateDownstreamKeyRequest {
pub max_requests: Option<i64>,
pub supported_models: Option<Vec<String>>,
pub allowed_route_ids: Option<String>,
pub user_id: Option<i64>,
}
/// Helper to validate models from a JSON string against a Vec<String>
@@ -54,16 +94,36 @@ fn validate_supported_models(models: &[String]) -> AppResult<()> {
}
/// GET /api/downstream-api-keys
pub async fn list_downstream_keys(State(state): State<AppState>) -> AppResult<Json<Value>> {
info!("Listing downstream API keys");
pub async fn list_downstream_keys(
State(state): State<AppState>,
Query(query): Query<ListKeysQuery>,
) -> AppResult<Json<Value>> {
info!("Listing downstream API keys with filter: {:?}", query);
let keys: Vec<DownstreamApiKey> =
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys ORDER BY name")
let q = query.q.unwrap_or_default().trim().to_string();
let q_like = format!("%{}%", q);
let user_id = query.user_id;
let keys: Vec<DownstreamApiKey> = sqlx::query_as::<_, DownstreamApiKey>(
r#"
SELECT * FROM downstream_api_keys
WHERE (? = '' OR name LIKE ? OR key LIKE ? OR tags LIKE ?)
AND (? IS NULL OR user_id = ?)
ORDER BY id DESC
"#,
)
.bind(&q)
.bind(&q_like)
.bind(&q_like)
.bind(&q_like)
.bind(user_id)
.bind(user_id)
.fetch_all(&*state.db)
.await?;
let projected: Vec<Value> = keys.into_iter().map(key_to_response).collect();
Ok(Json(
json!({ "success": true, "items": keys, "data": keys }),
json!({ "success": true, "items": projected, "data": projected }),
))
}
@@ -79,7 +139,11 @@ pub async fn create_downstream_key(
}
// Auto-generate key
let key = format!("sk-{}", Uuid::new_v4().to_string().replace('-', ""));
let key = if payload.user_id.is_some() {
format!("sk-user-{}", Uuid::new_v4().to_string().replace('-', ""))
} else {
format!("sk-{}", Uuid::new_v4().to_string().replace('-', ""))
};
// Serialize supported_models to JSON string if provided
let supported_models: Option<String> = if let Some(ref models) = payload.supported_models {
@@ -95,7 +159,7 @@ pub async fn create_downstream_key(
let enabled = payload.enabled.unwrap_or(true);
let result = sqlx::query(
"INSERT INTO downstream_api_keys (name, key, description, group_name, tags, enabled, expires_at, max_cost, max_requests, supported_models, allowed_route_ids) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
"INSERT INTO downstream_api_keys (name, key, description, group_name, tags, enabled, expires_at, max_cost, max_requests, supported_models, allowed_route_ids, user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
)
.bind(&payload.name)
.bind(&key)
@@ -108,6 +172,7 @@ pub async fn create_downstream_key(
.bind(payload.max_requests)
.bind(&supported_models)
.bind(&payload.allowed_route_ids)
.bind(payload.user_id)
.execute(&*state.db)
.await?;
@@ -138,6 +203,14 @@ pub async fn update_downstream_key(
AppError::NotFound(format!("Downstream key with id {} not found", id))
})?;
// Guard: refuse any request that tries to disable the built-in
// `comfyui-agent` system key (`name='comfyui-agent' AND user_id IS NULL`).
// The agent's outbound calls in Local mode bear this key — disabling it
// would silently break image / TTS / chat for every user.
if is_system_key(&existing.name, existing.user_id) && payload.enabled == Some(false) {
return Err(AppError::Forbidden("system key cannot be disabled".into()));
}
let supported_models: Option<String> = if let Some(ref models) = payload.supported_models {
validate_supported_models(models)?;
Some(
@@ -149,7 +222,7 @@ pub async fn update_downstream_key(
};
sqlx::query(
"UPDATE downstream_api_keys SET name = ?, description = ?, group_name = ?, tags = ?, enabled = ?, expires_at = ?, max_cost = ?, max_requests = ?, supported_models = ?, allowed_route_ids = ?, updated_at = datetime('now') WHERE id = ?"
"UPDATE downstream_api_keys SET name = ?, description = ?, group_name = ?, tags = ?, enabled = ?, expires_at = ?, max_cost = ?, max_requests = ?, supported_models = ?, allowed_route_ids = ?, user_id = ?, updated_at = datetime('now') WHERE id = ?"
)
.bind(payload.name.unwrap_or(existing.name))
.bind(payload.description.or(existing.description))
@@ -161,6 +234,7 @@ pub async fn update_downstream_key(
.bind(payload.max_requests.or(existing.max_requests))
.bind(&supported_models)
.bind(payload.allowed_route_ids.or(existing.allowed_route_ids))
.bind(payload.user_id.or(existing.user_id))
.bind(id)
.execute(&*state.db)
.await?;
@@ -181,6 +255,22 @@ pub async fn delete_downstream_key(
) -> AppResult<Json<Value>> {
info!("Deleting downstream API key: {}", id);
// Pre-fetch so we can refuse the built-in `comfyui-agent` row before
// running the DELETE. Returning 403 instead of silently filtering keeps
// the failure observable to operators who attempt the action.
let existing: DownstreamApiKey =
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys WHERE id = ?")
.bind(id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| {
AppError::NotFound(format!("Downstream key with id {} not found", id))
})?;
if is_system_key(&existing.name, existing.user_id) {
return Err(AppError::Forbidden("system key cannot be deleted".into()));
}
let affected = sqlx::query("DELETE FROM downstream_api_keys WHERE id = ?")
.bind(id)
.execute(&*state.db)
@@ -196,3 +286,252 @@ pub async fn delete_downstream_key(
Ok(Json(json!({ "data": { "deleted": true, "id": id } })))
}
/// POST /api/downstream-api-keys/{id}/reset
pub async fn reset_downstream_key(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Value>> {
info!("Resetting downstream API key: {}", id);
let existing: DownstreamApiKey =
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys WHERE id = ?")
.bind(id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound(format!("Downstream key {} not found", id)))?;
let new_key = if existing.user_id.is_some() {
format!("sk-user-{}", Uuid::new_v4().to_string().replace('-', ""))
} else {
format!("sk-{}", Uuid::new_v4().to_string().replace('-', ""))
};
sqlx::query(
"UPDATE downstream_api_keys SET key = ?, updated_at = datetime('now') WHERE id = ?",
)
.bind(&new_key)
.bind(id)
.execute(&*state.db)
.await?;
let api_key: DownstreamApiKey =
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys WHERE id = ?")
.bind(id)
.fetch_one(&*state.db)
.await?;
Ok(Json(json!({ "data": api_key })))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{AppConfig, AuthConfig, DbConfig, ProxyConfig, ServerConfig};
use crate::services::builtin_keys::BuiltinKeys;
use sqlx::sqlite::SqlitePoolOptions;
async fn test_state() -> AppState {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
sqlx::query(
"CREATE TABLE downstream_api_keys (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
key TEXT NOT NULL,
description TEXT,
group_name TEXT,
tags TEXT,
enabled INTEGER DEFAULT 1,
expires_at TEXT,
max_cost REAL,
used_cost REAL DEFAULT 0,
max_requests INTEGER,
used_requests INTEGER DEFAULT 0,
supported_models TEXT,
allowed_route_ids TEXT,
site_weight_multipliers TEXT,
excluded_site_ids TEXT,
excluded_credential_refs TEXT,
last_used_at TEXT,
user_id INTEGER,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)",
)
.execute(&pool)
.await
.unwrap();
// Row 1: built-in system key (name='comfyui-agent', user_id IS NULL)
sqlx::query(
"INSERT INTO downstream_api_keys (id, name, key, enabled, user_id) VALUES (1, 'comfyui-agent', 'sk-comfyui-abcdef0123456789', 1, NULL)"
)
.execute(&pool)
.await
.unwrap();
// Row 2: regular admin-scoped key
sqlx::query(
"INSERT INTO downstream_api_keys (id, name, key, enabled, user_id) VALUES (2, 'team-prod', 'sk-prod-xyz123', 1, NULL)"
)
.execute(&pool)
.await
.unwrap();
AppState::new(
AppConfig {
server: ServerConfig {
host: "127.0.0.1".into(),
port: 0,
log_level: "info".into(),
cors_origins: vec![],
},
db: DbConfig {
url: "sqlite::memory:".into(),
max_connections: 1,
ssl: false,
runtime_db_url: None,
runtime_db_dialect: None,
runtime_db_ssl: None,
},
auth: AuthConfig {
jwt_secret: "secret".into(),
jwt_expiry_hours: 1,
auth_token: "auth".into(),
proxy_token: "proxy".into(),
},
proxy: ProxyConfig {
upstream_timeout_secs: 30,
first_byte_timeout_secs: 30,
max_retries: 3,
proxy_log_retention_days: 7,
template_proxy_url: None,
},
comfyui: Default::default(),
},
pool,
BuiltinKeys {
comfyui_agent: (1, "sk-comfyui-abcdef0123456789".into()),
},
crate::services::runtime_auth::RuntimeAuthSettings::from_token_for_test("proxy"),
)
}
/// 4.2 — Listing: the system row carries `system: true` and a masked
/// `key`, while regular rows pass through unchanged.
#[tokio::test]
async fn list_masks_system_key_and_passes_regular_keys_through() {
let state = test_state().await;
let resp = list_downstream_keys(
State(state),
Query(ListKeysQuery {
q: None,
user_id: None,
}),
)
.await
.expect("list should succeed");
let items = resp.0.get("items").and_then(Value::as_array).unwrap();
assert_eq!(items.len(), 2);
let system = items
.iter()
.find(|item| item["id"] == 1)
.expect("system row");
assert_eq!(system["system"], Value::Bool(true));
assert_eq!(system["key"], "••••••••6789");
assert_eq!(system["name"], "comfyui-agent");
let regular = items
.iter()
.find(|item| item["id"] == 2)
.expect("regular row");
assert!(
regular.get("system").is_none(),
"regular keys must not carry the system flag"
);
assert_eq!(regular["key"], "sk-prod-xyz123");
}
/// 4.3 — DELETE on the built-in row returns 403 with the expected message.
#[tokio::test]
async fn delete_rejects_system_key_with_forbidden() {
let state = test_state().await;
let result = delete_downstream_key(State(state), Path(1)).await;
match result {
Err(AppError::Forbidden(msg)) => assert_eq!(msg, "system key cannot be deleted"),
other => panic!("expected Forbidden, got {:?}", other),
}
}
/// 4.5 — DELETE on a regular row succeeds (control case).
#[tokio::test]
async fn delete_succeeds_for_regular_key() {
let state = test_state().await;
let resp = delete_downstream_key(State(state.clone()), Path(2))
.await
.expect("delete should succeed for regular key");
assert_eq!(resp.0["data"]["deleted"], Value::Bool(true));
}
/// 4.4 — Setting `enabled: false` on the system row returns 403; the row
/// remains enabled in the DB.
#[tokio::test]
async fn update_rejects_disabling_system_key() {
let state = test_state().await;
let payload = UpdateDownstreamKeyRequest {
name: None,
description: None,
group_name: None,
tags: None,
enabled: Some(false),
expires_at: None,
max_cost: None,
max_requests: None,
supported_models: None,
allowed_route_ids: None,
user_id: None,
};
let result = update_downstream_key(State(state.clone()), Path(1), Json(payload)).await;
match result {
Err(AppError::Forbidden(msg)) => assert_eq!(msg, "system key cannot be disabled"),
other => panic!("expected Forbidden, got {:?}", other),
}
// Confirm the row was not touched.
let still_enabled: Option<bool> =
sqlx::query_scalar("SELECT enabled FROM downstream_api_keys WHERE id = 1")
.fetch_one(&*state.db)
.await
.unwrap();
assert_eq!(still_enabled, Some(true));
}
/// 4.4 — Other field updates on the system row (e.g. `description`) are
/// allowed; only `enabled = false` is gated.
#[tokio::test]
async fn update_allows_non_disable_changes_on_system_key() {
let state = test_state().await;
let payload = UpdateDownstreamKeyRequest {
name: None,
description: Some("internal: do not delete".into()),
group_name: None,
tags: None,
enabled: None,
expires_at: None,
max_cost: None,
max_requests: None,
supported_models: None,
allowed_route_ids: None,
user_id: None,
};
let resp = update_downstream_key(State(state), Path(1), Json(payload))
.await
.expect("non-disable update should pass");
assert_eq!(resp.0["data"]["description"], "internal: do not delete");
}
}
+320 -11
View File
@@ -1,4 +1,5 @@
use crate::{
auth::jwt::validate_jwt,
db::models::setting::Setting,
error::{AppError, AppResult},
AppState,
@@ -8,7 +9,7 @@ use axum::{
http::HeaderMap,
middleware,
response::Response,
routing::{get, put},
routing::{get, patch, post, put},
Json, Router,
};
use serde_json::{json, Value};
@@ -18,11 +19,14 @@ mod account_tokens;
mod accounts;
mod compat;
mod downstream_keys;
mod model_candidates;
pub(crate) mod models_marketplace;
mod sites;
mod tokens;
mod users;
/// Middleware to require admin authentication via config token or admin JWT.
/// 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,
@@ -39,17 +43,13 @@ async fn require_admin_auth(
return Ok(next.run(request).await);
}
match crate::auth::jwt::validate_jwt(&state.config.auth.jwt_secret, auth_header) {
Ok(claims) if claims.role == "admin" => {}
Ok(_) => {
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(),
));
}
Err(_) => {
return Err(AppError::Unauthorized("Invalid admin token".into()));
}
}
Ok(next.run(request).await)
}
@@ -61,11 +61,25 @@ pub fn routes(state: AppState) -> Router<AppState> {
"/api/sites",
get(sites::list_sites).post(sites::create_site),
)
.route("/api/sites/detect", post(sites::detect_site))
.route(
"/api/sites/{id}",
get(sites::get_site)
.put(sites::update_site)
.delete(sites::delete_site),
)
.route(
"/api/sites/{id}/disabled-models",
get(sites::list_site_disabled_models).put(sites::update_site_disabled_models),
)
.route(
"/api/sites/{id}/available-models",
get(sites::list_site_available_models),
)
.route("/api/sites/{id}/probe-now", post(sites::probe_site_now))
.route(
"/api/sites/{id}/probe-stream",
get(sites::probe_site_stream),
);
let accounts_routes = Router::new()
@@ -73,11 +87,33 @@ pub fn routes(state: AppState) -> Router<AppState> {
"/api/accounts",
get(accounts::list_accounts).post(accounts::create_account),
)
.route("/api/accounts/batch", post(accounts::batch_accounts))
.route("/api/accounts/verify-token", post(accounts::verify_token))
.route(
"/api/accounts/health/refresh",
post(accounts::refresh_account_health),
)
.route(
"/api/accounts/{id}",
get(accounts::get_account)
.put(accounts::update_account)
.delete(accounts::delete_account),
)
.route(
"/api/accounts/{id}/models",
get(accounts::get_account_models),
)
.route(
"/api/accounts/{id}/balance",
post(accounts::refresh_account_balance),
)
.route(
"/api/accounts/{id}/models/manual",
post(accounts::add_account_manual_models),
)
.route(
"/api/accounts/{id}/models/refresh",
post(accounts::refresh_account_models),
);
let tokens_routes = Router::new()
@@ -111,15 +147,33 @@ pub fn routes(state: AppState) -> Router<AppState> {
put(downstream_keys::update_downstream_key)
.delete(downstream_keys::delete_downstream_key),
)
.route(
"/api/downstream-api-keys/{id}/reset",
post(downstream_keys::reset_downstream_key),
)
.route(
"/api/downstream-keys/{id}",
put(downstream_keys::update_downstream_key)
.delete(downstream_keys::delete_downstream_key),
)
.route(
"/api/downstream-keys/{id}/reset",
post(downstream_keys::reset_downstream_key),
);
let settings_routes =
Router::new().route("/api/settings", get(get_settings).put(update_setting));
let model_candidates_routes = Router::new().route(
"/api/models/token-candidates",
get(model_candidates::token_candidates),
);
let models_marketplace_routes = Router::new().route(
"/api/models/marketplace",
get(models_marketplace::get_marketplace),
);
let users_routes = Router::new()
.route(
"/api/admin/users",
@@ -131,6 +185,31 @@ pub fn routes(state: AppState) -> Router<AppState> {
.put(users::update_user)
.patch(users::update_user)
.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),
)
.route(
"/api/admin/users/{id}/keys/{key_id}",
patch(users::update_user_key).delete(users::delete_user_key),
)
.route(
"/api/admin/users/{id}/keys/{key_id}/reset",
post(users::reset_user_key),
);
Router::new()
@@ -140,6 +219,8 @@ pub fn routes(state: AppState) -> Router<AppState> {
.merge(downstream_routes)
.merge(settings_routes)
.merge(users_routes)
.merge(model_candidates_routes)
.merge(models_marketplace_routes)
.merge(account_tokens::routes())
.merge(compat::routes())
.route_layer(middleware::from_fn_with_state(state, require_admin_auth))
@@ -147,7 +228,21 @@ pub fn routes(state: AppState) -> Router<AppState> {
// ── Settings handlers ────────────────────────────────────────────────
/// Settings rows whose keys begin with this prefix are managed by
/// `services::builtin_keys` and contain server-issued secrets (e.g. the
/// `comfyui-agent` downstream key). They must never appear in admin
/// list/get/edit responses.
const HIDDEN_SETTINGS_PREFIX: &str = "builtin_key.";
fn is_hidden_setting_key(key: &str) -> bool {
key.starts_with(HIDDEN_SETTINGS_PREFIX)
|| key == crate::services::runtime_auth::PROXY_TOKEN_SETTING_KEY
}
/// GET /api/settings — return all settings as a key-value map.
///
/// Filters out any keys with the `builtin_key.` prefix so the admin UI
/// never sees secrets bootstrapped by `services::builtin_keys`.
async fn get_settings(State(state): State<AppState>) -> AppResult<Json<Value>> {
info!("Getting all settings");
@@ -156,14 +251,21 @@ async fn get_settings(State(state): State<AppState>) -> AppResult<Json<Value>> {
.fetch_all(&*state.db)
.await?;
let map: std::collections::BTreeMap<String, Option<String>> =
rows.into_iter().map(|s| (s.key, s.value)).collect();
let map: std::collections::BTreeMap<String, Option<String>> = rows
.into_iter()
.filter(|s| !is_hidden_setting_key(&s.key))
.map(|s| (s.key, s.value))
.collect();
Ok(Json(json!({ "data": map })))
}
/// PUT /api/settings — upsert a setting by key.
/// Expects JSON body: { "key": "...", "value": "..." }
///
/// Refuses any key with the `builtin_key.` prefix: those rows are owned by
/// the bootstrap path and a stray PUT would clobber the secret used by the
/// ComfyUI Agent's outbound calls.
async fn update_setting(
State(state): State<AppState>,
Json(payload): Json<serde_json::Value>,
@@ -173,6 +275,12 @@ async fn update_setting(
.and_then(|k| k.as_str())
.ok_or_else(|| AppError::BadRequest("key is required".into()))?;
if is_hidden_setting_key(key) {
return Err(AppError::Forbidden(
"system setting cannot be modified".into(),
));
}
let value = payload.get("value").and_then(|v| {
if v.is_null() {
None
@@ -196,3 +304,204 @@ async fn update_setting(
}
})))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::jwt::generate_jwt;
use crate::config::{AppConfig, AuthConfig, DbConfig, ProxyConfig, ServerConfig};
use crate::services::builtin_keys::BuiltinKeys;
use axum::{
body::Body,
http::{Request, StatusCode},
};
use sqlx::sqlite::SqlitePoolOptions;
use tower::ServiceExt as _;
async fn settings_state() -> AppState {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
sqlx::query("CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)")
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO settings (key, value) VALUES ('site.title', 'BoosAPI')")
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO settings (key, value) VALUES ('builtin_key.comfyui_agent.secret', 'sk-comfyui-secret')",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO settings (key, value) VALUES ('runtime.auth.proxy_token', 'sk-runtime-secret')",
)
.execute(&pool)
.await
.unwrap();
AppState::new(
AppConfig {
server: ServerConfig {
host: "127.0.0.1".into(),
port: 0,
log_level: "info".into(),
cors_origins: vec![],
},
db: DbConfig {
url: "sqlite::memory:".into(),
max_connections: 1,
ssl: false,
runtime_db_url: None,
runtime_db_dialect: None,
runtime_db_ssl: None,
},
auth: AuthConfig {
jwt_secret: "secret".into(),
jwt_expiry_hours: 1,
auth_token: "auth".into(),
proxy_token: "proxy".into(),
},
proxy: ProxyConfig {
upstream_timeout_secs: 30,
first_byte_timeout_secs: 30,
max_retries: 3,
proxy_log_retention_days: 7,
template_proxy_url: None,
},
comfyui: Default::default(),
pixelle_video: Default::default(),
payment: Default::default(),
},
pool,
BuiltinKeys {
comfyui_agent: (1, "sk-comfyui-secret".into()),
},
crate::services::runtime_auth::RuntimeAuthSettings::from_token_for_test("proxy"),
)
}
/// 5.2 — `GET /api/settings` returns regular settings but never protected
/// secret rows.
#[tokio::test]
async fn list_settings_filters_builtin_key_prefix() {
let state = settings_state().await;
let resp = get_settings(State(state)).await.expect("list ok");
let data = resp.0.get("data").and_then(Value::as_object).unwrap();
assert!(data.contains_key("site.title"));
assert!(
!data.contains_key("builtin_key.comfyui_agent.secret"),
"builtin_key.* rows must not appear in /api/settings response"
);
assert!(
!data.contains_key("runtime.auth.proxy_token"),
"runtime proxy token must not appear in /api/settings response"
);
}
/// 5.3 — `PUT /api/settings` rejects writes targeting `builtin_key.*`
/// keys with 403; the row is left untouched.
#[tokio::test]
async fn update_settings_rejects_builtin_key_writes() {
let state = settings_state().await;
let payload = json!({
"key": "builtin_key.comfyui_agent.secret",
"value": "sk-attacker"
});
let result = update_setting(State(state.clone()), Json(payload)).await;
match result {
Err(AppError::Forbidden(msg)) => {
assert_eq!(msg, "system setting cannot be modified");
}
other => panic!("expected Forbidden, got {:?}", other),
}
let stored: Option<String> = sqlx::query_scalar(
"SELECT value FROM settings WHERE key = 'builtin_key.comfyui_agent.secret'",
)
.fetch_one(&*state.db)
.await
.unwrap();
assert_eq!(stored.as_deref(), Some("sk-comfyui-secret"));
}
/// 5.3 — Regular settings still upsert as before.
#[tokio::test]
async fn update_settings_allows_regular_keys() {
let state = settings_state().await;
let payload = json!({ "key": "site.title", "value": "Renamed" });
let resp = update_setting(State(state.clone()), Json(payload))
.await
.expect("update ok");
assert_eq!(resp.0["data"]["value"], "Renamed");
}
#[tokio::test]
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());
let token_response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/settings/auth/info")
.header("Authorization", "Bearer auth")
.body(Body::empty())
.unwrap(),
)
.await
.expect("token request");
assert_eq!(token_response.status(), StatusCode::OK);
// 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(
Request::builder()
.uri("/api/settings/auth/info")
.header("Authorization", format!("Bearer {admin_jwt}"))
.body(Body::empty())
.unwrap(),
)
.await
.expect("jwt request");
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(
Request::builder()
.uri("/api/settings/auth/info")
.body(Body::empty())
.unwrap(),
)
.await
.expect("missing auth request");
assert_eq!(missing_response.status(), StatusCode::UNAUTHORIZED);
}
}
+179
View File
@@ -0,0 +1,179 @@
//! `GET /api/models/token-candidates` — feeds the frontend "candidate models"
//! panel and the new-route dialog dropdown (`TokenRoutes.tsx:252`).
//!
//! Response shape (must match the legacy Node handler):
//!
//! ```json
//! {
//! "models": {
//! "<modelName>": [
//! { "accountId", "tokenId", "tokenName", "isDefault",
//! "username", "siteId", "siteName" }, ...
//! ]
//! },
//! "modelsWithoutToken": {
//! "<modelName>": [ { "accountId", "username", "siteId", "siteName" }, ... ]
//! },
//! "modelsMissingTokenGroups": {},
//! "endpointTypesByModel": {}
//! }
//! ```
//!
//! `modelsMissingTokenGroups` and `endpointTypesByModel` are intentionally empty:
//! this codebase doesn't have the pricing-catalog metadata or
//! marketplace-cache machinery the Node version relies on. Returning empty
//! objects keeps the contract intact (frontend tolerates them) without
//! pulling in unrelated infrastructure.
use crate::{error::AppResult, AppState};
use axum::{extract::State, Json};
use serde_json::{json, Map, Value};
use std::collections::{HashMap, HashSet};
/// Row from `model_availability ⋈ account_tokens ⋈ accounts ⋈ sites` — every
/// `(model, token)` combo that's currently usable.
#[derive(Debug, sqlx::FromRow)]
struct CoveredRow {
model_name: String,
account_id: i64,
token_id: i64,
token_name: String,
is_default: Option<bool>,
username: Option<String>,
site_id: i64,
site_name: Option<String>,
}
/// Row from `model_availability ⋈ accounts ⋈ sites` — every `(model, account)`
/// combo regardless of token coverage. We diff this against `CoveredRow` to
/// surface accounts that have the model but no usable token.
#[derive(Debug, sqlx::FromRow)]
struct AccountModelRow {
model_name: String,
account_id: i64,
username: Option<String>,
site_id: i64,
site_name: Option<String>,
}
pub async fn token_candidates(State(state): State<AppState>) -> AppResult<Json<Value>> {
// 1. Models that already have at least one usable token. The joins drop:
// inactive accounts/sites, disabled tokens, and tokens whose value
// isn't ready (e.g. mid-rotation).
let covered: Vec<CoveredRow> = sqlx::query_as(
"SELECT ma.model_name AS model_name,
a.id AS account_id,
at.id AS token_id,
at.name AS token_name,
at.is_default AS is_default,
a.username AS username,
s.id AS site_id,
s.name AS site_name
FROM model_availability ma
JOIN account_tokens at ON at.account_id = ma.account_id
JOIN accounts a ON a.id = ma.account_id
JOIN sites s ON s.id = a.site_id
WHERE COALESCE(ma.available, 0) = 1
AND COALESCE(at.enabled, 1) = 1
AND COALESCE(at.value_status, 'ready') = 'ready'
AND a.status = 'active'
AND COALESCE(s.status, 'active') = 'active'",
)
.fetch_all(&*state.db)
.await?;
// 2. All available `(account, model)` pairs — used to compute which
// accounts have the model but no usable token (`modelsWithoutToken`).
let account_models: Vec<AccountModelRow> = sqlx::query_as(
"SELECT ma.model_name AS model_name,
a.id AS account_id,
a.username AS username,
s.id AS site_id,
s.name AS site_name
FROM model_availability ma
JOIN accounts a ON a.id = ma.account_id
JOIN sites s ON s.id = a.site_id
WHERE COALESCE(ma.available, 0) = 1
AND a.status = 'active'
AND COALESCE(s.status, 'active') = 'active'",
)
.fetch_all(&*state.db)
.await?;
// Build `models` map. Dedupe candidates by tokenId per model so a token
// marked available against the same model twice doesn't render twice.
let mut models: HashMap<String, Vec<Value>> = HashMap::new();
let mut seen_token_per_model: HashMap<String, HashSet<i64>> = HashMap::new();
let mut covered_account_model: HashSet<(i64, String)> = HashSet::new();
for row in covered {
let model_name = row.model_name.trim().to_string();
if model_name.is_empty() {
continue;
}
covered_account_model.insert((row.account_id, model_name.to_lowercase()));
let seen = seen_token_per_model.entry(model_name.clone()).or_default();
if !seen.insert(row.token_id) {
continue;
}
models.entry(model_name).or_default().push(json!({
"accountId": row.account_id,
"tokenId": row.token_id,
"tokenName": row.token_name,
"isDefault": row.is_default.unwrap_or(false),
"username": row.username,
"siteId": row.site_id,
"siteName": row.site_name,
}));
}
// Build `modelsWithoutToken`: every (account, model) in `account_models`
// that didn't show up in `covered`. Dedupe by accountId per model.
let mut models_without_token: HashMap<String, Vec<Value>> = HashMap::new();
let mut seen_account_per_model: HashMap<String, HashSet<i64>> = HashMap::new();
for row in account_models {
let model_name = row.model_name.trim().to_string();
if model_name.is_empty() {
continue;
}
let key = (row.account_id, model_name.to_lowercase());
if covered_account_model.contains(&key) {
continue;
}
let seen = seen_account_per_model
.entry(model_name.clone())
.or_default();
if !seen.insert(row.account_id) {
continue;
}
models_without_token
.entry(model_name)
.or_default()
.push(json!({
"accountId": row.account_id,
"username": row.username,
"siteId": row.site_id,
"siteName": row.site_name,
}));
}
// Convert HashMap -> serde_json::Map so the response keys serialize
// deterministically as a JSON object (HashMap also works but Map is the
// canonical type for object-shaped payloads).
let models_obj: Map<String, Value> = models
.into_iter()
.map(|(k, v)| (k, Value::Array(v)))
.collect();
let without_obj: Map<String, Value> = models_without_token
.into_iter()
.map(|(k, v)| (k, Value::Array(v)))
.collect();
Ok(Json(json!({
"models": models_obj,
"modelsWithoutToken": without_obj,
"modelsMissingTokenGroups": {},
"endpointTypesByModel": {},
})))
}
+420
View File
@@ -0,0 +1,420 @@
//! `GET /api/models/marketplace` — model marketplace listing for the admin
//! `/models` page (frontend: `Models.tsx`).
//!
//! Stage 1: aggregates `model_availability` × `accounts` × `account_tokens`
//! × `sites` into the shape `ModelsMarketplaceResponse` expects. Per-model
//! latency / success rate / description / tags / supportedEndpointTypes /
//! pricingSources are returned as null/empty placeholders — the frontend
//! falls back gracefully and stage 2 will hydrate them from `proxy_logs`
//! and pricing tables.
//!
//! `?refresh=1` and `?includePricing=1` are accepted but currently no-ops:
//! we always return the in-memory aggregation and an empty `meta`. Stage 2
//! will wire them to an async refresh job and the pricing hydration path.
use crate::{error::AppResult, AppState};
use axum::{
extract::{Query, State},
Json,
};
use serde::Deserialize;
use serde_json::{json, Value};
use sqlx::FromRow;
use std::collections::{BTreeMap, HashMap};
#[derive(Debug, Deserialize)]
#[allow(dead_code)] // stage 2 will read these; keep the fields to lock the API shape.
pub struct MarketplaceQuery {
pub refresh: Option<String>,
#[serde(rename = "includePricing")]
pub include_pricing: Option<String>,
}
#[derive(FromRow)]
struct AvailRow {
account_id: i64,
model_name: String,
}
#[derive(FromRow)]
struct AccountRow {
id: i64,
site_id: i64,
username: Option<String>,
balance: Option<f64>,
}
#[derive(FromRow)]
struct TokenRow {
id: i64,
account_id: i64,
name: String,
is_default: Option<bool>,
}
pub async fn get_marketplace(
State(state): State<AppState>,
Query(_query): Query<MarketplaceQuery>,
) -> AppResult<Json<Value>> {
let value = marketplace_inner(&state.db).await?;
Ok(Json(value))
}
/// Pool-only entry point. Identical to `get_marketplace` but decoupled from
/// `AppState` and axum extractors so unit tests can drive it with an
/// in-memory SQLite pool (mirrors the `rebuild_routes_inner` pattern in
/// `services::route_rebuild`).
pub(crate) async fn marketplace_inner(pool: &sqlx::Pool<sqlx::Sqlite>) -> AppResult<Value> {
// 1. Active (account, model) pairs. Filtering on accounts.status =
// 'active' here keeps the rest of the aggregation small.
let avail_rows: Vec<AvailRow> = sqlx::query_as(
"SELECT ma.account_id, ma.model_name
FROM model_availability ma
JOIN accounts a ON a.id = ma.account_id
WHERE COALESCE(ma.available, 0) = 1
AND a.status = 'active'",
)
.fetch_all(pool)
.await?;
if avail_rows.is_empty() {
return Ok(json!({ "models": [], "meta": {} }));
}
// 2. One-shot lookups — accounts, sites, tokens. Bounded by the active
// account count (typically dozens), so a full scan beats per-model
// in-loop selects.
let accounts: Vec<AccountRow> = sqlx::query_as(
"SELECT id, site_id, username, balance FROM accounts WHERE status = 'active'",
)
.fetch_all(pool)
.await?;
let accounts_by_id: HashMap<i64, AccountRow> =
accounts.into_iter().map(|a| (a.id, a)).collect();
let sites: Vec<(i64, String)> = sqlx::query_as("SELECT id, name FROM sites")
.fetch_all(pool)
.await?;
let site_name_by_id: HashMap<i64, String> = sites.into_iter().collect();
let tokens: Vec<TokenRow> = sqlx::query_as(
"SELECT id, account_id, name, is_default
FROM account_tokens
WHERE COALESCE(enabled, 1) = 1",
)
.fetch_all(pool)
.await?;
let mut tokens_by_account: HashMap<i64, Vec<TokenRow>> = HashMap::new();
for t in tokens {
tokens_by_account.entry(t.account_id).or_default().push(t);
}
// 3. Group by model name. BTreeMap → output sorted by name (matches the
// deterministic ordering the frontend's table view expects).
let mut by_model: BTreeMap<String, Vec<i64>> = BTreeMap::new();
for row in avail_rows {
by_model
.entry(row.model_name)
.or_default()
.push(row.account_id);
}
// 4. Render. Stage 1 returns null/empty for the metadata fields; the
// frontend has fallback handling for each one.
let mut models: Vec<Value> = Vec::with_capacity(by_model.len());
for (model_name, account_ids) in by_model {
let account_count = account_ids.len();
let mut token_count: usize = 0;
let mut accounts_json: Vec<Value> = Vec::with_capacity(account_count);
for aid in &account_ids {
let acc = match accounts_by_id.get(aid) {
Some(a) => a,
None => continue,
};
let site_name = site_name_by_id
.get(&acc.site_id)
.cloned()
.unwrap_or_default();
let acc_tokens: &[TokenRow] =
tokens_by_account.get(aid).map(Vec::as_slice).unwrap_or(&[]);
token_count += acc_tokens.len();
let token_json: Vec<Value> = acc_tokens
.iter()
.map(|t| {
json!({
"id": t.id,
"name": t.name,
"isDefault": t.is_default.unwrap_or(false),
})
})
.collect();
accounts_json.push(json!({
"id": acc.id,
"site": site_name,
"username": acc.username,
"latency": Value::Null,
"balance": acc.balance.unwrap_or(0.0),
"tokens": token_json,
}));
}
models.push(json!({
"name": model_name,
"accountCount": account_count,
"tokenCount": token_count,
"avgLatency": Value::Null,
"successRate": Value::Null,
"description": Value::Null,
"tags": [],
"supportedEndpointTypes": [],
"pricingSources": [],
"accounts": accounts_json,
}));
}
Ok(json!({
"models": models,
"meta": {},
}))
}
#[cfg(test)]
mod tests {
use super::*;
use sqlx::sqlite::SqlitePoolOptions;
use sqlx::{Pool, Sqlite};
/// Minimal schema for the marketplace aggregation. Mirrors
/// `services::route_rebuild::tests::fixture_pool` — we create only the
/// columns `marketplace_inner` actually reads, so adding unrelated
/// migrations later doesn't break these tests.
async fn fixture_pool() -> Pool<Sqlite> {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("open in-memory sqlite");
for ddl in [
"CREATE TABLE sites (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL
)",
"CREATE TABLE accounts (
id INTEGER PRIMARY KEY,
site_id INTEGER NOT NULL,
username TEXT,
balance REAL,
status TEXT NOT NULL DEFAULT 'active'
)",
"CREATE TABLE model_availability (
account_id INTEGER NOT NULL,
model_name TEXT NOT NULL,
available INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (account_id, model_name)
)",
"CREATE TABLE account_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL,
name TEXT NOT NULL,
enabled INTEGER DEFAULT 1,
is_default INTEGER DEFAULT 0
)",
] {
sqlx::query(ddl)
.execute(&pool)
.await
.unwrap_or_else(|err| panic!("failed to create schema: {err}\nddl: {ddl}"));
}
pool
}
/// Empty DB → empty `models` array with empty `meta`. Locks the early-return
/// path at the top of `marketplace_inner` so an accidental refactor that
/// drops the guard still produces a frontend-safe response shape rather
/// than 500-ing on the follow-up `accounts`/`sites` selects.
#[tokio::test]
async fn empty_db_returns_empty_models_array() {
let pool = fixture_pool().await;
let value = marketplace_inner(&pool).await.expect("inner ok");
assert_eq!(value, json!({ "models": [], "meta": {} }));
}
/// Inactive accounts and unavailable models must be filtered out at the
/// SQL level. Account 2 is `disabled` and account 3's row has
/// `available = 0` — neither should contribute to the response.
#[tokio::test]
async fn filters_inactive_accounts_and_unavailable_models() {
let pool = fixture_pool().await;
sqlx::query("INSERT INTO sites (id, name) VALUES (1, 'site-a')")
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO accounts (id, site_id, username, balance, status) VALUES
(1, 1, 'alice', 10.0, 'active'),
(2, 1, 'bob', 5.0, 'disabled'),
(3, 1, 'carol', 7.0, 'active')",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO model_availability (account_id, model_name, available) VALUES
(1, 'gpt-x', 1),
(2, 'gpt-x', 1),
(3, 'gpt-x', 0)",
)
.execute(&pool)
.await
.unwrap();
let value = marketplace_inner(&pool).await.expect("inner ok");
let models = value.get("models").and_then(Value::as_array).unwrap();
assert_eq!(
models.len(),
1,
"exactly one model should survive filtering"
);
let m = &models[0];
assert_eq!(m["name"], "gpt-x");
assert_eq!(
m["accountCount"], 1,
"only account 1 (active + available) counts"
);
let accounts = m["accounts"].as_array().unwrap();
assert_eq!(accounts.len(), 1);
assert_eq!(accounts[0]["username"], "alice");
}
/// Multi-account, multi-token aggregation. Verifies:
/// - `accountCount` == distinct active accounts advertising the model
/// - `tokenCount` == sum of enabled tokens across those accounts
/// - per-account `tokens[]` is correctly nested with `isDefault`
/// - disabled tokens (`enabled = 0`) don't show up
/// - site name is resolved from `sites.name`
#[tokio::test]
async fn aggregates_accounts_and_tokens_per_model() {
let pool = fixture_pool().await;
sqlx::query(
"INSERT INTO sites (id, name) VALUES (1, 'openai-mirror'), (2, 'anthropic-mirror')",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO accounts (id, site_id, username, balance, status) VALUES
(1, 1, 'alice', 100.5, 'active'),
(2, 2, 'bob', 50.0, 'active')",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO model_availability (account_id, model_name, available) VALUES
(1, 'gpt-x', 1),
(2, 'gpt-x', 1)",
)
.execute(&pool)
.await
.unwrap();
// Account 1: two enabled tokens (one default), one disabled token (must
// be excluded). Account 2: one enabled non-default token.
sqlx::query(
"INSERT INTO account_tokens (id, account_id, name, enabled, is_default) VALUES
(10, 1, 'primary', 1, 1),
(11, 1, 'backup', 1, 0),
(12, 1, 'retired', 0, 0),
(20, 2, 'only-one', 1, 0)",
)
.execute(&pool)
.await
.unwrap();
let value = marketplace_inner(&pool).await.expect("inner ok");
let models = value["models"].as_array().unwrap();
assert_eq!(models.len(), 1);
let m = &models[0];
assert_eq!(m["name"], "gpt-x");
assert_eq!(m["accountCount"], 2);
assert_eq!(
m["tokenCount"], 3,
"2 from account 1 + 1 from account 2; disabled token excluded"
);
// Stage 1 placeholders.
assert!(m["avgLatency"].is_null());
assert!(m["successRate"].is_null());
assert!(m["description"].is_null());
assert_eq!(m["tags"], json!([]));
assert_eq!(m["supportedEndpointTypes"], json!([]));
assert_eq!(m["pricingSources"], json!([]));
let accounts = m["accounts"].as_array().unwrap();
assert_eq!(accounts.len(), 2);
// Find each account by id rather than relying on insertion order in
// the HashMap-driven account_ids vec.
let alice = accounts
.iter()
.find(|a| a["id"] == 1)
.expect("alice present");
assert_eq!(alice["site"], "openai-mirror");
assert_eq!(alice["username"], "alice");
assert_eq!(alice["balance"], 100.5);
let alice_tokens = alice["tokens"].as_array().unwrap();
assert_eq!(
alice_tokens.len(),
2,
"disabled 'retired' token must not be exposed"
);
let primary = alice_tokens.iter().find(|t| t["id"] == 10).unwrap();
assert_eq!(primary["name"], "primary");
assert_eq!(primary["isDefault"], true);
let backup = alice_tokens.iter().find(|t| t["id"] == 11).unwrap();
assert_eq!(backup["isDefault"], false);
let bob = accounts.iter().find(|a| a["id"] == 2).expect("bob present");
assert_eq!(bob["site"], "anthropic-mirror");
assert_eq!(bob["balance"], 50.0);
assert_eq!(bob["tokens"].as_array().unwrap().len(), 1);
}
/// Models must come back in deterministic name order (BTreeMap). The
/// frontend's table view assumes a stable sort so toggling refresh
/// doesn't reshuffle rows; lock that contract here.
#[tokio::test]
async fn models_are_sorted_by_name() {
let pool = fixture_pool().await;
sqlx::query("INSERT INTO sites (id, name) VALUES (1, 's')")
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO accounts (id, site_id, status) VALUES (1, 1, 'active')")
.execute(&pool)
.await
.unwrap();
// Insert in scrambled order; expect output sorted lexicographically.
sqlx::query(
"INSERT INTO model_availability (account_id, model_name, available) VALUES
(1, 'gpt-z', 1),
(1, 'gpt-a', 1),
(1, 'gpt-m', 1)",
)
.execute(&pool)
.await
.unwrap();
let value = marketplace_inner(&pool).await.expect("inner ok");
let names: Vec<&str> = value["models"]
.as_array()
.unwrap()
.iter()
.map(|m| m["name"].as_str().unwrap())
.collect();
assert_eq!(names, vec!["gpt-a", "gpt-m", "gpt-z"]);
}
}
+1386 -15
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -236,7 +236,8 @@ pub async fn refresh_route(
return Ok(Json(json!({
"data": {
"refreshed": true,
"affected_routes": result.rows_affected()
"affected_routes": result.rows_affected(),
"affectedRoutes": result.rows_affected()
}
})));
}
@@ -251,7 +252,8 @@ pub async fn refresh_route(
Ok(Json(json!({
"data": {
"refreshed": true,
"affected_routes": result.rows_affected()
"affected_routes": result.rows_affected(),
"affectedRoutes": result.rows_affected()
}
})))
}
+835 -12
View File
@@ -2,17 +2,25 @@
use crate::{
auth::password::hash_password,
db::models::user::User,
db::models::{downstream_api_key::DownstreamApiKey, user::User},
error::{AppError, AppResult},
routes::auth::DEFAULT_NEW_USER_MAX_COST_USD,
AppState,
};
use axum::{
extract::{Path, Query, State},
http::StatusCode,
Json,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tracing::info;
use uuid::Uuid;
#[derive(Debug, Deserialize)]
pub struct CreateUserApiKeyRequest {
pub name: String,
}
#[derive(Debug, Deserialize)]
pub struct CreateUserRequest {
@@ -49,6 +57,10 @@ pub struct UserResponse {
pub status: String,
pub created_at: Option<String>,
pub updated_at: Option<String>,
pub default_api_key: Option<String>,
pub total_requests: Option<i64>,
pub total_cost: Option<f64>,
pub last_7_days_cost: Option<f64>,
}
impl From<User> for UserResponse {
@@ -61,6 +73,10 @@ impl From<User> for UserResponse {
status: u.status,
created_at: u.created_at,
updated_at: u.updated_at,
default_api_key: None,
total_requests: None,
total_cost: None,
last_7_days_cost: None,
}
}
}
@@ -89,12 +105,12 @@ pub async fn list_users(
let users: Vec<User> = sqlx::query_as::<_, User>(
r#"
SELECT *
FROM users
WHERE (? = '' OR username LIKE ? OR email LIKE ? OR CAST(id AS TEXT) = ?)
AND (? IS NULL OR role = ?)
AND (? IS NULL OR status = ?)
ORDER BY id DESC
SELECT u.*
FROM users u
WHERE (? = '' OR u.username LIKE ? OR u.email LIKE ? OR CAST(u.id AS TEXT) = ?)
AND (? IS NULL OR u.role = ?)
AND (? IS NULL OR u.status = ?)
ORDER BY u.id DESC
LIMIT ? OFFSET ?
"#,
)
@@ -144,7 +160,12 @@ pub async fn list_users(
.fetch_one(&*state.db)
.await?;
let items: Vec<UserResponse> = users.into_iter().map(Into::into).collect();
let mut items = Vec::with_capacity(users.len());
for user in users {
let mut item = UserResponse::from(user);
attach_usage_summary(&state, &mut item).await?;
items.push(item);
}
Ok(Json(json!({
"data": items,
"users": items,
@@ -174,7 +195,9 @@ pub async fn get_user(
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound(format!("User {} not found", id)))?;
Ok(Json(json!({ "data": UserResponse::from(user) })))
let mut item = UserResponse::from(user);
attach_usage_summary(&state, &mut item).await?;
Ok(Json(json!({ "data": item })))
}
/// POST /api/admin/users
@@ -203,26 +226,151 @@ pub async fn create_user(
let password_hash = hash_password(&payload.password)
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to hash password: {e}")))?;
let mut tx = state.db.begin().await?;
let result =
sqlx::query("INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)")
.bind(payload.username.trim())
.bind(payload.email.trim())
.bind(&password_hash)
.bind(&role)
.execute(&*state.db)
.execute(&mut *tx)
.await?;
let user_id = result.last_insert_rowid();
// 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, max_cost) VALUES (?, ?, ?, ?, ?)",
)
.bind(&key_name)
.bind(&key)
.bind(user_id)
.bind(true)
.bind(DEFAULT_NEW_USER_MAX_COST_USD)
.execute(&mut *tx)
.await?;
tx.commit().await?;
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
.bind(result.last_insert_rowid())
.bind(user_id)
.fetch_one(&*state.db)
.await?;
let mut resp = UserResponse::from(user);
resp.default_api_key = Some(key);
Ok((
axum::http::StatusCode::CREATED,
Json(json!({ "data": UserResponse::from(user) })),
Json(json!({ "data": resp })),
))
}
/// GET /api/admin/users/{id}/usage
pub async fn get_user_usage(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Value>> {
info!("Getting usage for user: {}", id);
// Total requests and cost
let stats: (i64, f64) = sqlx::query_as(
r#"
SELECT
COUNT(l.id) as total_requests,
COALESCE(SUM(l.cost), 0.0) as total_cost
FROM usage_logs l
JOIN downstream_api_keys k ON l.downstream_key_id = k.id
WHERE k.user_id = ?
"#,
)
.bind(id)
.fetch_one(&*state.db)
.await?;
// Last 7 days cost
let last_7_days_cost: f64 = sqlx::query_scalar(
r#"
SELECT COALESCE(SUM(l.cost), 0.0)
FROM usage_logs l
JOIN downstream_api_keys k ON l.downstream_key_id = k.id
WHERE k.user_id = ? AND l.created_at >= datetime('now', '-7 days')
"#,
)
.bind(id)
.fetch_one(&*state.db)
.await?;
// Daily trend (last 30 days)
let trend: Vec<(String, i64, f64)> = sqlx::query_as(
r#"
SELECT
strftime('%Y-%m-%d', l.created_at) as day,
COUNT(*) as request_count,
SUM(cost) as cost
FROM usage_logs l
JOIN downstream_api_keys k ON l.downstream_key_id = k.id
WHERE k.user_id = ? AND l.created_at >= datetime('now', '-30 days')
GROUP BY day
ORDER BY day ASC
"#,
)
.bind(id)
.fetch_all(&*state.db)
.await?;
// Recent logs
let recent_logs: Vec<Value> = sqlx::query(
r#"
SELECT
l.*,
k.id AS downstream_api_key_id,
k.name AS downstream_key_name
FROM usage_logs l
JOIN downstream_api_keys k ON l.downstream_key_id = k.id
WHERE k.user_id = ?
ORDER BY l.id DESC
LIMIT 50
"#,
)
.bind(id)
.fetch_all(&*state.db)
.await?
.into_iter()
.map(|row| {
use sqlx::Row;
json!({
"id": row.get::<i64, _>("id"),
"model": row.get::<Option<String>, _>("model"),
"downstreamApiKeyId": row.get::<Option<i64>, _>("downstream_api_key_id"),
"downstreamKeyName": row.get::<Option<String>, _>("downstream_key_name"),
"status": "success",
"httpStatus": 200,
"source": "usage-logs",
"promptTokens": row.get::<Option<i64>, _>("prompt_tokens").unwrap_or(0),
"completionTokens": row.get::<Option<i64>, _>("completion_tokens").unwrap_or(0),
"totalTokens": row.get::<Option<i64>, _>("total_tokens").unwrap_or(0),
"cost": row.get::<Option<f64>, _>("cost").unwrap_or(0.0),
"createdAt": row.get::<Option<String>, _>("created_at"),
})
})
.collect();
Ok(Json(json!({
"data": {
"totalRequests": stats.0,
"totalCost": stats.1,
"last7DaysCost": last_7_days_cost,
"trend": trend.into_iter().map(|t| json!({ "day": t.0, "requests": t.1, "cost": t.2 })).collect::<Vec<_>>(),
"recentLogs": recent_logs,
}
})))
}
/// PUT /api/admin/users/{id}
pub async fn update_user(
State(state): State<AppState>,
@@ -345,3 +493,678 @@ async fn ensure_email_available(
}
Ok(())
}
async fn attach_usage_summary(state: &AppState, item: &mut UserResponse) -> AppResult<()> {
let (total_requests, total_cost, last_7_days_cost): (i64, f64, f64) = sqlx::query_as(
r#"
SELECT
COUNT(l.id) AS total_requests,
COALESCE(SUM(l.cost), 0.0) AS total_cost,
COALESCE(SUM(CASE
WHEN l.created_at >= datetime('now', '-7 days') THEN l.cost
ELSE 0.0
END), 0.0) AS last_7_days_cost
FROM usage_logs l
JOIN downstream_api_keys k ON l.downstream_key_id = k.id
WHERE k.user_id = ?
"#,
)
.bind(item.id)
.fetch_one(&*state.db)
.await?;
item.total_requests = Some(total_requests);
item.total_cost = Some(total_cost);
item.last_7_days_cost = Some(last_7_days_cost);
Ok(())
}
// ── User API Key Management for Admin ──────────────────────────────────
/// GET /api/admin/users/{id}/keys
pub async fn list_user_keys(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Value>> {
info!("Admin listing API keys for user: {}", id);
// Verify user exists
let _exists = sqlx::query_scalar::<_, i64>("SELECT 1 FROM users WHERE id = ?")
.bind(id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound(format!("User {} not found", id)))?;
let keys: Vec<DownstreamApiKey> = sqlx::query_as::<_, DownstreamApiKey>(
"SELECT * FROM downstream_api_keys WHERE user_id = ? ORDER BY created_at DESC",
)
.bind(id)
.fetch_all(&*state.db)
.await?;
Ok(Json(json!({ "data": keys, "keys": keys })))
}
/// POST /api/admin/users/{id}/keys
pub async fn create_user_key(
State(state): State<AppState>,
Path(id): Path<i64>,
Json(payload): Json<CreateUserApiKeyRequest>,
) -> AppResult<(StatusCode, Json<Value>)> {
info!("Admin creating API key for user: {}", id);
let key = format!("sk-user-{}", Uuid::new_v4().to_string().replace('-', ""));
let result = sqlx::query(
"INSERT INTO downstream_api_keys (name, key, user_id, enabled) VALUES (?, ?, ?, ?)",
)
.bind(&payload.name)
.bind(&key)
.bind(id)
.bind(true)
.execute(&*state.db)
.await?;
let key_id = result.last_insert_rowid();
let api_key: DownstreamApiKey =
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys WHERE id = ?")
.bind(key_id)
.fetch_one(&*state.db)
.await?;
Ok((StatusCode::CREATED, Json(json!({ "data": api_key }))))
}
/// DELETE /api/admin/users/{id}/keys/{key_id}
pub async fn delete_user_key(
State(state): State<AppState>,
Path((user_id, key_id)): Path<(i64, i64)>,
) -> AppResult<Json<Value>> {
info!("Admin deleting API key {} for user {}", key_id, user_id);
let affected = sqlx::query("DELETE FROM downstream_api_keys WHERE id = ? AND user_id = ?")
.bind(key_id)
.bind(user_id)
.execute(&*state.db)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound(format!(
"Key {} for user {} not found",
key_id, user_id
)));
}
Ok(Json(json!({ "data": { "deleted": true, "id": key_id } })))
}
/// POST /api/admin/users/{id}/keys/{key_id}/reset
pub async fn reset_user_key(
State(state): State<AppState>,
Path((user_id, key_id)): Path<(i64, i64)>,
) -> AppResult<Json<Value>> {
info!("Admin resetting API key {} for user {}", key_id, user_id);
let new_key = format!("sk-user-{}", Uuid::new_v4().to_string().replace('-', ""));
let affected = sqlx::query("UPDATE downstream_api_keys SET key = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?")
.bind(&new_key)
.bind(key_id)
.bind(user_id)
.execute(&*state.db)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound(format!(
"Key {} for user {} not found",
key_id, user_id
)));
}
let api_key: DownstreamApiKey =
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys WHERE id = ?")
.bind(key_id)
.fetch_one(&*state.db)
.await?;
Ok(Json(json!({ "data": api_key })))
}
/// PATCH /api/admin/users/{id}/keys/{key_id}
pub async fn update_user_key(
State(state): State<AppState>,
Path((user_id, key_id)): Path<(i64, i64)>,
Json(payload): Json<serde_json::Value>,
) -> AppResult<Json<Value>> {
info!("Admin updating API key {} for user {}", key_id, user_id);
let _existing: DownstreamApiKey = sqlx::query_as::<_, DownstreamApiKey>(
"SELECT * FROM downstream_api_keys WHERE id = ? AND user_id = ?",
)
.bind(key_id)
.bind(user_id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound(format!("Key {} for user {} not found", key_id, user_id)))?;
// Build dynamic UPDATE
let mut sets: Vec<String> = vec![];
let mut params: Vec<serde_json::Value> = vec![];
if let Some(v) = payload.get("name").and_then(|v| v.as_str()) {
sets.push("name = ?".to_string());
params.push(json!(v));
}
if let Some(v) = payload.get("description") {
sets.push("description = ?".to_string());
params.push(v.clone());
}
if let Some(v) = payload.get("enabled").and_then(|v| v.as_bool()) {
sets.push("enabled = ?".to_string());
params.push(json!(v));
}
if let Some(v) = payload.get("expiresAt") {
sets.push("expires_at = ?".to_string());
params.push(v.clone());
}
if let Some(v) = payload.get("maxCost").and_then(|v| v.as_f64()) {
sets.push("max_cost = ?".to_string());
params.push(json!(v));
}
if let Some(v) = payload.get("maxRequests").and_then(|v| v.as_i64()) {
sets.push("max_requests = ?".to_string());
params.push(json!(v));
}
if !sets.is_empty() {
sets.push("updated_at = datetime('now')".to_string());
let sql = format!(
"UPDATE downstream_api_keys SET {} WHERE id = ? AND user_id = ?",
sets.join(", ")
);
let mut q = sqlx::query(&sql);
for p in &params {
if let Some(s) = p.as_str() {
q = q.bind(s.to_owned());
} else if let Some(b) = p.as_bool() {
q = q.bind(b);
} else if let Some(f) = p.as_f64() {
q = q.bind(f);
} else if let Some(i) = p.as_i64() {
q = q.bind(i);
} else if p.is_null() {
q = q.bind(None::<String>);
}
}
q.bind(key_id).bind(user_id).execute(&*state.db).await?;
}
let updated: DownstreamApiKey =
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys WHERE id = ?")
.bind(key_id)
.fetch_one(&*state.db)
.await?;
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::*;
use crate::{
config::{AppConfig, AuthConfig, DbConfig, ProxyConfig, ServerConfig},
services::{builtin_keys::BuiltinKeys, runtime_auth::RuntimeAuthSettings},
};
use axum::extract::State;
use sqlx::sqlite::SqlitePoolOptions;
async fn test_state() -> AppState {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("in-memory database");
sqlx::query(
"CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
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 DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)",
)
.execute(&pool)
.await
.expect("users table");
sqlx::query(
"CREATE TABLE downstream_api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
name TEXT NOT NULL,
key TEXT NOT NULL,
description TEXT,
group_name TEXT,
tags TEXT,
enabled INTEGER DEFAULT 1,
expires_at TEXT,
max_cost REAL,
used_cost REAL DEFAULT 0,
max_requests INTEGER,
used_requests INTEGER DEFAULT 0,
supported_models TEXT,
allowed_route_ids TEXT,
site_weight_multipliers TEXT,
excluded_site_ids TEXT,
excluded_credential_refs TEXT,
last_used_at TEXT,
user_id INTEGER,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)",
)
.execute(&pool)
.await
.expect("downstream keys table");
AppState::new(
AppConfig {
server: ServerConfig {
host: "127.0.0.1".into(),
port: 0,
log_level: "info".into(),
cors_origins: vec![],
},
db: DbConfig {
url: "sqlite::memory:".into(),
max_connections: 1,
ssl: false,
runtime_db_url: None,
runtime_db_dialect: None,
runtime_db_ssl: None,
},
auth: AuthConfig {
jwt_secret: "test-secret".into(),
jwt_expiry_hours: 1,
auth_token: "auth".into(),
proxy_token: "proxy".into(),
},
proxy: ProxyConfig {
upstream_timeout_secs: 30,
first_byte_timeout_secs: 30,
max_retries: 3,
proxy_log_retention_days: 7,
template_proxy_url: None,
},
comfyui: Default::default(),
pixelle_video: Default::default(),
payment: Default::default(),
},
pool,
BuiltinKeys {
comfyui_agent: (1, "sk-comfyui-test".into()),
},
RuntimeAuthSettings::from_token_for_test("proxy"),
)
}
#[tokio::test]
async fn update_user_persists_admin_role_for_existing_user() {
let state = test_state().await;
let user_id = sqlx::query(
"INSERT INTO users (username, email, password_hash, role, status)
VALUES (?, ?, ?, 'user', 'active')",
)
.bind("Regular")
.bind("regular@example.test")
.bind("hash")
.execute(&*state.db)
.await
.expect("insert user")
.last_insert_rowid();
let Json(value) = update_user(
State(state.clone()),
Path(user_id),
Json(UpdateUserRequest {
username: None,
email: None,
password: None,
role: Some("admin".into()),
status: None,
}),
)
.await
.expect("update user");
assert_eq!(value["data"]["role"], "admin");
let role: String = sqlx::query_scalar("SELECT role FROM users WHERE id = ?")
.bind(user_id)
.fetch_one(&*state.db)
.await
.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));
}
}
+1018 -10
View File
File diff suppressed because it is too large Load Diff
+437 -2
View File
@@ -6,12 +6,13 @@
use crate::{
auth::api_key::{authorize_downstream_token, DownstreamAuthResult},
auth::jwt::validate_jwt,
error::{AppError, AppResult},
AppState,
};
use axum::{
body::Bytes,
extract::{Path, Query, State},
extract::{ws::Message, Path, Query, State, WebSocketUpgrade},
http::{HeaderMap, Method, StatusCode, Uri},
response::{Html, IntoResponse, Response},
routing::{any, get, post},
@@ -20,7 +21,9 @@ use axum::{
use reqwest::Client;
use serde_json::Value;
use std::collections::HashMap;
use tokio_tungstenite::{connect_async, tungstenite};
use tracing::info;
use tungstenite::client::IntoClientRequest;
/// Shared HTTP client
fn http_client() -> Client {
@@ -45,10 +48,20 @@ async fn check_auth(state: &AppState, headers: &HeaderMap) -> Result<(), AppErro
return Ok(());
}
if let Ok(claims) = validate_jwt(&state.config.auth.jwt_secret, auth) {
if claims.role == "admin" {
return Ok(());
}
return Err(AppError::Forbidden(
"Admin account is required for this route".into(),
));
}
// Downstream API key
if auth.starts_with("sk-") {
let proxy_token = state.runtime_proxy_token();
let result =
authorize_downstream_token(auth, &state.db, Some(&state.config.auth.proxy_token))
authorize_downstream_token(auth, &state.db, Some(&proxy_token))
.await
.map_err(AppError::Unauthorized)?;
@@ -66,6 +79,105 @@ async fn check_auth(state: &AppState, headers: &HeaderMap) -> Result<(), AppErro
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
auth::jwt::generate_jwt,
config::{AppConfig, AuthConfig, ComfyUIConfig, DbConfig, ProxyConfig, ServerConfig},
services::{builtin_keys::BuiltinKeys, runtime_auth::RuntimeAuthSettings},
};
use axum::http::HeaderValue;
use sqlx::sqlite::SqlitePoolOptions;
async fn test_state() -> AppState {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("sqlite pool");
AppState::new(
AppConfig {
server: ServerConfig {
host: "127.0.0.1".to_string(),
port: 4000,
log_level: "info".to_string(),
cors_origins: Vec::new(),
},
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-jwt-secret".to_string(),
jwt_expiry_hours: 24,
auth_token: "test-admin-token".to_string(),
proxy_token: "sk-test-proxy".to_string(),
},
proxy: ProxyConfig {
upstream_timeout_secs: 30,
first_byte_timeout_secs: 30,
max_retries: 1,
proxy_log_retention_days: 7,
template_proxy_url: None,
},
comfyui: ComfyUIConfig::default(),
},
pool,
BuiltinKeys {
comfyui_agent: (0, "sk-comfyui-test".to_string()),
},
RuntimeAuthSettings::from_token_for_test("sk-test-proxy"),
)
}
fn bearer_headers(token: &str) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(
"Authorization",
HeaderValue::from_str(&format!("Bearer {token}")).expect("valid bearer header"),
);
headers
}
#[tokio::test]
async fn comfyui_auth_accepts_admin_jwt() {
let state = test_state().await;
let token = generate_jwt(
&state.config.auth.jwt_secret,
"42",
"admin",
state.config.auth.jwt_expiry_hours,
)
.expect("admin jwt");
check_auth(&state, &bearer_headers(&token))
.await
.expect("admin jwt can access comfyui proxy routes");
}
#[tokio::test]
async fn comfyui_auth_rejects_user_jwt() {
let state = test_state().await;
let token = generate_jwt(
&state.config.auth.jwt_secret,
"43",
"user",
state.config.auth.jwt_expiry_hours,
)
.expect("user jwt");
let result = check_auth(&state, &bearer_headers(&token)).await;
assert!(matches!(result, Err(AppError::Forbidden(_))));
}
}
/// Get ComfyUI base URL from embedded/external runtime configuration.
fn comfyui_base(state: &AppState) -> String {
state.comfyui.internal_base_url()
@@ -85,11 +197,188 @@ pub fn routes() -> Router<AppState> {
// Public ComfyUI reverse proxy for the embedded iframe UI.
.route("/api/comfyui/proxy", any(proxy_static_root))
.route("/api/comfyui/proxy/", any(proxy_static_root))
.route("/api/comfyui/proxy/ws", get(proxy_ws))
.route(
"/api/comfyui/proxy/api/experiment/models",
get(proxy_model_folders),
)
.route(
"/api/comfyui/proxy/api/experiment/models/{folder}",
get(proxy_models_in_folder),
)
.route(
"/api/comfyui/proxy/api/view_metadata/{folder}",
get(proxy_model_metadata),
)
.route("/api/comfyui/proxy/{*path}", any(proxy_static))
// HTML page (public)
.route("/comfyui", get(comfyui_page))
}
async fn proxy_model_folders(State(state): State<AppState>) -> AppResult<Response> {
let mut folders = proxy_to_comfyui_json(&state, "GET", "/api/experiment/models", None).await?;
if let Some(items) = folders.as_array_mut() {
if !items
.iter()
.any(|item| item.get("name").and_then(Value::as_str) == Some("boosapi_models"))
{
items.insert(0, serde_json::json!({ "name": "boosapi_models" }));
}
}
Ok((
StatusCode::OK,
[(axum::http::header::CONTENT_TYPE, "application/json")],
folders.to_string(),
)
.into_response())
}
async fn proxy_models_in_folder(
State(state): State<AppState>,
Path(folder): Path<String>,
) -> AppResult<Response> {
if folder == "boosapi_models" {
let models = list_boosapi_models(&state).await?;
let items: Vec<Value> = models
.into_iter()
.map(|name| serde_json::json!({ "name": name, "pathIndex": 0 }))
.collect();
return Ok((
StatusCode::OK,
[(axum::http::header::CONTENT_TYPE, "application/json")],
Value::Array(items).to_string(),
)
.into_response());
}
let path = format!("/api/experiment/models/{}", urlencoding(&folder));
proxy_to_comfyui_response(&state, "GET", &path, None).await
}
async fn proxy_model_metadata(
State(state): State<AppState>,
Path(folder): Path<String>,
uri: Uri,
) -> AppResult<Response> {
if folder == "boosapi_models" {
let filename = uri
.query()
.and_then(|query| {
query.split('&').find_map(|part| {
let (key, value) = part.split_once('=')?;
(key == "filename").then_some(value.replace("%2F", "/").replace("%20", " "))
})
})
.unwrap_or_else(|| "BoosAPI model".to_string());
let body = serde_json::json!({
"title": filename,
"author": "BoosAPI",
"description": "BoosAPI 后台模型路由。双击可创建 BoosAPI 节点,实际调用本地 /v1 模型网关。",
"modelspec.tags": "boosapi,api,model",
});
return Ok((
StatusCode::OK,
[(axum::http::header::CONTENT_TYPE, "application/json")],
body.to_string(),
)
.into_response());
}
let path = match uri.query() {
Some(query) => format!("/api/view_metadata/{}?{}", urlencoding(&folder), query),
None => format!("/api/view_metadata/{}", urlencoding(&folder)),
};
proxy_to_comfyui_response(&state, "GET", &path, None).await
}
async fn proxy_ws(
State(state): State<AppState>,
ws: WebSocketUpgrade,
uri: Uri,
) -> AppResult<Response> {
let mut target = comfyui_base(&state)
.replacen("https://", "wss://", 1)
.replacen("http://", "ws://", 1);
target.push_str("/ws");
if let Some(query) = uri.query() {
target.push('?');
target.push_str(query);
}
Ok(ws.on_upgrade(move |socket| proxy_ws_bridge(socket, target)))
}
async fn proxy_ws_bridge(mut client_socket: axum::extract::ws::WebSocket, target: String) {
let request = match target.as_str().into_client_request() {
Ok(request) => request,
Err(err) => {
let _ = client_socket
.send(Message::Text(
format!("invalid ComfyUI websocket URL: {err}").into(),
))
.await;
return;
}
};
let Ok((upstream_socket, _)) = connect_async(request).await else {
let _ = client_socket
.send(Message::Text("ComfyUI websocket connect failed".into()))
.await;
return;
};
use futures::{SinkExt, StreamExt};
let (mut upstream_write, mut upstream_read) = upstream_socket.split();
loop {
tokio::select! {
from_upstream = upstream_read.next() => {
match from_upstream {
Some(Ok(message)) => {
let outgoing = match message {
tungstenite::Message::Text(text) => Message::Text(text.to_string().into()),
tungstenite::Message::Binary(bytes) => Message::Binary(bytes.to_vec().into()),
tungstenite::Message::Ping(bytes) => Message::Ping(bytes.to_vec().into()),
tungstenite::Message::Pong(bytes) => Message::Pong(bytes.to_vec().into()),
tungstenite::Message::Close(frame) => {
let _ = client_socket.send(Message::Close(frame.map(|frame| axum::extract::ws::CloseFrame {
code: frame.code.into(),
reason: frame.reason.to_string().into(),
}))).await;
break;
}
tungstenite::Message::Frame(_) => continue,
};
if client_socket.send(outgoing).await.is_err() {
break;
}
}
Some(Err(_)) | None => break,
}
}
from_client = client_socket.recv() => {
match from_client {
Some(Ok(message)) => {
let outgoing = match message {
Message::Text(text) => tungstenite::Message::Text(text.to_string().into()),
Message::Binary(bytes) => tungstenite::Message::Binary(bytes.to_vec().into()),
Message::Ping(bytes) => tungstenite::Message::Ping(bytes.to_vec().into()),
Message::Pong(bytes) => tungstenite::Message::Pong(bytes.to_vec().into()),
Message::Close(frame) => {
let _ = upstream_write.send(tungstenite::Message::Close(frame.map(|frame| tungstenite::protocol::CloseFrame {
code: frame.code.into(),
reason: frame.reason.to_string().into(),
}))).await;
break;
}
};
if upstream_write.send(outgoing).await.is_err() {
break;
}
}
Some(Err(_)) | None => break,
}
}
}
}
}
// ── Business route handlers (require auth) ───────────────────────────
/// POST /api/comfyui/prompt
@@ -243,6 +532,7 @@ async fn proxy_static_request(
.replace("comfyui", "BoosAPI");
// Rewrite URLs
let rewritten = rewrite_proxy_urls(&rebranded);
let rewritten = inject_boosapi_model_library_patch(&rewritten);
Ok((
StatusCode::from_u16(status.as_u16()).unwrap(),
[(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
@@ -274,6 +564,96 @@ async fn proxy_static_request(
}
}
async fn proxy_to_comfyui_json(
state: &AppState,
method: &str,
path: &str,
body: Option<Value>,
) -> AppResult<Value> {
let client = http_client();
let url = format!("{}{}", comfyui_base(state), path);
let req = match method {
"POST" => client.post(&url).json(&body.unwrap_or(Value::Null)),
_ => client.get(&url),
};
let resp = req
.send()
.await
.map_err(|e| AppError::UpstreamError(format!("ComfyUI unreachable: {e}")))?;
let status = resp.status();
let text = resp
.text()
.await
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
if !status.is_success() {
return Err(AppError::UpstreamError(text));
}
serde_json::from_str(&text).map_err(|e| AppError::UpstreamError(e.to_string()))
}
async fn proxy_to_comfyui_response(
state: &AppState,
method: &str,
path: &str,
body: Option<Value>,
) -> AppResult<Response> {
let client = http_client();
let url = format!("{}{}", comfyui_base(state), path);
let req = match method {
"POST" => client.post(&url).json(&body.unwrap_or(Value::Null)),
_ => client.get(&url),
};
let resp = req
.send()
.await
.map_err(|e| AppError::UpstreamError(format!("ComfyUI unreachable: {e}")))?;
let status = resp.status();
let content_type = resp
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("application/json")
.to_string();
let bytes = resp
.bytes()
.await
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
Ok((
StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY),
[(axum::http::header::CONTENT_TYPE, content_type)],
bytes,
)
.into_response())
}
async fn list_boosapi_models(state: &AppState) -> AppResult<Vec<String>> {
let rows: Vec<String> = sqlx::query_scalar(
"SELECT DISTINCT COALESCE(NULLIF(TRIM(model_mapping), ''), model_pattern)
FROM token_routes
WHERE COALESCE(enabled, 1) = 1
AND TRIM(COALESCE(model_pattern, '')) <> ''
ORDER BY 1 COLLATE NOCASE",
)
.fetch_all(&*state.db)
.await?;
let mut seen = std::collections::HashSet::new();
let mut models: Vec<String> = rows
.into_iter()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.filter(|value| seen.insert(value.clone()))
.collect();
if models.is_empty() {
models.extend([
"gpt-5.5".to_string(),
"gpt-5.4".to_string(),
"gpt-image-2".to_string(),
]);
}
Ok(models)
}
/// GET /comfyui — HTML page with iframe
async fn comfyui_page() -> Html<&'static str> {
Html(
@@ -335,6 +715,61 @@ fn rewrite_proxy_urls(html: &str) -> String {
result
}
fn inject_boosapi_model_library_patch(html: &str) -> String {
let patch = r#"<script>
(() => {
const isBoosapiModelText = (text) => {
const value = String(text || '').replace(/\s+/g, ' ').trim();
return value && !value.includes('boosapi_models') && !value.endsWith('_models');
};
const isImageModel = (name) => /image|dall|flux|sd|midjourney|stable|wan|seedream/i.test(name);
const addBoosapiNode = (model) => {
try {
const graph = window.app?.graph || window.LiteGraph?.LGraphCanvas?.active_canvas?.graph;
const canvas = window.app?.canvas || window.LiteGraph?.LGraphCanvas?.active_canvas;
if (!graph || !window.LiteGraph?.createNode) return false;
const nodeType = isImageModel(model) ? 'BoosAPITextToImage' : 'BoosAPILLM';
const node = window.LiteGraph.createNode(nodeType, nodeType);
if (!node) return false;
node.pos = canvas?.convertEventToCanvasOffset
? canvas.convertEventToCanvasOffset({ clientX: window.innerWidth / 2, clientY: window.innerHeight / 2 })
: [120, 120];
graph.add(node);
const modelWidget = node.widgets?.find((item) => item.name === 'model');
if (modelWidget) modelWidget.value = model;
graph.setDirtyCanvas?.(true, true);
canvas?.selectNode?.(node);
return true;
} catch (err) {
console.warn('[BoosAPI] failed to add model node', err);
return false;
}
};
const patchModelClicks = () => {
document.querySelectorAll('#model-library .p-tree-node-content, #model-library [role="treeitem"], .model-lib-tree-explorer .p-tree-node-content').forEach((el) => {
if (el.__boosapiModelPatch) return;
el.__boosapiModelPatch = true;
el.addEventListener('dblclick', (event) => {
const text = String(el.textContent || '').replace(/\s+/g, ' ').trim();
if (!isBoosapiModelText(text)) return;
const hasBoosapiAncestor = Array.from(el.parentElement?.querySelectorAll?.('*') || [])
.some((child) => String(child.textContent || '').includes('boosapi_models'));
if (!hasBoosapiAncestor && !text.match(/gpt|claude|gemini|image|dall|flux|seed|deepseek|qwen|glm|doubao|wan/i)) return;
if (addBoosapiNode(text)) {
event.preventDefault();
event.stopPropagation();
}
}, true);
});
};
document.addEventListener('click', () => setTimeout(patchModelClicks, 100), true);
setInterval(patchModelClicks, 1200);
setTimeout(patchModelClicks, 500);
})();
</script>"#;
html.replace("</head>", &format!("{patch}\n</head>"))
}
/// Build query string from params
fn build_query_string(params: &HashMap<String, String>) -> String {
if params.is_empty() {
+795 -39
View File
@@ -1,6 +1,6 @@
use crate::AppState;
use crate::{auth::jwt::validate_jwt, db::models::downstream_api_key::DownstreamApiKey, AppState};
use axum::{
extract::State,
extract::{DefaultBodyLimit, State},
http::HeaderMap,
response::sse::{Event, Sse},
routing::{get, post},
@@ -59,24 +59,121 @@ emotion: 平静
- 如果没有用户明确要求,切勿自作主张输出全部内容
"#;
const AGENT_CHAT_BODY_LIMIT_BYTES: usize = 64 * 1024 * 1024;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/comfyui-agent/status", get(status_handler))
.route("/api/comfyui-agent/models", get(models_handler))
.route("/api/comfyui-agent/image-models", get(image_models_handler))
.route("/api/comfyui-agent/chat", post(chat_handler))
.layer(DefaultBodyLimit::max(AGENT_CHAT_BODY_LIMIT_BYTES))
}
#[derive(Debug, Deserialize)]
struct AgentChatRequest {
messages: Vec<AgentMessage>,
model: Option<String>,
#[serde(rename = "imageModel")]
image_model: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AgentMessage {
role: String,
content: String,
#[serde(default)]
attachments: Vec<AgentAttachment>,
}
fn upstream_config(state: &AppState) -> (String, String) {
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AgentAttachment {
name: String,
mime_type: Option<String>,
#[serde(default, rename = "mediaKind")]
media_kind: Option<String>,
#[serde(default, rename = "referenceLabel")]
reference_label: Option<String>,
text: Option<String>,
data_url: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AgentMode {
/// Route through this server's own `/v1/*` using the caller's downstream key.
Local,
/// Legacy fallback: external upstream configured via `BOOSAPI_UPSTREAM_BASE` / `BOOSAPI_UPSTREAM_KEY`.
FallbackRemote,
}
#[derive(Debug, Clone)]
struct AgentAuthContext {
user_id: Option<i64>,
role: String,
outbound_key: String,
key_name: String,
}
impl AgentMode {
fn as_str(self) -> &'static str {
match self {
AgentMode::Local => "local",
AgentMode::FallbackRemote => "fallback_remote",
}
}
}
fn detect_agent_mode() -> AgentMode {
let base_override = std::env::var("BOOSAPI_UPSTREAM_BASE")
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty());
let key_override = std::env::var("BOOSAPI_UPSTREAM_KEY")
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty());
if base_override.is_some() || key_override.is_some() {
AgentMode::FallbackRemote
} else {
AgentMode::Local
}
}
fn local_base(state: &AppState) -> String {
let host = if state.config.server.host == "0.0.0.0" || state.config.server.host == "::" {
"127.0.0.1".to_string()
} else {
state.config.server.host.clone()
};
format!("http://{}:{}/v1", host, state.config.server.port)
}
/// Resolve `(mode, api_base, api_key, key_name)` for the agent's outbound calls.
///
/// Defaults to `AgentMode::Local`, which targets this server's own `/v1`
/// endpoints with the current caller's downstream key — every call then runs
/// through the regular accounting / routing pipeline and is billed/logged
/// against that user key.
///
/// Setting `BOOSAPI_UPSTREAM_BASE` or `BOOSAPI_UPSTREAM_KEY` switches to
/// `FallbackRemote` for operators that still want to point straight at an
/// external aggregator.
fn upstream_config(
state: &AppState,
auth: &AgentAuthContext,
) -> (AgentMode, String, String, String) {
match detect_agent_mode() {
AgentMode::Local => {
let base = local_base(state);
(
AgentMode::Local,
base,
auth.outbound_key.clone(),
auth.key_name.clone(),
)
}
AgentMode::FallbackRemote => {
let base = std::env::var("BOOSAPI_UPSTREAM_BASE")
.ok()
.map(|value| value.trim().trim_end_matches('/').to_string())
@@ -86,43 +183,276 @@ fn upstream_config(state: &AppState) -> (String, String) {
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| state.config.auth.proxy_token.clone());
(base, key)
.unwrap_or_else(|| auth.outbound_key.clone());
let key_name = if std::env::var("BOOSAPI_UPSTREAM_KEY")
.ok()
.map(|value| !value.trim().is_empty())
.unwrap_or(false)
{
"external".to_string()
} else {
auth.key_name.clone()
};
(AgentMode::FallbackRemote, base, key, key_name)
}
}
}
fn authorize(headers: &HeaderMap, state: &AppState) -> bool {
fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> {
let auth_header = headers
.get("authorization")
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
let token = auth_header
auth_header
.strip_prefix("Bearer ")
.or_else(|| auth_header.strip_prefix("bearer "))
.unwrap_or_default();
.map(str::trim)
.filter(|token| !token.is_empty())
}
token == state.config.auth.auth_token
async fn user_downstream_key(state: &AppState, user_id: i64) -> Result<DownstreamApiKey, String> {
sqlx::query_as::<_, DownstreamApiKey>(
"SELECT *
FROM downstream_api_keys
WHERE user_id = ?
AND COALESCE(enabled, 1) = 1
AND (
expires_at IS NULL
OR expires_at = ''
OR datetime(expires_at) > datetime('now')
)
ORDER BY updated_at DESC, created_at DESC, id DESC
LIMIT 1",
)
.bind(user_id)
.fetch_optional(&*state.db)
.await
.map_err(|err| err.to_string())?
.ok_or_else(|| "当前用户没有可用的 API Key,请先在用户中心创建或启用 API Key".to_string())
}
async fn resolve_agent_auth(
headers: &HeaderMap,
state: &AppState,
) -> Result<AgentAuthContext, (axum::http::StatusCode, Json<Value>)> {
let token = extract_bearer_token(headers).ok_or_else(|| {
(
axum::http::StatusCode::UNAUTHORIZED,
Json(json!({ "error": "Unauthorized" })),
)
})?;
let proxy_token = state.runtime_proxy_token();
if token == state.config.auth.auth_token || token == proxy_token {
return Ok(AgentAuthContext {
user_id: None,
role: "admin".to_string(),
outbound_key: proxy_token,
key_name: "global-proxy".to_string(),
});
}
let claims = validate_jwt(&state.config.auth.jwt_secret, token).map_err(|_| {
(
axum::http::StatusCode::UNAUTHORIZED,
Json(json!({ "error": "Unauthorized" })),
)
})?;
if claims.role != "admin" && claims.role != "user" {
return Err((
axum::http::StatusCode::FORBIDDEN,
Json(json!({ "error": "Forbidden" })),
));
}
let user_id = claims.sub.parse::<i64>().map_err(|_| {
(
axum::http::StatusCode::UNAUTHORIZED,
Json(json!({ "error": "Invalid user ID in token" })),
)
})?;
match user_downstream_key(state, user_id).await {
Ok(key) => Ok(AgentAuthContext {
user_id: Some(user_id),
role: claims.role,
outbound_key: key.key,
key_name: key.name,
}),
Err(_) if claims.role == "admin" => Ok(AgentAuthContext {
user_id: Some(user_id),
role: claims.role,
outbound_key: state.runtime_proxy_token(),
key_name: "global-proxy".to_string(),
}),
Err(message) => Err((
axum::http::StatusCode::FORBIDDEN,
Json(json!({ "error": message })),
)),
}
}
fn is_exact_model_pattern(value: &str) -> bool {
let normalized = value.trim();
!normalized.is_empty()
&& !normalized.to_ascii_lowercase().starts_with("re:")
&& !normalized.contains('*')
&& !normalized.contains('?')
}
fn is_image_model_pattern(value: &str) -> bool {
if !is_exact_model_pattern(value) {
return false;
}
let normalized = value.trim().to_ascii_lowercase();
let image_keywords = [
"gpt-image",
"dall-e",
"dalle",
"midjourney",
"recraft",
"kolors",
"cogview",
"seedream",
"sdxl",
"stable-diffusion",
"stablediffusion",
"pixart",
"playground",
"firefly",
"imagen",
"flux",
"image",
"img",
"canvas",
"painting",
"draw",
];
if image_keywords
.iter()
.any(|keyword| normalized.contains(keyword))
{
return true;
}
let chat_keywords = [
"claude", "chatgpt", "gpt-4", "gpt-3", "gpt-5", "o1", "o3", "o4", "gemini", "deepseek",
"qwen", "llama", "mistral", "command", "kimi", "doubao", "glm", "hunyuan", "spark",
"baichuan", "ernie", "wenxin", "grok",
];
!chat_keywords
.iter()
.any(|keyword| normalized.contains(keyword))
}
async fn status_handler(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, (axum::http::StatusCode, Json<Value>)> {
if !authorize(&headers, &state) {
return Err((
axum::http::StatusCode::UNAUTHORIZED,
Json(json!({ "error": "Unauthorized" })),
));
let auth = resolve_agent_auth(&headers, &state).await?;
let (mode, upstream_base, _, key_name) = upstream_config(&state, &auth);
let upstream_label = match mode {
AgentMode::Local => "BoosAPI 本地路由".to_string(),
AgentMode::FallbackRemote => {
let parsed = reqwest::Url::parse(&upstream_base).ok();
parsed
.and_then(|url| url.host_str().map(ToOwned::to_owned))
.unwrap_or_else(|| "外部上游".to_string())
}
let (upstream_base, _) = upstream_config(&state);
};
Ok(Json(json!({
"success": true,
"model": state.config.comfyui.agent_model,
"mode": mode.as_str(),
"keyName": key_name,
"userId": auth.user_id,
"role": auth.role,
"apiBase": upstream_base,
"upstreamBase": upstream_base,
"upstreamLabel": upstream_label,
"comfyui": state.comfyui.status_response().await,
})))
}
async fn models_handler(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, (axum::http::StatusCode, Json<Value>)> {
let _auth = resolve_agent_auth(&headers, &state).await?;
let rows: Vec<String> = sqlx::query_scalar(
"SELECT model_pattern
FROM token_routes
WHERE COALESCE(enabled, 1) = 1
ORDER BY model_pattern COLLATE NOCASE",
)
.fetch_all(&*state.db)
.await
.map_err(|err| {
(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": err.to_string() })),
)
})?;
let mut seen = std::collections::HashSet::new();
let models: Vec<String> = rows
.into_iter()
.map(|value| value.trim().to_string())
.filter(|value| is_exact_model_pattern(value))
.filter(|value| seen.insert(value.clone()))
.collect();
Ok(Json(json!({
"success": true,
"models": models,
"defaultModel": state.config.comfyui.agent_model,
})))
}
async fn image_models_handler(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, (axum::http::StatusCode, Json<Value>)> {
let _auth = resolve_agent_auth(&headers, &state).await?;
let rows: Vec<String> = sqlx::query_scalar(
"SELECT model_pattern
FROM token_routes
WHERE COALESCE(enabled, 1) = 1
ORDER BY model_pattern COLLATE NOCASE",
)
.fetch_all(&*state.db)
.await
.map_err(|err| {
(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": err.to_string() })),
)
})?;
let mut seen = std::collections::HashSet::new();
let models: Vec<String> = rows
.into_iter()
.map(|value| value.trim().to_string())
.filter(|value| is_image_model_pattern(value))
.filter(|value| seen.insert(value.clone()))
.collect();
Ok(Json(json!({
"success": true,
"models": models,
"defaultModel": models
.iter()
.find(|model| model.to_ascii_lowercase().contains("image"))
.or_else(|| models.first())
.cloned()
.unwrap_or_else(|| "gpt-image-2".to_string()),
})))
}
#[derive(Debug, Default)]
struct ParsedSections {
characters: Vec<CharacterSection>,
@@ -271,7 +601,12 @@ fn parse_sections(full_text: &str) -> ParsedSections {
sections
}
async fn call_image_gen(api_base: &str, api_key: &str, prompt: &str) -> Result<String, String> {
async fn call_image_gen(
api_base: &str,
api_key: &str,
image_model: &str,
prompt: &str,
) -> Result<String, String> {
let client = Client::new();
let url = format!("{}/images/generations", api_base.trim_end_matches('/'));
@@ -279,7 +614,7 @@ async fn call_image_gen(api_base: &str, api_key: &str, prompt: &str) -> Result<S
.post(&url)
.bearer_auth(api_key)
.json(&json!({
"model": "gpt-image-2",
"model": image_model,
"prompt": prompt,
"n": 1,
"size": "1024x1024"
@@ -348,24 +683,184 @@ fn send_sse(tx: &UnboundedSender<Result<Event, Infallible>>, payload: Value) ->
.is_ok()
}
fn string_field(value: &Value, keys: &[&str]) -> Option<String> {
keys.iter()
.find_map(|key| value.get(*key).and_then(Value::as_str))
.map(ToOwned::to_owned)
.filter(|text| !text.is_empty())
}
fn extract_reasoning_delta(parsed: &Value) -> Option<String> {
if let Some(delta) = parsed
.get("choices")
.and_then(Value::as_array)
.and_then(|choices| choices.first())
.and_then(|choice| choice.get("delta"))
{
if let Some(text) = string_field(
delta,
&[
"reasoning_content",
"reasoning",
"thinking",
"thinking_delta",
"reasoning_delta",
],
) {
return Some(text);
}
if let Some(text) = delta
.get("reasoning")
.and_then(|value| value.get("content").or_else(|| value.get("text")))
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.filter(|text| !text.is_empty())
{
return Some(text);
}
}
let event_type = parsed
.get("type")
.and_then(Value::as_str)
.unwrap_or_default();
match event_type {
"response.reasoning.delta"
| "response.reasoning_text.delta"
| "response.reasoning_summary_text.delta" => {
string_field(parsed, &["delta", "text", "content"])
}
"content_block_delta" => {
let delta = parsed.get("delta")?;
match delta
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
{
"thinking_delta" => string_field(delta, &["thinking", "text", "delta"]),
"signature_delta" => None,
other if other.contains("thinking") || other.contains("reasoning") => {
string_field(delta, &["thinking", "text", "content", "delta"])
}
_ => None,
}
}
other if other.contains("reasoning") || other.contains("thinking") => string_field(
parsed,
&["delta", "text", "content", "thinking", "reasoning"],
),
_ => None,
}
}
fn attachment_summary(attachment: &AgentAttachment) -> String {
let mime = attachment
.mime_type
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("application/octet-stream");
let name = attachment.name.trim();
let name = if name.is_empty() { "untitled" } else { name };
let reference = attachment
.reference_label
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("@素材");
let media_kind = attachment
.media_kind
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("file");
let header =
format!("\n\n[素材引用: {reference}, 名称: {name}, 类型: {mime}, 分类: {media_kind}]");
if let Some(text) = attachment
.text
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
format!("{header}\n{text}")
} else {
header
}
}
fn agent_message_to_upstream(message: AgentMessage) -> Value {
let role = message.role;
let mut text = message.content;
let mut content_parts = Vec::new();
for attachment in &message.attachments {
let mime = attachment
.mime_type
.as_deref()
.unwrap_or_default()
.to_ascii_lowercase();
if mime.starts_with("image/") {
if let Some(data_url) = attachment
.data_url
.as_deref()
.map(str::trim)
.filter(|value| value.starts_with("data:image/"))
{
if !content_parts
.iter()
.any(|part: &Value| part["type"] == "text")
{
content_parts.push(json!({
"type": "text",
"text": text,
}));
text = String::new();
}
content_parts.push(json!({
"type": "image_url",
"image_url": { "url": data_url },
}));
continue;
}
}
text.push_str(&attachment_summary(attachment));
}
if !content_parts.is_empty() {
if !text.trim().is_empty() {
content_parts.push(json!({
"type": "text",
"text": text,
}));
}
json!({
"role": role,
"content": content_parts,
})
} else {
json!({
"role": role,
"content": text,
})
}
}
async fn chat_handler(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<AgentChatRequest>,
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, (axum::http::StatusCode, Json<Value>)>
{
if !authorize(&headers, &state) {
return Err((
axum::http::StatusCode::UNAUTHORIZED,
Json(json!({ "error": "Unauthorized" })),
));
}
let auth = resolve_agent_auth(&headers, &state).await?;
if body.messages.is_empty()
|| body
.messages
.iter()
.any(|message| message.role.trim().is_empty() || message.content.trim().is_empty())
|| body.messages.iter().any(|message| {
message.role.trim().is_empty()
|| (message.content.trim().is_empty() && message.attachments.is_empty())
})
{
return Err((
axum::http::StatusCode::BAD_REQUEST,
@@ -373,18 +868,34 @@ async fn chat_handler(
));
}
let requested_model = body
.model
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let requested_image_model = body
.image_model
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let messages: Vec<Value> = body
.messages
.into_iter()
.map(|message| {
json!({
"role": message.role,
"content": message.content,
})
})
.map(agent_message_to_upstream)
.collect();
let (api_base, api_key) = upstream_config(&state);
let agent_model = state.config.comfyui.agent_model.clone();
let (mode, api_base, api_key, key_name) = upstream_config(&state, &auth);
info!(
mode = mode.as_str(),
upstream_base = %api_base,
key_name = %key_name,
user_id = ?auth.user_id,
"comfyui-agent: resolved upstream",
);
let agent_model = requested_model.unwrap_or_else(|| state.config.comfyui.agent_model.clone());
let image_model = requested_image_model.unwrap_or_else(|| "gpt-image-2".to_string());
let (tx, rx) = unbounded_channel::<Result<Event, Infallible>>();
tokio::spawn(async move {
@@ -499,12 +1010,28 @@ async fn chat_handler(
continue;
};
let content = parsed
if let Some(reasoning) = extract_reasoning_delta(&parsed) {
if !send_sse(
&tx,
json!({
"type": "reasoning",
"content": reasoning,
}),
) {
return;
}
}
let delta = parsed
.get("choices")
.and_then(Value::as_array)
.and_then(|choices| choices.first())
.and_then(|choice| choice.get("delta"))
.and_then(|delta| delta.get("content"))
.cloned()
.unwrap_or(Value::Null);
let content = delta
.get("content")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
@@ -579,7 +1106,7 @@ async fn chat_handler(
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
}
match call_image_gen(&api_base, &api_key, prompt).await {
match call_image_gen(&api_base, &api_key, &image_model, prompt).await {
Ok(url) if !url.is_empty() => {
send_sse(
&tx,
@@ -613,7 +1140,7 @@ async fn chat_handler(
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
match call_image_gen(&api_base, &api_key, &scene.prompt).await {
match call_image_gen(&api_base, &api_key, &image_model, &scene.prompt).await {
Ok(url) if !url.is_empty() => {
send_sse(
&tx,
@@ -723,3 +1250,232 @@ async fn chat_handler(
Ok(Sse::new(UnboundedReceiverStream::new(rx)))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{AppConfig, AuthConfig, DbConfig, ProxyConfig, ServerConfig};
use crate::services::builtin_keys::BuiltinKeys;
use axum::http::HeaderValue;
use sqlx::sqlite::SqlitePoolOptions;
use tokio::sync::Mutex;
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
/// `BOOSAPI_UPSTREAM_*` is process-global. Serialize tests that read or
/// mutate it so cargo's parallel runner does not race their assertions.
/// Uses `tokio::sync::Mutex` so the guard can safely span `.await` points
/// (mock server setup, agent worker poll loop) without tripping
/// `clippy::await_holding_lock`.
static ENV_GUARD: Mutex<()> = Mutex::const_new(());
async fn make_state(host: &str, port: u16) -> AppState {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
AppState::new(
AppConfig {
server: ServerConfig {
host: host.to_string(),
port,
log_level: "info".into(),
cors_origins: vec![],
},
db: DbConfig {
url: "sqlite::memory:".into(),
max_connections: 1,
ssl: false,
runtime_db_url: None,
runtime_db_dialect: None,
runtime_db_ssl: None,
},
auth: AuthConfig {
jwt_secret: "secret".into(),
jwt_expiry_hours: 1,
auth_token: "auth".into(),
proxy_token: "proxy".into(),
},
proxy: ProxyConfig {
upstream_timeout_secs: 30,
first_byte_timeout_secs: 30,
max_retries: 3,
proxy_log_retention_days: 7,
template_proxy_url: None,
},
comfyui: Default::default(),
},
pool,
BuiltinKeys {
comfyui_agent: (1, "sk-comfyui-test".into()),
},
crate::services::runtime_auth::RuntimeAuthSettings::from_token_for_test("proxy"),
)
}
fn clear_upstream_env() {
std::env::remove_var("BOOSAPI_UPSTREAM_BASE");
std::env::remove_var("BOOSAPI_UPSTREAM_KEY");
}
fn auth_headers() -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert("Authorization", HeaderValue::from_static("Bearer auth"));
headers
}
async fn call_status(state: AppState) -> Value {
let result = status_handler(State(state), auth_headers()).await;
result.expect("status_handler returned error").0
}
#[test]
fn extracts_chat_reasoning_delta() {
let parsed = json!({
"choices": [{
"delta": {
"reasoning_content": "正在拆解镜头..."
}
}]
});
assert_eq!(
extract_reasoning_delta(&parsed).as_deref(),
Some("正在拆解镜头...")
);
}
#[test]
fn extracts_responses_reasoning_delta() {
let parsed = json!({
"type": "response.reasoning_summary_text.delta",
"delta": "先分析角色和场景。"
});
assert_eq!(
extract_reasoning_delta(&parsed).as_deref(),
Some("先分析角色和场景。")
);
}
#[test]
fn extracts_anthropic_thinking_delta() {
let parsed = json!({
"type": "content_block_delta",
"index": 0,
"delta": {
"type": "thinking_delta",
"thinking": "用户需要一个视频分镜。"
}
});
assert_eq!(
extract_reasoning_delta(&parsed).as_deref(),
Some("用户需要一个视频分镜。")
);
}
/// 3.5 — `/api/comfyui-agent/status` reflects the env state in one shot:
/// no env → local; whitespace/empty env → still local (treated as unset);
/// real env → fallback_remote with trimmed `upstreamBase`.
#[tokio::test]
async fn status_reflects_env_state_across_three_scenarios() {
let _g = ENV_GUARD.lock().await;
clear_upstream_env();
// Scenario A: env unset → local; 0.0.0.0 host normalizes to 127.0.0.1.
{
let state = make_state("0.0.0.0", 7860).await;
let body = call_status(state).await;
assert_eq!(body["mode"], "local");
assert_eq!(body["keyName"], "global-proxy");
assert_eq!(body["upstreamBase"], "http://127.0.0.1:7860/v1");
}
// Scenario B: env set to whitespace/empty → treated as unset → local.
std::env::set_var("BOOSAPI_UPSTREAM_BASE", " ");
std::env::set_var("BOOSAPI_UPSTREAM_KEY", "");
{
let state = make_state("127.0.0.1", 9000).await;
let body = call_status(state).await;
assert_eq!(body["mode"], "local");
assert_eq!(body["keyName"], "global-proxy");
assert_eq!(body["upstreamBase"], "http://127.0.0.1:9000/v1");
}
// Scenario C: env set → fallback_remote, base trimmed of trailing slash.
std::env::set_var("BOOSAPI_UPSTREAM_BASE", "https://api.example.com/v1/");
std::env::remove_var("BOOSAPI_UPSTREAM_KEY");
{
let state = make_state("0.0.0.0", 7860).await;
let body = call_status(state).await;
assert_eq!(body["mode"], "fallback_remote");
assert_eq!(body["keyName"], "global-proxy");
assert_eq!(body["upstreamBase"], "https://api.example.com/v1");
}
clear_upstream_env();
}
/// 3.6 — In Local mode the agent's chat call must hit the local
/// `/v1/chat/completions` endpoint with the current caller's downstream
/// key as the bearer token.
#[tokio::test]
async fn agent_local_mode_routes_chat_to_local_upstream() {
let _g = ENV_GUARD.lock().await;
clear_upstream_env();
let mock = MockServer::start().await;
let sse = "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\ndata: [DONE]\n\n";
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.and(header("authorization", "Bearer proxy"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(sse),
)
.mount(&mock)
.await;
let addr = mock.address();
let host = addr.ip().to_string();
let port = addr.port();
let state = make_state(&host, port).await;
let result = chat_handler(
State(state),
auth_headers(),
Json(AgentChatRequest {
messages: vec![AgentMessage {
role: "user".to_string(),
content: "hello".to_string(),
attachments: Vec::new(),
}],
model: None,
image_model: None,
}),
)
.await;
assert!(result.is_ok(), "chat_handler should accept the request");
// The Sse stream is dropped here; the worker still runs the upstream
// POST before its streaming loop notices `tx.closed()`.
// Wait up to ~2s for the spawned worker's POST to land at wiremock.
let mut hit = false;
for _ in 0..40 {
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
let received = mock.received_requests().await.unwrap_or_default();
if received
.iter()
.any(|r| r.url.path() == "/v1/chat/completions")
{
hit = true;
break;
}
}
assert!(
hit,
"expected /v1/chat/completions to be hit by the local-mode agent"
);
}
}
File diff suppressed because it is too large Load Diff
+671 -13
View File
@@ -1,14 +1,23 @@
// Events routes — system event notifications
use crate::{db::models::event::Event, error::AppResult, AppState};
use crate::{
db::models::event::Event,
error::{AppError, AppResult},
services::runtime_logs,
AppState,
};
use axum::{
extract::{Path, Query, State},
http::HeaderMap,
middleware,
response::Response,
routing::{get, post},
Json, Router,
};
use serde::Deserialize;
use serde::Serialize;
use serde_json::{json, Value};
use tracing::info;
use tracing::{debug, info};
#[derive(Debug, Deserialize)]
pub struct ListEventsQuery {
@@ -19,12 +28,35 @@ pub struct ListEventsQuery {
read: Option<String>, // "true" | "false"
}
pub fn routes() -> Router<AppState> {
pub fn routes(state: AppState) -> Router<AppState> {
Router::new()
.route("/api/events", get(list_events).delete(clear_events))
.route("/api/events/count", get(unread_count))
.route("/api/events/{id}/read", post(mark_read))
.route("/api/events/read-all", post(mark_all_read))
.route_layer(middleware::from_fn_with_state(
state,
require_events_admin_auth,
))
}
async fn require_events_admin_auth(
State(state): State<AppState>,
headers: HeaderMap,
request: axum::http::Request<axum::body::Body>,
next: axum::middleware::Next,
) -> Result<Response, AppError> {
let auth_header = headers
.get("Authorization")
.and_then(|v| v.to_str().ok())
.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()));
}
Ok(next.run(request).await)
}
/// GET /api/events
@@ -32,20 +64,29 @@ async fn list_events(
State(state): State<AppState>,
Query(q): Query<ListEventsQuery>,
) -> AppResult<Json<Value>> {
info!("Listing events");
debug!("Listing events");
let limit = q.limit.unwrap_or(30).clamp(1, 500);
let offset = q.offset.unwrap_or(0).max(0);
let filter_type = q
.filter_type
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
let read_filter = q.read.as_deref();
let source_limit = offset
.saturating_add(limit)
.saturating_add(200)
.clamp(50, 1000);
let mut sql = "SELECT * FROM events WHERE 1=1".to_string();
let mut bind_idx = 0;
let mut sql = "SELECT id, type, title, message, level, read, related_id, related_type, created_at FROM events WHERE 1=1".to_string();
let mut params: Vec<String> = vec![];
if let Some(ref t) = q.filter_type {
bind_idx += 1;
sql.push_str(&format!(" AND type = ?{}", bind_idx));
if let Some(ref t) = filter_type {
sql.push_str(" AND type = ?");
params.push(t.clone());
}
if let Some(ref r) = q.read {
if let Some(r) = read_filter {
if r == "true" {
sql.push_str(" AND read = 1");
} else if r == "false" {
@@ -53,16 +94,304 @@ async fn list_events(
}
}
sql.push_str(" ORDER BY created_at DESC");
sql.push_str(&format!(" LIMIT {} OFFSET {}", limit, offset));
sql.push_str(" ORDER BY created_at DESC LIMIT 1000");
let mut query = sqlx::query_as::<_, Event>(&sql);
for p in &params {
query = query.bind(p);
}
let events: Vec<Event> = query.fetch_all(&*state.db).await?;
let mut rows: Vec<EventListItem> = events.into_iter().map(EventListItem::from).collect();
Ok(Json(json!({ "data": events })))
if should_include_source(filter_type.as_deref(), "runtime") {
rows.extend(
runtime_logs::list()
.into_iter()
.filter(|entry| read_filter != Some("false") || !entry.is_read)
.map(EventListItem::from),
);
}
if should_include_read_only_source(filter_type.as_deref(), read_filter, "checkin") {
rows.extend(load_checkin_log_events(&state, source_limit).await?);
}
if should_include_read_only_source(filter_type.as_deref(), read_filter, "proxy") {
rows.extend(load_proxy_log_events(&state, source_limit).await?);
}
if should_include_read_only_source(filter_type.as_deref(), read_filter, "balance") {
rows.extend(load_balance_log_events(&state, source_limit).await?);
}
if should_include_read_only_source(filter_type.as_deref(), read_filter, "token") {
rows.extend(load_token_log_events(&state, source_limit).await?);
}
if should_include_read_only_source(filter_type.as_deref(), read_filter, "site_notice") {
rows.extend(load_site_notice_events(&state, source_limit).await?);
}
if should_include_read_only_source(filter_type.as_deref(), read_filter, "status") {
rows.extend(load_status_log_events(&state, source_limit).await?);
}
rows.sort_by(|a, b| {
b.created_at
.cmp(&a.created_at)
.then_with(|| a.id.cmp(&b.id))
});
let start = offset as usize;
let end = start.saturating_add(limit as usize).min(rows.len());
let page = if start < rows.len() {
rows[start..end].to_vec()
} else {
Vec::new()
};
Ok(Json(json!({ "data": page })))
}
fn should_include_source(filter_type: Option<&str>, source: &str) -> bool {
filter_type.is_none_or(|value| value == source)
}
fn should_include_read_only_source(
filter_type: Option<&str>,
read_filter: Option<&str>,
source: &str,
) -> bool {
should_include_source(filter_type, source) && read_filter != Some("false")
}
async fn load_checkin_log_events(state: &AppState, limit: i64) -> AppResult<Vec<EventListItem>> {
let rows = sqlx::query_as::<_, EventListItem>(
r#"
SELECT
(-10000000000 - c.id) AS id,
'checkin' AS event_type,
CASE
WHEN lower(COALESCE(c.status, '')) IN ('success', 'ok', 'completed') THEN '签到成功'
WHEN lower(COALESCE(c.status, '')) IN ('skipped', 'skip') THEN '签到跳过'
ELSE '签到失败'
END || ':' || COALESCE(NULLIF(s.name, ''), '账号 #' || c.account_id) AS title,
COALESCE(NULLIF(a.username, ''), '账号 #' || c.account_id)
|| ' · 状态=' || COALESCE(NULLIF(c.status, ''), '-')
|| CASE WHEN c.reward IS NOT NULL AND trim(c.reward) != '' THEN ' · 奖励=' || c.reward ELSE '' END
|| CASE WHEN c.message IS NOT NULL AND trim(c.message) != '' THEN ' · ' || c.message ELSE '' END AS message,
CASE
WHEN lower(COALESCE(c.status, '')) IN ('success', 'ok', 'completed') THEN 'info'
WHEN lower(COALESCE(c.status, '')) IN ('skipped', 'skip') THEN 'warning'
ELSE 'error'
END AS level,
1 AS is_read,
c.id AS related_id,
'checkin_log' AS related_type,
c.created_at AS created_at
FROM checkin_logs c
LEFT JOIN accounts a ON a.id = c.account_id
LEFT JOIN sites s ON s.id = a.site_id
ORDER BY c.created_at DESC
LIMIT ?
"#,
)
.bind(limit)
.fetch_all(&*state.db)
.await?;
Ok(rows)
}
async fn load_proxy_log_events(state: &AppState, limit: i64) -> AppResult<Vec<EventListItem>> {
let rows = sqlx::query_as::<_, EventListItem>(
r#"
SELECT
(-20000000000 - l.id) AS id,
'proxy' AS event_type,
CASE
WHEN lower(COALESCE(l.status, '')) = 'success' THEN '代理请求成功'
ELSE '代理请求失败'
END || ':' || COALESCE(NULLIF(l.model_requested, ''), NULLIF(l.model_actual, ''), '未知模型') AS title,
'站点=' || COALESCE(NULLIF(s.name, ''), '-')
|| ' · 账号=' || COALESCE(NULLIF(a.username, ''), CASE WHEN l.account_id IS NOT NULL THEN '账号 #' || l.account_id ELSE '-' END)
|| ' · 状态=' || COALESCE(NULLIF(l.status, ''), '-')
|| CASE WHEN l.http_status IS NOT NULL THEN ' · HTTP ' || l.http_status ELSE '' END
|| CASE WHEN l.latency_ms IS NOT NULL THEN ' · ' || l.latency_ms || 'ms' ELSE '' END
|| CASE WHEN l.total_tokens IS NOT NULL THEN ' · Tokens=' || l.total_tokens ELSE '' END
|| CASE WHEN l.estimated_cost IS NOT NULL THEN ' · 成本=' || printf('$%.6f', l.estimated_cost) ELSE '' END
|| CASE WHEN l.error_message IS NOT NULL AND trim(l.error_message) != '' THEN ' · ' || l.error_message ELSE '' END AS message,
CASE WHEN lower(COALESCE(l.status, '')) = 'success' THEN 'info' ELSE 'error' END AS level,
1 AS is_read,
l.id AS related_id,
'proxy_log' AS related_type,
l.created_at AS created_at
FROM proxy_logs l
LEFT JOIN accounts a ON a.id = l.account_id
LEFT JOIN sites s ON s.id = a.site_id
ORDER BY l.created_at DESC
LIMIT ?
"#,
)
.bind(limit)
.fetch_all(&*state.db)
.await?;
Ok(rows)
}
async fn load_balance_log_events(state: &AppState, limit: i64) -> AppResult<Vec<EventListItem>> {
let rows = sqlx::query_as::<_, EventListItem>(
r#"
SELECT
(-30000000000 - a.id) AS id,
'balance' AS event_type,
'余额已刷新:' || COALESCE(NULLIF(s.name, ''), '账号 #' || a.id) AS title,
COALESCE(NULLIF(a.username, ''), '账号 #' || a.id)
|| ' · 当前余额=' || printf('%.6f', COALESCE(a.balance, 0))
|| ' · 已用=' || printf('%.6f', COALESCE(a.balance_used, 0))
|| ' · 额度=' || printf('%.6f', COALESCE(a.quota, 0)) AS message,
'info' AS level,
1 AS is_read,
a.id AS related_id,
'account' AS related_type,
a.last_balance_refresh AS created_at
FROM accounts a
LEFT JOIN sites s ON s.id = a.site_id
WHERE a.last_balance_refresh IS NOT NULL
AND trim(a.last_balance_refresh) != ''
ORDER BY a.last_balance_refresh DESC
LIMIT ?
"#,
)
.bind(limit)
.fetch_all(&*state.db)
.await?;
Ok(rows)
}
async fn load_token_log_events(state: &AppState, limit: i64) -> AppResult<Vec<EventListItem>> {
let rows = sqlx::query_as::<_, EventListItem>(
r#"
SELECT
(-40000000000 - t.id) AS id,
'token' AS event_type,
CASE
WHEN COALESCE(t.updated_at, t.created_at, '') != COALESCE(t.created_at, '') THEN '令牌已更新'
ELSE '令牌已创建'
END || ':' || COALESCE(NULLIF(t.name, ''), 'Token #' || t.id) AS title,
COALESCE(NULLIF(s.name, ''), '-')
|| ' · ' || COALESCE(NULLIF(a.username, ''), '账号 #' || t.account_id)
|| ' · 状态=' || COALESCE(NULLIF(t.value_status, ''), '-')
|| CASE WHEN COALESCE(t.enabled, 1) = 1 THEN ' · 已启用' ELSE ' · 已禁用' END
|| CASE WHEN t.token_group IS NOT NULL AND trim(t.token_group) != '' THEN ' · 分组=' || t.token_group ELSE '' END
|| CASE WHEN t.source IS NOT NULL AND trim(t.source) != '' THEN ' · 来源=' || t.source ELSE '' END AS message,
CASE
WHEN COALESCE(t.enabled, 1) = 1
AND lower(COALESCE(t.value_status, 'ready')) IN ('ready', 'valid') THEN 'info'
ELSE 'warning'
END AS level,
1 AS is_read,
t.id AS related_id,
'account_token' AS related_type,
COALESCE(t.updated_at, t.created_at) AS created_at
FROM account_tokens t
LEFT JOIN accounts a ON a.id = t.account_id
LEFT JOIN sites s ON s.id = a.site_id
WHERE COALESCE(t.updated_at, t.created_at) IS NOT NULL
AND trim(COALESCE(t.updated_at, t.created_at)) != ''
ORDER BY COALESCE(t.updated_at, t.created_at) DESC
LIMIT ?
"#,
)
.bind(limit)
.fetch_all(&*state.db)
.await?;
Ok(rows)
}
async fn load_site_notice_events(state: &AppState, limit: i64) -> AppResult<Vec<EventListItem>> {
let rows = sqlx::query_as::<_, EventListItem>(
r#"
SELECT
(-50000000000 - n.id) AS id,
'site_notice' AS event_type,
'站点公告:' || COALESCE(NULLIF(n.title, ''), '未命名公告') AS title,
COALESCE(NULLIF(s.name, ''), '站点 #' || n.site_id)
|| CASE WHEN n.content IS NOT NULL AND trim(n.content) != '' THEN ' · ' || substr(n.content, 1, 420) ELSE '' END AS message,
COALESCE(NULLIF(n.level, ''), 'info') AS level,
1 AS is_read,
n.id AS related_id,
'site_announcement' AS related_type,
COALESCE(n.upstream_created_at, n.first_seen_at) AS created_at
FROM site_announcements n
LEFT JOIN sites s ON s.id = n.site_id
ORDER BY COALESCE(n.upstream_created_at, n.first_seen_at) DESC
LIMIT ?
"#,
)
.bind(limit)
.fetch_all(&*state.db)
.await?;
Ok(rows)
}
async fn load_status_log_events(state: &AppState, limit: i64) -> AppResult<Vec<EventListItem>> {
let per_source_limit = limit.clamp(50, 1000);
let mut rows = sqlx::query_as::<_, EventListItem>(
r#"
SELECT
(-60000000000 - s.id) AS id,
'status' AS event_type,
'站点状态:' || COALESCE(NULLIF(s.name, ''), '站点 #' || s.id) AS title,
'平台=' || COALESCE(NULLIF(s.platform, ''), '-')
|| ' · 状态=' || COALESCE(NULLIF(s.status, ''), '-')
|| CASE WHEN s.url IS NOT NULL AND trim(s.url) != '' THEN ' · ' || s.url ELSE '' END AS message,
CASE
WHEN lower(COALESCE(s.status, 'active')) IN ('active', 'enabled', 'healthy') THEN 'info'
WHEN lower(COALESCE(s.status, '')) IN ('disabled', 'inactive') THEN 'warning'
ELSE 'error'
END AS level,
1 AS is_read,
s.id AS related_id,
'site' AS related_type,
COALESCE(s.updated_at, s.created_at) AS created_at
FROM sites s
WHERE COALESCE(s.updated_at, s.created_at) IS NOT NULL
AND trim(COALESCE(s.updated_at, s.created_at)) != ''
ORDER BY COALESCE(s.updated_at, s.created_at) DESC
LIMIT ?
"#,
)
.bind(per_source_limit)
.fetch_all(&*state.db)
.await?;
rows.extend(
sqlx::query_as::<_, EventListItem>(
r#"
SELECT
(-70000000000 - a.id) AS id,
'status' AS event_type,
'账号状态:' || COALESCE(NULLIF(s.name, ''), '站点 #' || a.site_id) AS title,
COALESCE(NULLIF(a.username, ''), '账号 #' || a.id)
|| ' · 状态=' || COALESCE(NULLIF(a.status, ''), '-')
|| CASE WHEN a.oauth_provider IS NOT NULL AND trim(a.oauth_provider) != '' THEN ' · OAuth=' || a.oauth_provider ELSE '' END AS message,
CASE
WHEN lower(COALESCE(a.status, 'active')) IN ('active', 'enabled', 'healthy') THEN 'info'
WHEN lower(COALESCE(a.status, '')) IN ('disabled', 'inactive') THEN 'warning'
ELSE 'error'
END AS level,
1 AS is_read,
a.id AS related_id,
'account' AS related_type,
COALESCE(a.updated_at, a.created_at) AS created_at
FROM accounts a
LEFT JOIN sites s ON s.id = a.site_id
WHERE COALESCE(a.updated_at, a.created_at) IS NOT NULL
AND trim(COALESCE(a.updated_at, a.created_at)) != ''
ORDER BY COALESCE(a.updated_at, a.created_at) DESC
LIMIT ?
"#,
)
.bind(per_source_limit)
.fetch_all(&*state.db)
.await?,
);
Ok(rows)
}
/// GET /api/events/count — unread count
@@ -98,5 +427,334 @@ async fn clear_events(State(state): State<AppState>) -> AppResult<Json<Value>> {
sqlx::query("DELETE FROM events")
.execute(&*state.db)
.await?;
runtime_logs::clear();
Ok(Json(json!({ "success": true })))
}
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
#[serde(rename_all = "camelCase")]
struct EventListItem {
id: i64,
#[serde(rename = "type")]
event_type: String,
title: String,
message: Option<String>,
level: String,
#[serde(rename = "read")]
is_read: bool,
related_id: Option<i64>,
related_type: Option<String>,
created_at: Option<String>,
}
impl From<Event> for EventListItem {
fn from(event: Event) -> Self {
Self {
id: event.id,
event_type: event.event_type,
title: event.title,
message: event.message,
level: event.level.unwrap_or_else(|| "info".to_string()),
is_read: event.is_read.unwrap_or(false),
related_id: event.related_id,
related_type: event.related_type,
created_at: event.created_at,
}
}
}
impl From<runtime_logs::RuntimeLogEntry> for EventListItem {
fn from(entry: runtime_logs::RuntimeLogEntry) -> Self {
Self {
id: entry.id,
event_type: entry.event_type,
title: entry.title,
message: entry.message,
level: entry.level,
is_read: entry.is_read,
related_id: entry.related_id,
related_type: entry.related_type,
created_at: Some(entry.created_at),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
auth::jwt::generate_jwt,
config::{AppConfig, AuthConfig, DbConfig, ProxyConfig, ServerConfig},
services::{builtin_keys::BuiltinKeys, runtime_auth::RuntimeAuthSettings},
};
use axum::{
body::Body,
http::{Request, StatusCode},
};
use axum::extract::{Query, State};
use sqlx::sqlite::SqlitePoolOptions;
use tower::ServiceExt as _;
async fn test_state() -> AppState {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("memory sqlite");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("run migrations");
AppState::new(
AppConfig {
server: ServerConfig {
host: "127.0.0.1".into(),
port: 0,
log_level: "info".into(),
cors_origins: vec![],
},
db: DbConfig {
url: "sqlite::memory:".into(),
max_connections: 1,
ssl: false,
runtime_db_url: None,
runtime_db_dialect: None,
runtime_db_ssl: None,
},
auth: AuthConfig {
jwt_secret: "secret".into(),
jwt_expiry_hours: 1,
auth_token: "auth".into(),
proxy_token: "proxy".into(),
},
proxy: ProxyConfig {
upstream_timeout_secs: 30,
first_byte_timeout_secs: 30,
max_retries: 3,
proxy_log_retention_days: 7,
template_proxy_url: None,
},
comfyui: Default::default(),
},
pool,
BuiltinKeys {
comfyui_agent: (1, "sk-comfyui-test".into()),
},
RuntimeAuthSettings::from_token_for_test("proxy"),
)
}
async fn seed_event_sources(state: &AppState) {
sqlx::query(
"INSERT INTO sites (id, name, url, platform, status)
VALUES (1, '主站', 'https://example.test', 'new-api', 'active')",
)
.execute(&*state.db)
.await
.unwrap();
sqlx::query(
"INSERT INTO accounts
(id, site_id, username, access_token, balance, balance_used, quota, last_balance_refresh, status)
VALUES
(10, 1, 'alice', 'access-token', 12.5, 1.25, 20.0, '2026-06-08T09:30:00Z', 'active')",
)
.execute(&*state.db)
.await
.unwrap();
sqlx::query(
"INSERT INTO account_tokens
(id, account_id, name, token, source, token_group, value_status, enabled, created_at, updated_at)
VALUES
(20, 10, '默认 Key', 'sk-account-token', 'manual', 'prod', 'ready', 1, '2026-06-08T09:00:00Z', '2026-06-08T09:40:00Z')",
)
.execute(&*state.db)
.await
.unwrap();
sqlx::query(
"INSERT INTO checkin_logs (id, account_id, status, message, reward, created_at)
VALUES (30, 10, 'success', 'Daily checkin completed', '+$0.0100', '2026-06-08T09:50:00Z')",
)
.execute(&*state.db)
.await
.unwrap();
sqlx::query(
"INSERT INTO proxy_logs
(id, account_id, model_requested, model_actual, status, http_status, latency_ms, total_tokens, estimated_cost, error_message, created_at)
VALUES
(40, 10, 'gpt-test', 'gpt-test', 'failed', 404, 812, 33, 0.0012, 'model not found', '2026-06-08T10:00:00Z')",
)
.execute(&*state.db)
.await
.unwrap();
sqlx::query(
"INSERT INTO site_announcements
(id, site_id, platform, source_key, title, content, level, first_seen_at, read_at)
VALUES
(50, 1, 'new-api', 'notice-1', '维护通知', '上游将维护 30 分钟', 'warning', '2026-06-08T10:10:00Z', NULL)",
)
.execute(&*state.db)
.await
.unwrap();
}
async fn list_for_type(state: AppState, filter_type: Option<&str>) -> Value {
let Json(body) = list_events(
State(state),
Query(ListEventsQuery {
limit: Some(50),
offset: Some(0),
filter_type: filter_type.map(str::to_string),
read: None,
}),
)
.await
.unwrap();
body
}
#[tokio::test]
async fn events_router_requires_admin_api_token() {
let state = test_state().await;
let app = routes(state.clone()).with_state(state.clone());
let ok_response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/events")
.header("Authorization", "Bearer auth")
.body(Body::empty())
.unwrap(),
)
.await
.expect("admin token request");
assert_eq!(ok_response.status(), StatusCode::OK);
let user_jwt = generate_jwt(&state.config.auth.jwt_secret, "1", "user", 1)
.expect("user jwt");
let user_response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/events")
.header("Authorization", format!("Bearer {user_jwt}"))
.body(Body::empty())
.unwrap(),
)
.await
.expect("user jwt request");
assert_eq!(user_response.status(), StatusCode::UNAUTHORIZED);
let missing_response = app
.oneshot(
Request::builder()
.uri("/api/events")
.body(Body::empty())
.unwrap(),
)
.await
.expect("missing auth request");
assert_eq!(missing_response.status(), StatusCode::UNAUTHORIZED);
}
fn data_types(body: &Value) -> Vec<String> {
body["data"]
.as_array()
.unwrap()
.iter()
.filter_map(|row| row.get("type").and_then(Value::as_str).map(str::to_string))
.collect()
}
#[tokio::test]
async fn list_events_aggregates_business_log_sources() {
let state = test_state().await;
seed_event_sources(&state).await;
let all = list_for_type(state.clone(), None).await;
let all_types = data_types(&all);
assert!(all_types.contains(&"checkin".to_string()));
assert!(all_types.contains(&"proxy".to_string()));
assert!(all_types.contains(&"balance".to_string()));
assert!(all_types.contains(&"token".to_string()));
assert!(all_types.contains(&"site_notice".to_string()));
assert!(all_types.contains(&"status".to_string()));
for (filter_type, expected_title) in [
("checkin", "签到成功"),
("proxy", "代理请求失败"),
("balance", "余额已刷新"),
("token", "令牌已更新"),
("site_notice", "站点公告"),
("status", "站点状态"),
] {
let body = list_for_type(state.clone(), Some(filter_type)).await;
let rows = body["data"].as_array().unwrap();
assert!(!rows.is_empty(), "{filter_type} should return rows");
assert!(rows
.iter()
.all(|row| row.get("type").and_then(Value::as_str) == Some(filter_type)));
assert!(rows.iter().any(|row| row
.get("title")
.and_then(Value::as_str)
.unwrap_or_default()
.contains(expected_title)));
}
}
#[tokio::test]
async fn unread_filter_excludes_read_only_virtual_logs() {
let state = test_state().await;
seed_event_sources(&state).await;
let Json(checkin_body) = list_events(
State(state.clone()),
Query(ListEventsQuery {
limit: Some(50),
offset: Some(0),
filter_type: Some("checkin".into()),
read: Some("false".into()),
}),
)
.await
.unwrap();
assert!(checkin_body["data"].as_array().unwrap().is_empty());
let Json(notice_body) = list_events(
State(state),
Query(ListEventsQuery {
limit: Some(50),
offset: Some(0),
filter_type: Some("site_notice".into()),
read: Some("false".into()),
}),
)
.await
.unwrap();
assert!(notice_body["data"].as_array().unwrap().is_empty());
}
#[tokio::test]
async fn status_snapshots_are_available_in_all_logs_and_status_filter() {
let state = test_state().await;
seed_event_sources(&state).await;
let all = list_for_type(state.clone(), None).await;
assert!(data_types(&all).contains(&"status".to_string()));
let status = list_for_type(state, Some("status")).await;
let rows = status["data"].as_array().unwrap();
assert!(rows.iter().any(|row| row["title"]
.as_str()
.unwrap_or_default()
.contains("站点状态")));
assert!(rows.iter().any(|row| row["title"]
.as_str()
.unwrap_or_default()
.contains("账号状态")));
assert!(rows
.iter()
.all(|row| row.get("type").and_then(Value::as_str) == Some("status")));
}
}
+235 -42
View File
@@ -1,105 +1,298 @@
use crate::AppState;
use axum::{
body::Body,
http::{Request, StatusCode},
response::Response,
routing::get,
Router,
http::{header, HeaderValue, Request, StatusCode},
response::{IntoResponse, Response},
routing::{any, get},
Json, Router,
};
use include_dir::{include_dir, Dir};
use serde_json::{json, Value};
use std::convert::Infallible;
use tower::service_fn;
use tower_http::compression::CompressionLayer;
use tower_http::services::ServeDir;
static EMBEDDED_WEB: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/dist/web");
static EMBEDDED_WEBSITE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/dist/website");
const _: &[u8] = include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/dist/web/index.html"));
const _: &[u8] =
include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/dist/website/index.html"));
mod admin;
mod auth;
mod checkin;
mod comfyui;
mod comfyui_agent;
mod digital_human;
mod events;
mod health;
mod monitor;
mod money_printer;
mod oauth;
mod organizations;
mod picture_agent;
mod proxy;
mod search;
mod site_announcements;
mod stats;
pub(crate) mod stats;
mod tasks;
mod update_center;
mod user;
mod user_api_keys;
#[path = "video_agent_legacy.rs"]
mod video_agent;
mod workflow_ui;
mod workspace;
pub fn build_router(state: AppState) -> Router {
let web_dir = std::env::var("BOOSAPI_WEB_DIR").unwrap_or_else(|_| "dist/web".to_string());
// Build the API router
let api_routes = Router::new()
.route("/healthz", get(health::health_check))
.merge(admin::routes(state.clone()))
.merge(proxy::routes())
.merge(picture_agent::routes())
.merge(money_printer::routes())
.merge(comfyui_agent::routes())
.merge(digital_human::routes())
.merge(comfyui::routes())
.merge(crate::workflow::routes::routes())
.route("/comfy", any(removed_comfy_workbench))
.route("/comfy/", any(removed_comfy_workbench))
.route("/comfy/{*path}", any(removed_comfy_workbench));
let api_routes = api_routes
.merge(auth::routes())
.merge(events::routes())
.merge(events::routes(state.clone()))
.merge(checkin::routes())
.merge(monitor::routes())
.merge(oauth::routes())
.merge(organizations::routes())
.merge(search::routes())
.merge(site_announcements::routes())
.merge(workflow_ui::routes())
.merge(stats::routes())
.merge(tasks::routes())
.merge(update_center::routes())
.merge(user::routes())
.merge(user_api_keys::routes())
.merge(workspace::routes())
.merge(video_agent::routes())
.with_state(state);
// Static file serving with SPA fallback
let web_dir_owned = web_dir.clone();
let router = Router::new().merge(api_routes);
let router = match std::env::var("BOOSAPI_WEB_DIR")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
{
Some(web_dir) => {
let website_dir = std::env::var("BOOSAPI_WEBSITE_DIR")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "dist/website".to_string());
let static_service = ServeDir::new(&web_dir)
.append_index_html_on_directories(true)
.fallback(service_fn(move |req: Request<Body>| {
let web_dir = web_dir_owned.clone();
let web_dir = web_dir.clone();
let website_dir = website_dir.clone();
async move {
let path = req.uri().path();
if is_api_path(path) {
return Ok::<_, Infallible>(api_not_found_response(path));
}
// API routes that don't match → return JSON error, not index.html
if path.starts_with("/api/") || path.starts_with("/v1/") {
return Ok::<_, Infallible>(
Ok::<_, Infallible>(filesystem_static_response(
path,
&web_dir,
&website_dir,
)
.await)
}
}));
router.fallback_service(static_service)
}
None => router.fallback(embedded_static_handler),
};
router.layer(CompressionLayer::new())
}
fn api_not_found_response(path: &str) -> Response {
Response::builder()
.status(StatusCode::NOT_FOUND)
.header("Content-Type", "application/json; charset=utf-8")
.header(header::CONTENT_TYPE, "application/json; charset=utf-8")
.body(Body::from(
serde_json::json!({"error": "Not found", "path": path}).to_string(),
))
.unwrap(),
);
.unwrap_or_else(|_| StatusCode::NOT_FOUND.into_response())
}
async fn filesystem_static_response(path: &str, web_dir: &str, website_dir: &str) -> Response {
let root_dir = if should_serve_website(path) {
website_dir
} else {
web_dir
};
let asset_path = embedded_asset_path(path);
let candidate = format!("{}/{}", root_dir.trim_end_matches('/'), asset_path);
match tokio::fs::read(&candidate).await {
Ok(bytes) => return embedded_file_response(&asset_path, &bytes),
Err(_) => {
if candidate.ends_with("/index.html") {
let without_index = candidate.trim_end_matches("index.html");
if let Ok(bytes) = tokio::fs::read(without_index).await {
return embedded_file_response(&asset_path, &bytes);
}
}
}
}
// SPA fallback: serve index.html for client-side routing
let index_path = format!("{}/index.html", web_dir);
match tokio::fs::read_to_string(&index_path).await {
Ok(html) => Ok::<_, Infallible>(
Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "text/html; charset=utf-8")
.body(Body::from(html))
.unwrap(),
),
Err(_) => Ok::<_, Infallible>(
let index_path = format!("{}/index.html", root_dir.trim_end_matches('/'));
match tokio::fs::read(&index_path).await {
Ok(bytes) => embedded_file_response("index.html", &bytes),
Err(_) if should_serve_website(path) => {
let fallback_index = format!("{}/index.html", web_dir.trim_end_matches('/'));
match tokio::fs::read(&fallback_index).await {
Ok(bytes) => embedded_file_response("index.html", &bytes),
Err(_) => not_found_response(path),
}
}
Err(_) => not_found_response(path),
}
}
fn not_found_response(path: &str) -> Response {
Response::builder()
.status(StatusCode::NOT_FOUND)
.header("Content-Type", "text/plain; charset=utf-8")
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(Body::from(format!("404: {} not found", path)))
.unwrap(),
),
}
}
}));
Router::new()
.merge(api_routes)
.fallback_service(static_service)
.unwrap_or_else(|_| StatusCode::NOT_FOUND.into_response())
}
async fn embedded_static_handler(req: Request<Body>) -> Response {
let path = req.uri().path();
if is_api_path(path) {
return api_not_found_response(path);
}
let asset_path = embedded_asset_path(path);
let static_dir = if should_serve_website(path) {
&EMBEDDED_WEBSITE
} else {
&EMBEDDED_WEB
};
if let Some(file) = static_dir.get_file(&asset_path) {
return embedded_file_response(&asset_path, file.contents());
}
if let Some(file) = static_dir.get_file("index.html") {
return embedded_file_response("index.html", file.contents());
}
let disk_fallback = filesystem_static_response(path, "dist/web", "dist/website").await;
if disk_fallback.status() != StatusCode::NOT_FOUND {
return disk_fallback;
}
if should_serve_website(path) && EMBEDDED_WEBSITE.get_file("index.html").is_none() {
if let Some(file) = EMBEDDED_WEB.get_file("index.html") {
return embedded_file_response("index.html", file.contents());
}
}
Response::builder()
.status(StatusCode::NOT_FOUND)
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(Body::from(
"embedded frontend index.html not found; rebuild with ./build-and-run.sh --production or set BOOSAPI_WEB_DIR/BOOSAPI_WEBSITE_DIR",
))
.unwrap_or_else(|_| StatusCode::NOT_FOUND.into_response())
}
fn is_api_path(path: &str) -> bool {
path.starts_with("/api/")
|| path.starts_with("/v1/")
|| path == "/health"
|| path == "/ready"
|| path == "/metrics"
}
fn should_serve_website(path: &str) -> bool {
path == "/"
|| path == "/new"
|| path.starts_with("/new/")
|| path.starts_with("/_next/")
|| path == "/favicon.ico"
|| path == "/favicon.png"
|| path == "/favicon-64.png"
|| path == "/logo.png"
}
fn embedded_asset_path(path: &str) -> String {
let trimmed = path.trim_start_matches('/');
if trimmed == "new" || trimmed == "new/" || trimmed == "new/index.html" {
return "new.html".to_string();
}
if trimmed.is_empty() || trimmed.ends_with('/') {
format!("{trimmed}index.html")
} else {
trimmed.to_string()
}
}
fn embedded_file_response(path: &str, bytes: &[u8]) -> Response {
let content_type = mime_guess::from_path(path)
.first_or_octet_stream()
.essence_str()
.to_string();
let mut response = Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, content_type)
.body(Body::from(bytes.to_vec()))
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response());
if path.starts_with("assets/") || path.contains(".") && !path.ends_with("index.html") {
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("public, max-age=31536000, immutable"),
);
}
response
}
async fn removed_comfy_workbench() -> (StatusCode, Json<Value>) {
(
StatusCode::GONE,
Json(json!({
"error": {
"message": "内置 Docker ComfyUI 工作台已移除。请配置外部 comfyui.base_url 后使用 /api/comfyui/proxy,或打开 /workflows 使用 ComfyUI 风格工作流画布。",
"type": "ComfyUIWorkbenchRemoved"
},
"redirect": "/workflows"
})),
)
}
#[cfg(test)]
mod tests {
use super::{embedded_asset_path, should_serve_website};
#[test]
fn experimental_website_route_is_served_from_website_export() {
assert!(should_serve_website("/new"));
assert!(should_serve_website("/new/"));
assert!(should_serve_website("/new/index.html"));
assert_eq!(embedded_asset_path("/new"), "new.html");
assert_eq!(embedded_asset_path("/new/"), "new.html");
assert_eq!(embedded_asset_path("/new/index.html"), "new.html");
}
#[test]
fn application_routes_still_use_web_spa() {
assert!(!should_serve_website("/boos"));
assert!(!should_serve_website("/dashboard"));
assert!(!should_serve_website("/user/recharge"));
assert!(!should_serve_website("/logs"));
assert!(!should_serve_website("/events"));
}
}
File diff suppressed because it is too large Load Diff
-297
View File
@@ -1,297 +0,0 @@
// Monitor routes — LDOH incident dashboard proxy with cookie auth
use crate::{
error::{AppError, AppResult},
AppState,
};
use axum::{
extract::{Path, State},
http::{header, HeaderMap},
response::Response,
routing::{get, post},
Json, Router,
};
use serde_json::{json, Value};
const MONITOR_AUTH_COOKIE: &str = "meta_monitor_auth";
const LDOH_BASE_URL: &str = "https://ldoh.105117.xyz";
const LDOH_COOKIE_SETTING_KEY: &str = "monitor_ldoh_cookie";
pub fn routes() -> Router<AppState> {
Router::new()
.route(
"/api/monitor/config",
get(get_monitor_config).put(update_monitor_config),
)
.route("/api/monitor/session", post(create_session))
.route("/monitor-proxy/ldoh", get(ldoh_proxy_handler))
.route("/monitor-proxy/ldoh/", get(ldoh_proxy_handler))
.route("/monitor-proxy/ldoh/{*path}", get(ldoh_proxy_handler))
}
/// GET /api/monitor/config — returns whether LDOH cookie is configured
async fn get_monitor_config(State(state): State<AppState>) -> AppResult<Json<Value>> {
let cookie: Option<(String,)> = sqlx::query_as("SELECT value FROM settings WHERE key = ?")
.bind(LDOH_COOKIE_SETTING_KEY)
.fetch_optional(&*state.db)
.await?;
let raw = cookie
.and_then(|(v,)| {
serde_json::from_str::<serde_json::Value>(&v)
.ok()
.and_then(|parsed| parsed.as_str().map(String::from))
})
.unwrap_or_default();
let configured = !raw.is_empty();
let masked = if configured {
mask_cookie_value(&raw)
} else {
String::new()
};
Ok(Json(json!({
"ldohCookieConfigured": configured,
"ldohCookieMasked": masked,
})))
}
/// PUT /api/monitor/config — save LDOH cookie
async fn update_monitor_config(
State(state): State<AppState>,
Json(body): Json<serde_json::Value>,
) -> AppResult<Json<Value>> {
let cookie = body
.get("ldohCookie")
.and_then(|v| v.as_str())
.unwrap_or("");
if cookie.is_empty() {
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, '')")
.bind(LDOH_COOKIE_SETTING_KEY)
.execute(&*state.db)
.await?;
return Ok(Json(json!({
"success": true,
"message": "LDOH Cookie 已清空",
"ldohCookieConfigured": false,
})));
}
let normalized = normalize_ldoh_cookie(cookie);
if !normalized.starts_with("ld_auth_session=") || normalized.len() < 24 {
return Err(AppError::BadRequest("Cookie 格式无效".into()));
}
let escaped = serde_json::to_string(&normalized).unwrap_or_default();
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
.bind(LDOH_COOKIE_SETTING_KEY)
.bind(&escaped)
.execute(&*state.db)
.await?;
Ok(Json(json!({
"success": true,
"message": "LDOH Cookie 已保存",
"ldohCookieConfigured": true,
"ldohCookieMasked": mask_cookie_value(&normalized),
})))
}
/// POST /api/monitor/session — returns cookie value for client to set
async fn create_session(State(state): State<AppState>) -> Json<Value> {
let token = &state.config.auth.auth_token;
Json(json!({
"success": true,
"_cookie": format!("{}={}; Path=/; HttpOnly; SameSite=Lax; Max-Age=7200", MONITOR_AUTH_COOKIE, token),
}))
}
/// GET /monitor-proxy/ldoh/{*path} — reverse proxy to ldoh.105117.xyz
async fn ldoh_proxy_handler(
State(state): State<AppState>,
headers: HeaderMap,
Path(path): Path<String>,
) -> Result<Response, AppError> {
// Auth check via cookie
let cookies = parse_cookies(
headers
.get(header::COOKIE)
.and_then(|v| v.to_str().ok())
.unwrap_or(""),
);
if cookies.get(MONITOR_AUTH_COOKIE).map(|s| s.as_str()) != Some(&state.config.auth.auth_token) {
return Err(AppError::Unauthorized(
"Missing or invalid monitor session".into(),
));
}
// Get stored LDOH cookie
let stored: Option<(String,)> = sqlx::query_as("SELECT value FROM settings WHERE key = ?")
.bind(LDOH_COOKIE_SETTING_KEY)
.fetch_optional(&*state.db)
.await?;
let ldoh_cookie = stored
.and_then(|(v,)| {
serde_json::from_str::<serde_json::Value>(&v)
.ok()
.and_then(|parsed| parsed.as_str().map(String::from))
})
.unwrap_or_default();
if ldoh_cookie.is_empty() {
return Err(AppError::BadRequest("LDOH cookie not configured".into()));
}
// Build upstream URL
let clean_path = if path.is_empty() || path == "/" {
String::new()
} else {
format!("/{}", path)
};
let target_url = format!("{}{}", LDOH_BASE_URL, clean_path);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to build client: {e}")))?;
let mut req = client
.get(&target_url)
.header("cookie", &ldoh_cookie)
.header(
"accept",
headers
.get("accept")
.and_then(|v| v.to_str().ok())
.unwrap_or("*/*"),
)
.header(
"accept-language",
headers
.get("accept-language")
.and_then(|v| v.to_str().ok())
.unwrap_or("zh-CN,zh;q=0.9,en;q=0.8"),
)
.header(
"user-agent",
headers
.get("user-agent")
.and_then(|v| v.to_str().ok())
.unwrap_or("boosapiMonitorProxy/1.0"),
);
if let Some(referer) = headers.get("referer").and_then(|v| v.to_str().ok()) {
req = req.header("referer", referer.replace("/monitor-proxy/ldoh", ""));
}
let resp = req
.send()
.await
.map_err(|e| AppError::UpstreamError(format!("LDOH unreachable: {e}")))?;
let status = resp.status();
let content_type = resp
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("application/octet-stream")
.to_string();
let mut builder = axum::response::Response::builder().status(status);
if let Some(ctl) = resp
.headers()
.get("cache-control")
.and_then(|v| v.to_str().ok())
{
builder = builder.header("cache-control", ctl);
}
// Rewrite text content
if content_type.contains("text/html")
|| content_type.contains("javascript")
|| content_type.contains("text/css")
|| content_type.contains("application/json")
{
let text = resp
.text()
.await
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
return builder
.header(header::CONTENT_TYPE, &content_type)
.body(axum::body::Body::from(rewrite_proxy_text(&text)))
.map_err(|e| AppError::Internal(anyhow::anyhow!("{e}")));
}
let bytes = resp
.bytes()
.await
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
builder
.header(header::CONTENT_TYPE, &content_type)
.body(axum::body::Body::from(bytes))
.map_err(|e| AppError::Internal(anyhow::anyhow!("{e}")))
}
// ── Helpers ────────────────────────────────────────────────────────────
fn parse_cookies(raw: &str) -> std::collections::HashMap<String, String> {
let mut map = std::collections::HashMap::new();
for part in raw.split(';') {
let part = part.trim();
if let Some(idx) = part.find('=') {
let key = part[..idx].trim().to_string();
let val = part[idx + 1..].trim().to_string();
if !key.is_empty() {
map.insert(key, val);
}
}
}
map
}
fn mask_cookie_value(cookie_text: &str) -> String {
let raw = cookie_text.trim();
if raw.is_empty() {
return String::new();
}
let value = raw.split('=').nth(1).unwrap_or(raw);
if value.len() <= 10 {
format!("{}****", &value[..2.min(value.len())])
} else {
format!("{}****{}", &value[..6], &value[value.len() - 4..])
}
}
fn normalize_ldoh_cookie(raw: &str) -> String {
let trimmed = raw.trim().to_string();
if trimmed.contains("ld_auth_session=") {
if let Some(first) = trimmed.split(';').next() {
if first.trim().starts_with("ld_auth_session=") {
return first.trim().to_string();
}
}
}
format!("ld_auth_session={}", trimmed)
}
fn rewrite_proxy_text(text: &str) -> String {
text.replace("https://ldoh.105117.xyz/", "/monitor-proxy/ldoh/")
.replace(
"https:\\/\\/ldoh.105117.xyz\\/",
"\\/monitor-proxy\\/ldoh\\/",
)
.replace("src=\"/", "src=\"/monitor-proxy/ldoh/")
.replace("src='/", "src='/monitor-proxy/ldoh/")
.replace("href=\"/", "href=\"/monitor-proxy/ldoh/")
.replace("href='/", "href='/monitor-proxy/ldoh/")
.replace("action=\"/", "action=\"/monitor-proxy/ldoh/")
.replace("action='/", "action='/monitor-proxy/ldoh/")
.replace("\"/_next/", "\"/monitor-proxy/ldoh/_next/")
.replace("'/_next/", "'/monitor-proxy/ldoh/_next/")
.replace("\"\\\\/api/", "\"\\\\/monitor-proxy\\\\/ldoh\\\\/api/")
.replace("'/api/", "'/monitor-proxy/ldoh/api/")
.replace("\"/api/", "\"/monitor-proxy/ldoh/api/")
}
+4202 -113
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1545 -69
View File
File diff suppressed because it is too large Load Diff
+491 -121
View File
@@ -1,9 +1,14 @@
// Site Announcements routes — upstream provider notifications per site
//! User announcement routes.
//!
//! The public route names keep the old `site-announcements` path for frontend
//! compatibility, but the resource is now admin-authored announcements shown to
//! normal users.
use crate::{db::models::site_announcement::SiteAnnouncement, error::AppResult, AppState};
use crate::{auth::jwt::validate_jwt, error::AppResult, AppState};
use axum::{
extract::{Path, Query, State},
routing::{get, post},
http::HeaderMap,
routing::{get, post, put},
Json, Router,
};
use serde::Deserialize;
@@ -14,159 +19,524 @@ use tracing::info;
pub struct ListAnnouncementsQuery {
limit: Option<i64>,
offset: Option<i64>,
site_id: Option<i64>,
platform: Option<String>,
read: Option<String>, // "true" | "false"
status: Option<String>, // "active" | "expired" | "dismissed"
read: Option<String>,
status: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AnnouncementPayload {
title: String,
content: String,
level: Option<String>,
status: Option<String>,
#[serde(alias = "startsAt")]
starts_at: Option<String>,
#[serde(alias = "endsAt")]
ends_at: Option<String>,
}
pub fn routes() -> Router<AppState> {
Router::new()
.route(
"/api/site-announcements",
get(list_announcements).delete(delete_all),
get(list_admin_announcements)
.post(create_announcement)
.delete(delete_all),
)
.route("/api/site-announcements/{id}/read", post(mark_read))
.route("/api/site-announcements/read-all", post(mark_all_read))
.route(
"/api/site-announcements/{id}",
put(update_announcement).delete(delete_announcement),
)
.route("/api/site-announcements/{id}/read", post(mark_read_admin))
.route(
"/api/site-announcements/read-all",
post(mark_all_read_admin),
)
.route("/api/user/announcements", get(list_user_announcements))
.route("/api/user/announcements/{id}/read", post(mark_read_user))
.route("/api/user/announcements/read-all", post(mark_all_read_user))
}
/// GET /api/site-announcements — list announcements with optional filters
async fn list_announcements(
async fn list_admin_announcements(
State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<ListAnnouncementsQuery>,
) -> AppResult<Json<Value>> {
info!("Listing site announcements");
let limit = q.limit.unwrap_or(50).clamp(1, 500);
require_admin(&headers, &state)?;
info!("Listing user announcements for admin");
let limit = q.limit.unwrap_or(100).clamp(1, 500);
let offset = q.offset.unwrap_or(0).max(0);
let status = q
.status
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
let mut sql = "SELECT * FROM site_announcements WHERE 1=1".to_string();
let mut params: Vec<String> = vec![];
let mut bind_idx = 0;
if let Some(site_id) = q.site_id {
bind_idx += 1;
sql.push_str(&format!(" AND site_id = ?{}", bind_idx));
params.push(site_id.to_string());
}
if let Some(ref platform) = q.platform {
if !platform.is_empty() {
bind_idx += 1;
sql.push_str(&format!(" AND platform = ?{}", bind_idx));
params.push(platform.clone());
}
}
if let Some(ref r) = q.read {
if r == "true" {
sql.push_str(" AND read_at IS NOT NULL");
} else if r == "false" {
sql.push_str(" AND read_at IS NULL");
}
let mut sql = "SELECT
a.id, a.title, a.content, a.level, a.status, a.audience,
a.starts_at, a.ends_at, a.created_at, a.updated_at, a.published_at,
COUNT(r.user_id) AS read_count
FROM user_announcements a
LEFT JOIN user_announcement_reads r ON r.announcement_id = a.id
WHERE 1=1"
.to_string();
if status.is_some() {
sql.push_str(" AND a.status = ?");
}
sql.push_str(
" GROUP BY a.id
ORDER BY a.created_at DESC
LIMIT ? OFFSET ?",
);
// status filter: active = NOT dismissed AND (ends_at IS NULL OR ends_at > now)
// We fetch all and apply status filter in Rust for simplicity
// (the "expired" vs "active" logic references ends_at which is a datetime comparison)
sql.push_str(" ORDER BY first_seen_at DESC");
let mut query = sqlx::query_as::<_, SiteAnnouncement>(&sql);
for p in &params {
query = query.bind(p);
let mut query = sqlx::query_as::<_, AnnouncementRow>(&sql);
if let Some(status) = status {
query = query.bind(status);
}
let announcements: Vec<SiteAnnouncement> = query.fetch_all(&*state.db).await?;
let rows = query.bind(limit).bind(offset).fetch_all(&*state.db).await?;
// Apply status filter in-memory (matches Node.js behaviour)
let filtered = if let Some(ref status) = q.status {
let now = chrono::Utc::now().timestamp_millis();
let status = status.trim().to_lowercase();
announcements
.into_iter()
.filter(|a| {
if status == "dismissed" {
a.dismissed_at.is_some()
} else if status == "expired" {
a.dismissed_at.is_none()
&& a.ends_at.as_ref().is_some_and(|e| parse_dt_millis(e) < now)
} else if status == "active" {
a.dismissed_at.is_none()
&& a.ends_at.as_ref().is_none_or(|e| parse_dt_millis(e) >= now)
} else {
true
}
})
.collect()
} else {
announcements
};
// Apply pagination in-memory
let paginated: Vec<Value> = filtered
.into_iter()
.skip(offset as usize)
.take(limit as usize)
.map(|a| {
json!({
"id": a.id,
"siteId": a.site_id,
"platform": a.platform,
"sourceKey": a.source_key,
"title": a.title,
"content": a.content,
"level": a.level,
"sourceUrl": a.source_url,
"startsAt": a.starts_at,
"endsAt": a.ends_at,
"upstreamCreatedAt": a.upstream_created_at,
"upstreamUpdatedAt": a.upstream_updated_at,
"firstSeenAt": a.first_seen_at,
"lastSeenAt": a.last_seen_at,
"readAt": a.read_at,
"dismissedAt": a.dismissed_at,
"rawPayload": a.raw_payload,
})
})
.collect();
Ok(Json(Value::Array(paginated)))
Ok(Json(Value::Array(
rows.into_iter().map(|row| row.into_admin_json()).collect(),
)))
}
/// POST /api/site-announcements/{id}/read
async fn mark_read(State(state): State<AppState>, Path(id): Path<i64>) -> AppResult<Json<Value>> {
info!("Marking site announcement {} as read", id);
sqlx::query("UPDATE site_announcements SET read_at = datetime('now') WHERE id = ?")
async fn list_user_announcements(
State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<ListAnnouncementsQuery>,
) -> AppResult<Json<Value>> {
let user_id = current_user_id(&headers, &state)?;
let limit = q.limit.unwrap_or(50).clamp(1, 200);
let offset = q.offset.unwrap_or(0).max(0);
let read_filter = q.read.as_deref().map(str::trim);
let mut sql = "SELECT
a.id, a.title, a.content, a.level, a.status, a.audience,
a.starts_at, a.ends_at, a.created_at, a.updated_at, a.published_at,
CASE WHEN r.read_at IS NULL THEN 0 ELSE 1 END AS read,
r.read_at AS read_at
FROM user_announcements a
LEFT JOIN user_announcement_reads r
ON r.announcement_id = a.id AND r.user_id = ?
WHERE a.status = 'published'
AND (a.starts_at IS NULL OR a.starts_at <= datetime('now'))
AND (a.ends_at IS NULL OR a.ends_at >= datetime('now'))"
.to_string();
if read_filter == Some("true") {
sql.push_str(" AND r.read_at IS NOT NULL");
} else if read_filter == Some("false") {
sql.push_str(" AND r.read_at IS NULL");
}
sql.push_str(
" ORDER BY COALESCE(a.published_at, a.created_at) DESC, a.id DESC LIMIT ? OFFSET ?",
);
let rows: Vec<UserAnnouncementRow> = sqlx::query_as(&sql)
.bind(user_id)
.bind(limit)
.bind(offset)
.fetch_all(&*state.db)
.await?;
Ok(Json(Value::Array(
rows.into_iter().map(|row| row.into_user_json()).collect(),
)))
}
async fn create_announcement(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<AnnouncementPayload>,
) -> AppResult<Json<Value>> {
require_admin(&headers, &state)?;
let payload = normalize_payload(payload)?;
let result = sqlx::query(
"INSERT INTO user_announcements
(title, content, level, status, audience, starts_at, ends_at, published_at)
VALUES (?, ?, ?, ?, 'all', ?, ?, CASE WHEN ? = 'published' THEN datetime('now') ELSE NULL END)",
)
.bind(&payload.title)
.bind(&payload.content)
.bind(&payload.level)
.bind(&payload.status)
.bind(&payload.starts_at)
.bind(&payload.ends_at)
.bind(&payload.status)
.execute(&*state.db)
.await?;
let row = fetch_admin_row(&state, result.last_insert_rowid()).await?;
Ok(Json(row.into_admin_json()))
}
async fn update_announcement(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<i64>,
Json(payload): Json<AnnouncementPayload>,
) -> AppResult<Json<Value>> {
require_admin(&headers, &state)?;
let payload = normalize_payload(payload)?;
let result = sqlx::query(
"UPDATE user_announcements
SET title = ?,
content = ?,
level = ?,
status = ?,
starts_at = ?,
ends_at = ?,
published_at = CASE
WHEN status != 'published' AND ? = 'published' THEN datetime('now')
ELSE published_at
END,
updated_at = datetime('now')
WHERE id = ?",
)
.bind(&payload.title)
.bind(&payload.content)
.bind(&payload.level)
.bind(&payload.status)
.bind(&payload.starts_at)
.bind(&payload.ends_at)
.bind(&payload.status)
.bind(id)
.execute(&*state.db)
.await?;
if result.rows_affected() == 0 {
return Err(crate::error::AppError::NotFound(
"announcement not found".into(),
));
}
let row = fetch_admin_row(&state, id).await?;
Ok(Json(row.into_admin_json()))
}
async fn delete_announcement(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<i64>,
) -> AppResult<Json<Value>> {
require_admin(&headers, &state)?;
let result = sqlx::query("DELETE FROM user_announcements WHERE id = ?")
.bind(id)
.execute(&*state.db)
.await?;
if result.rows_affected() == 0 {
return Err(crate::error::AppError::NotFound(
"announcement not found".into(),
));
}
Ok(Json(json!({ "success": true })))
}
async fn mark_read_admin(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<i64>,
) -> AppResult<Json<Value>> {
require_admin(&headers, &state)?;
sqlx::query(
"INSERT INTO user_announcement_reads (announcement_id, user_id, read_at)
SELECT ?, id, datetime('now') FROM users WHERE role = 'user'
ON CONFLICT(announcement_id, user_id) DO UPDATE SET read_at = excluded.read_at",
)
.bind(id)
.execute(&*state.db)
.await?;
Ok(Json(json!({ "success": true })))
}
/// POST /api/site-announcements/read-all
async fn mark_all_read(State(state): State<AppState>) -> AppResult<Json<Value>> {
info!("Marking all site announcements as read");
sqlx::query("UPDATE site_announcements SET read_at = datetime('now') WHERE read_at IS NULL")
async fn mark_all_read_admin(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&headers, &state)?;
sqlx::query(
"INSERT INTO user_announcement_reads (announcement_id, user_id, read_at)
SELECT a.id, u.id, datetime('now')
FROM user_announcements a
CROSS JOIN users u
WHERE u.role = 'user'
ON CONFLICT(announcement_id, user_id) DO UPDATE SET read_at = excluded.read_at",
)
.execute(&*state.db)
.await?;
Ok(Json(json!({ "success": true })))
}
/// DELETE /api/site-announcements
async fn delete_all(State(state): State<AppState>) -> AppResult<Json<Value>> {
info!("Deleting all site announcements");
sqlx::query("DELETE FROM site_announcements")
async fn mark_read_user(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<i64>,
) -> AppResult<Json<Value>> {
let user_id = current_user_id(&headers, &state)?;
ensure_visible_to_user(&state, id).await?;
sqlx::query(
"INSERT INTO user_announcement_reads (announcement_id, user_id, read_at)
VALUES (?, ?, datetime('now'))
ON CONFLICT(announcement_id, user_id) DO UPDATE SET read_at = excluded.read_at",
)
.bind(id)
.bind(user_id)
.execute(&*state.db)
.await?;
Ok(Json(json!({ "success": true })))
}
/// Parse a datetime string like "2026-05-15 10:30:00" or ISO to milliseconds since epoch.
fn parse_dt_millis(s: &str) -> i64 {
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s.trim(), "%Y-%m-%d %H:%M:%S") {
dt.and_utc().timestamp_millis()
} else if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s.trim()) {
dt.timestamp_millis()
} else if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s.trim(), "%Y-%m-%dT%H:%M:%S") {
dt.and_utc().timestamp_millis()
} else {
0
async fn mark_all_read_user(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
let user_id = current_user_id(&headers, &state)?;
sqlx::query(
"INSERT INTO user_announcement_reads (announcement_id, user_id, read_at)
SELECT id, ?, datetime('now')
FROM user_announcements
WHERE status = 'published'
AND (starts_at IS NULL OR starts_at <= datetime('now'))
AND (ends_at IS NULL OR ends_at >= datetime('now'))
ON CONFLICT(announcement_id, user_id) DO UPDATE SET read_at = excluded.read_at",
)
.bind(user_id)
.execute(&*state.db)
.await?;
Ok(Json(json!({ "success": true })))
}
async fn delete_all(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
require_admin(&headers, &state)?;
info!("Deleting all user announcements");
sqlx::query("DELETE FROM user_announcements")
.execute(&*state.db)
.await?;
Ok(Json(json!({ "success": true })))
}
async fn fetch_admin_row(state: &AppState, id: i64) -> AppResult<AnnouncementRow> {
let row = sqlx::query_as::<_, AnnouncementRow>(
"SELECT
a.id, a.title, a.content, a.level, a.status, a.audience,
a.starts_at, a.ends_at, a.created_at, a.updated_at, a.published_at,
COUNT(r.user_id) AS read_count
FROM user_announcements a
LEFT JOIN user_announcement_reads r ON r.announcement_id = a.id
WHERE a.id = ?
GROUP BY a.id",
)
.bind(id)
.fetch_one(&*state.db)
.await?;
Ok(row)
}
async fn ensure_visible_to_user(state: &AppState, id: i64) -> AppResult<()> {
let exists: Option<i64> = sqlx::query_scalar(
"SELECT id
FROM user_announcements
WHERE id = ?
AND status = 'published'
AND (starts_at IS NULL OR starts_at <= datetime('now'))
AND (ends_at IS NULL OR ends_at >= datetime('now'))",
)
.bind(id)
.fetch_optional(&*state.db)
.await?;
if exists.is_none() {
return Err(crate::error::AppError::NotFound(
"announcement not found".into(),
));
}
Ok(())
}
fn current_user_id(headers: &HeaderMap, state: &AppState) -> AppResult<i64> {
let auth = headers
.get("Authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or_else(|| {
crate::error::AppError::Unauthorized("Missing or invalid Authorization header".into())
})?;
let claims = validate_jwt(&state.config.auth.jwt_secret, auth)
.map_err(|_| crate::error::AppError::Unauthorized("Invalid or expired token".into()))?;
if claims.role != "user" && claims.role != "admin" {
return Err(crate::error::AppError::Forbidden(
"user account is required".into(),
));
}
claims
.sub
.parse()
.map_err(|_| crate::error::AppError::Unauthorized("Invalid user ID in token".into()))
}
fn require_admin(headers: &HeaderMap, state: &AppState) -> AppResult<()> {
let auth = headers
.get("Authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or_else(|| {
crate::error::AppError::Unauthorized("Missing or invalid Authorization header".into())
})?;
if auth == state.config.auth.auth_token {
return Ok(());
}
let claims = validate_jwt(&state.config.auth.jwt_secret, auth)
.map_err(|_| crate::error::AppError::Unauthorized("Invalid admin token".into()))?;
if claims.role != "admin" {
return Err(crate::error::AppError::Forbidden(
"Admin account is required for this route".into(),
));
}
Ok(())
}
fn normalize_payload(payload: AnnouncementPayload) -> AppResult<NormalizedAnnouncementPayload> {
let title = payload.title.trim().to_string();
let content = payload.content.trim().to_string();
if title.is_empty() {
return Err(crate::error::AppError::BadRequest(
"title is required".into(),
));
}
if content.is_empty() {
return Err(crate::error::AppError::BadRequest(
"content is required".into(),
));
}
let level = payload
.level
.unwrap_or_else(|| "info".to_string())
.trim()
.to_lowercase();
if !["info", "warning", "error"].contains(&level.as_str()) {
return Err(crate::error::AppError::BadRequest(
"level must be info, warning, or error".into(),
));
}
let status = payload
.status
.unwrap_or_else(|| "published".to_string())
.trim()
.to_lowercase();
if !["draft", "published", "archived"].contains(&status.as_str()) {
return Err(crate::error::AppError::BadRequest(
"status must be draft, published, or archived".into(),
));
}
Ok(NormalizedAnnouncementPayload {
title,
content,
level,
status,
starts_at: payload.starts_at.filter(|s| !s.trim().is_empty()),
ends_at: payload.ends_at.filter(|s| !s.trim().is_empty()),
})
}
struct NormalizedAnnouncementPayload {
title: String,
content: String,
level: String,
status: String,
starts_at: Option<String>,
ends_at: Option<String>,
}
#[derive(sqlx::FromRow)]
struct AnnouncementRow {
id: i64,
title: String,
content: String,
level: String,
status: String,
audience: String,
starts_at: Option<String>,
ends_at: Option<String>,
created_at: String,
updated_at: String,
published_at: Option<String>,
read_count: i64,
}
impl AnnouncementRow {
fn into_admin_json(self) -> Value {
json!({
"id": self.id,
"siteId": 0,
"platform": "boosapi",
"sourceKey": format!("user-announcement:{}", self.id),
"title": self.title,
"content": self.content,
"level": self.level,
"status": self.status,
"audience": self.audience,
"startsAt": self.starts_at,
"endsAt": self.ends_at,
"firstSeenAt": self.created_at,
"lastSeenAt": self.updated_at,
"publishedAt": self.published_at,
"readCount": self.read_count,
"readAt": null,
"dismissedAt": if self.status == "archived" { Some(self.updated_at) } else { None },
})
}
}
#[derive(sqlx::FromRow)]
struct UserAnnouncementRow {
id: i64,
title: String,
content: String,
level: String,
status: String,
audience: String,
starts_at: Option<String>,
ends_at: Option<String>,
created_at: String,
updated_at: String,
published_at: Option<String>,
read: i64,
read_at: Option<String>,
}
impl UserAnnouncementRow {
fn into_user_json(self) -> Value {
json!({
"id": self.id,
"siteId": 0,
"platform": "boosapi",
"sourceKey": format!("user-announcement:{}", self.id),
"title": self.title,
"content": self.content,
"level": self.level,
"status": self.status,
"audience": self.audience,
"startsAt": self.starts_at,
"endsAt": self.ends_at,
"firstSeenAt": self.published_at.clone().unwrap_or(self.created_at),
"lastSeenAt": self.updated_at,
"publishedAt": self.published_at,
"read": self.read != 0,
"readAt": self.read_at,
})
}
}
+1101 -51
View File
File diff suppressed because it is too large Load Diff
-121
View File
@@ -1,18 +1,11 @@
// Background tasks routes — list and inspect async operations
use std::convert::Infallible;
use crate::{db::models::background_task::BackgroundTask, error::AppResult, AppState};
use axum::{
extract::{Path, Query, State},
response::{
sse::{Event, Sse},
IntoResponse,
},
routing::get,
Json, Router,
};
use futures::stream::Stream;
use serde::Deserialize;
use serde_json::{json, Value};
use tracing::info;
@@ -26,10 +19,6 @@ pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/tasks", get(list_tasks))
.route("/api/tasks/{id}", get(get_task))
.route(
"/api/update-center/tasks/{id}/stream",
get(stream_task_logs),
)
}
/// GET /api/tasks — list background tasks
@@ -67,113 +56,3 @@ async fn get_task(State(state): State<AppState>, Path(id): Path<String>) -> AppR
)),
}
}
/// GET /api/update-center/tasks/{id}/stream — SSE stream of task progress
async fn stream_task_logs(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, axum::response::Response> {
info!("Streaming task logs for: {}", id);
// Check task exists
let task: Option<BackgroundTask> =
sqlx::query_as::<_, BackgroundTask>("SELECT * FROM background_tasks WHERE id = ?")
.bind(&id)
.fetch_optional(&*state.db)
.await
.map_err(|e| {
(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
format!("DB error: {e}"),
)
.into_response()
})?;
let task = match task {
Some(t) => t,
None => {
return Err((
axum::http::StatusCode::NOT_FOUND,
json!({ "success": false, "message": "task not found" }).to_string(),
)
.into_response());
}
};
// Stream task state with polling
let initial_status = task.status.clone();
let is_done = initial_status != "pending" && initial_status != "running";
let state_clone = state.clone();
let stream = async_stream::stream! {
// Yield current task status
yield Ok::<_, Infallible>(Event::default()
.event("status")
.data(json!({ "status": initial_status }).to_string()));
yield Ok::<_, Infallible>(Event::default()
.event("log")
.data(json!({
"seq": 1,
"message": format!("Task {}: {}", initial_status, task.message.as_deref().unwrap_or("")),
"createdAt": task.created_at.as_deref().unwrap_or(""),
}).to_string()));
if is_done {
yield Ok::<_, Infallible>(Event::default()
.event("done")
.data(json!({ "status": initial_status }).to_string()));
return;
}
// Poll for updates
let poll_id = id.clone();
for i in 0..120 { // Poll up to 120 times (2 minutes)
tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
let current: Option<BackgroundTask> = sqlx::query_as(
"SELECT * FROM background_tasks WHERE id = ?"
)
.bind(&poll_id)
.fetch_optional(&*state_clone.db)
.await
.ok()
.flatten();
match current {
Some(t) => {
let status = t.status;
let updated_at = t.updated_at.unwrap_or_default();
yield Ok::<_, Infallible>(Event::default()
.event("log")
.data(json!({
"seq": i + 2,
"message": format!("Status: {} — {}", status, t.message.as_deref().unwrap_or("")),
"createdAt": updated_at,
}).to_string()));
if status != "pending" && status != "running" {
yield Ok::<_, Infallible>(Event::default()
.event("done")
.data(json!({ "status": status }).to_string()));
return;
}
}
None => {
yield Ok::<_, Infallible>(Event::default()
.event("done")
.data(json!({ "status": "unknown", "message": "task removed" }).to_string()));
return;
}
}
}
// Timeout
yield Ok::<_, Infallible>(Event::default()
.event("done")
.data(json!({ "status": "timeout" }).to_string()));
};
Ok(Sse::new(stream))
}
-435
View File
@@ -1,435 +0,0 @@
// Update Center routes — deploy/rollback orchestration via helper API
#![allow(clippy::type_complexity)]
use crate::{
error::{AppError, AppResult},
AppState,
};
use axum::{
extract::State,
routing::{get, post, put},
Json, Router,
};
use serde_json::{json, Value};
const UC_CONFIG_KEY: &str = "update_center_config";
const TASK_TYPE_DEPLOY: &str = "update-center-deploy";
const TASK_TYPE_ROLLBACK: &str = "update-center-rollback";
const DEDUPE_KEY_DEPLOY: &str = "update-center:deploy";
const DEDUPE_KEY_ROLLBACK: &str = "update-center:rollback";
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/update-center/status", get(get_status))
.route("/api/update-center/check", post(check_status))
.route("/api/update-center/config", put(update_config))
.route("/api/update-center/deploy", post(deploy_handler))
.route("/api/update-center/rollback", post(rollback_handler))
}
/// GET /api/update-center/status
async fn get_status(State(state): State<AppState>) -> AppResult<Json<Value>> {
let config = load_uc_config(&state).await?;
let helper_token = get_helper_token();
let last_deploy = load_last_task(&state, TASK_TYPE_DEPLOY).await?;
let last_rollback = load_last_task(&state, TASK_TYPE_ROLLBACK).await?;
Ok(Json(json!({
"enabled": config.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false),
"helperBaseUrl": config.get("helperBaseUrl"),
"namespace": config.get("namespace"),
"releaseName": config.get("releaseName"),
"helperTokenConfigured": !helper_token.is_empty(),
"lastDeploy": last_deploy,
"lastRollback": last_rollback,
})))
}
/// POST /api/update-center/check — refresh status cache
async fn check_status(State(state): State<AppState>) -> AppResult<Json<Value>> {
// Same as get_status for now
get_status(State(state)).await
}
/// PUT /api/update-center/config
async fn update_config(
State(state): State<AppState>,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
let serialized = serde_json::to_string(&body)
.map_err(|e| AppError::BadRequest(format!("Invalid config JSON: {e}")))?;
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
.bind(UC_CONFIG_KEY)
.bind(&serialized)
.execute(&*state.db)
.await?;
Ok(Json(json!({
"success": true,
"config": body,
})))
}
/// POST /api/update-center/deploy
async fn deploy_handler(
State(state): State<AppState>,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
let config = load_uc_config(&state).await?;
let helper_token = get_helper_token();
let helper_base = config
.get("helperBaseUrl")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
let namespace = config
.get("namespace")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
let chart_ref = config
.get("chartRef")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
let image_repo = config
.get("imageRepository")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
if helper_base.is_empty()
|| namespace.is_empty()
|| chart_ref.is_empty()
|| helper_token.is_empty()
{
return Err(AppError::BadRequest(
"Update center not fully configured".into(),
));
}
let target_tag = body
.get("targetTag")
.or_else(|| body.get("targetVersion"))
.and_then(|v| v.as_str())
.map(str::trim)
.unwrap_or("");
if target_tag.is_empty() {
return Err(AppError::BadRequest("targetTag is required".into()));
}
// Extract owned values for the spawn
let helper_base_owned = helper_base.trim_end_matches('/').to_string();
let namespace_owned = namespace.to_string();
let chart_ref_owned = chart_ref.to_string();
let image_repo_owned = image_repo.to_string();
let release_name = config
.get("releaseName")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned);
let target_tag_owned = target_tag.to_string();
let helper_token_owned = helper_token;
let title = format!("部署 {}", target_tag);
let task_id = uuid::Uuid::new_v4().to_string();
// Create background task
sqlx::query(
"INSERT INTO background_tasks (id, task_type, title, status, message, dedupe_key)
VALUES (?, ?, ?, 'running', ?, ?)",
)
.bind(&task_id)
.bind(TASK_TYPE_DEPLOY)
.bind(&title)
.bind(format!(
"Starting deploy of {} to {}/{}",
target_tag, namespace, chart_ref
))
.bind(DEDUPE_KEY_DEPLOY)
.execute(&*state.db)
.await?;
// Call deploy helper in background (fire-and-forget via tokio)
let client = reqwest::Client::new();
let deploy_url = format!("{}/api/deploy", helper_base_owned);
let task_id_clone = task_id.clone();
let db_pool = state.db.clone();
tokio::spawn(async move {
let payload = json!({
"namespace": namespace_owned,
"releaseName": release_name,
"chartRef": chart_ref_owned,
"imageRepository": image_repo_owned,
"targetTag": target_tag_owned,
});
match client
.post(&deploy_url)
.header("Authorization", format!("Bearer {}", helper_token_owned))
.json(&payload)
.send()
.await
{
Ok(resp) => {
let status = resp.status();
let status_code = status.as_u16();
let body_text = resp.text().await.unwrap_or_default();
if status.is_success() {
let _ = sqlx::query(
"UPDATE background_tasks
SET status = 'succeeded', message = ?, finished_at = datetime('now')
WHERE id = ?",
)
.bind(format!("Deploy completed (HTTP {})", status_code))
.bind(&task_id_clone)
.execute(&*db_pool)
.await;
} else {
let _ = sqlx::query(
"UPDATE background_tasks
SET status = 'failed', error = ?, finished_at = datetime('now')
WHERE id = ?",
)
.bind(format!(
"Helper returned HTTP {}: {}",
status_code, body_text
))
.bind(&task_id_clone)
.execute(&*db_pool)
.await;
}
}
Err(e) => {
let _ = sqlx::query(
"UPDATE background_tasks
SET status = 'failed', error = ?, finished_at = datetime('now')
WHERE id = ?",
)
.bind(format!("Helper unreachable: {}", e))
.bind(&task_id_clone)
.execute(&*db_pool)
.await;
}
}
});
Ok(Json(json!({
"success": true,
"reused": false,
"task": {
"id": task_id,
"type": TASK_TYPE_DEPLOY,
"title": title,
"status": "running",
},
})))
}
/// POST /api/update-center/rollback
async fn rollback_handler(
State(state): State<AppState>,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
let config = load_uc_config(&state).await?;
let helper_token = get_helper_token();
let helper_base = config
.get("helperBaseUrl")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
let namespace = config
.get("namespace")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
if helper_base.is_empty() || namespace.is_empty() || helper_token.is_empty() {
return Err(AppError::BadRequest(
"Update center not fully configured".into(),
));
}
let target_revision = body
.get("targetRevision")
.and_then(|v| v.as_str())
.map(str::trim)
.unwrap_or("");
if target_revision.is_empty() {
return Err(AppError::BadRequest("targetRevision is required".into()));
}
let helper_base_owned = helper_base.trim_end_matches('/').to_string();
let namespace_owned = namespace.to_string();
let release_name = config
.get("releaseName")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned);
let target_revision_owned = target_revision.to_string();
let helper_token_owned = helper_token;
let title = format!("回退 {}", target_revision);
let task_id = uuid::Uuid::new_v4().to_string();
sqlx::query(
"INSERT INTO background_tasks (id, task_type, title, status, message, dedupe_key)
VALUES (?, ?, ?, 'running', ?, ?)",
)
.bind(&task_id)
.bind(TASK_TYPE_ROLLBACK)
.bind(&title)
.bind(format!(
"Starting rollback to {} in {}",
target_revision, namespace
))
.bind(DEDUPE_KEY_ROLLBACK)
.execute(&*state.db)
.await?;
let client = reqwest::Client::new();
let rollback_url = format!("{}/api/rollback", helper_base_owned);
let task_id_clone = task_id.clone();
let db_pool = state.db.clone();
tokio::spawn(async move {
let payload = json!({
"namespace": namespace_owned,
"releaseName": release_name,
"targetRevision": target_revision_owned,
});
match client
.post(&rollback_url)
.header("Authorization", format!("Bearer {}", helper_token_owned))
.json(&payload)
.send()
.await
{
Ok(resp) => {
let status = resp.status();
let status_code = status.as_u16();
let body_text = resp.text().await.unwrap_or_default();
if status.is_success() {
let _ = sqlx::query(
"UPDATE background_tasks
SET status = 'succeeded', message = ?, finished_at = datetime('now')
WHERE id = ?",
)
.bind(format!("Rollback completed (HTTP {})", status_code))
.bind(&task_id_clone)
.execute(&*db_pool)
.await;
} else {
let _ = sqlx::query(
"UPDATE background_tasks
SET status = 'failed', error = ?, finished_at = datetime('now')
WHERE id = ?",
)
.bind(format!(
"Helper returned HTTP {}: {}",
status_code, body_text
))
.bind(&task_id_clone)
.execute(&*db_pool)
.await;
}
}
Err(e) => {
let _ = sqlx::query(
"UPDATE background_tasks
SET status = 'failed', error = ?, finished_at = datetime('now')
WHERE id = ?",
)
.bind(format!("Helper unreachable: {}", e))
.bind(&task_id_clone)
.execute(&*db_pool)
.await;
}
}
});
Ok(Json(json!({
"success": true,
"reused": false,
"task": {
"id": task_id,
"type": TASK_TYPE_ROLLBACK,
"title": title,
"status": "running",
},
})))
}
// ── Helpers ────────────────────────────────────────────────────────────
fn get_helper_token() -> String {
std::env::var("UPDATE_CENTER_HELPER_TOKEN")
.or_else(|_| std::env::var("DEPLOY_HELPER_TOKEN"))
.unwrap_or_default()
.trim()
.to_string()
}
async fn load_uc_config(state: &AppState) -> AppResult<Value> {
let row: Option<(String,)> = sqlx::query_as("SELECT value FROM settings WHERE key = ?")
.bind(UC_CONFIG_KEY)
.fetch_optional(&*state.db)
.await?;
match row {
Some((v,)) => serde_json::from_str(&v)
.map_err(|e| AppError::BadRequest(format!("Invalid update center config JSON: {e}"))),
None => Ok(json!({})),
}
}
async fn load_last_task(state: &AppState, task_type: &str) -> AppResult<Option<Value>> {
let row: Option<(
String,
String,
Option<String>,
Option<String>,
String,
Option<String>,
Option<String>,
)> = sqlx::query_as(
"SELECT id, status, message, error, created_at, started_at, finished_at
FROM background_tasks
WHERE task_type = ?
ORDER BY created_at DESC
LIMIT 1",
)
.bind(task_type)
.fetch_optional(&*state.db)
.await?;
Ok(row.map(
|(id, status, message, error, created_at, started_at, finished_at)| {
json!({
"id": id,
"type": task_type,
"status": status,
"message": message,
"error": error,
"createdAt": created_at,
"startedAt": started_at,
"finishedAt": finished_at,
})
},
))
}
+2845 -4
View File
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More