Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 888563a22b |
@@ -1,24 +0,0 @@
|
||||
# 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
|
||||
@@ -1,20 +0,0 @@
|
||||
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
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
/target
|
||||
**/*.rs.bk
|
||||
.env
|
||||
.env.*
|
||||
data/
|
||||
node_modules/
|
||||
website/.next/
|
||||
website/out/
|
||||
dist/
|
||||
release/
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
logs/
|
||||
tmp/
|
||||
!tmp/.gitkeep
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
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
|
||||
@@ -1,4 +0,0 @@
|
||||
[submodule "vendor/ComfyUI_frontend"]
|
||||
path = vendor/ComfyUI_frontend
|
||||
url = https://github.com/Comfy-Org/ComfyUI_frontend.git
|
||||
branch = main
|
||||
@@ -1,72 +0,0 @@
|
||||
# BoosAPI Engineering Rules
|
||||
|
||||
> **AI开发**: 使用 **gpt-5.5**(`api.boos.lat/v1`,key: `sk-boos4721`)进行开发工作。
|
||||
> 本项目为纯 Rust 项目,无 Node.js 运行时依赖。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
boosapi-rs/ ← 根目录(已迁移至此)
|
||||
├── src/
|
||||
│ ├── main.rs ← 入口
|
||||
│ ├── lib.rs
|
||||
│ ├── config.rs
|
||||
│ ├── error.rs
|
||||
│ ├── routes/
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── comfyui_agent.rs ← ComfyUI Agent SSE 聊天
|
||||
│ │ ├── comfyui/ ← ComfyUI 代理路由
|
||||
│ │ ├── proxy/ ← OpenAI 兼容代理
|
||||
│ │ ├── auth/ ← 认证
|
||||
│ │ └── ...
|
||||
│ ├── proxy/ ← 代理核心(Conductor/Token Router)
|
||||
│ ├── services/ ← 后台服务
|
||||
│ ├── db/ ← 数据库
|
||||
│ └── transformers/ ← 协议转换
|
||||
├── dist/web/ ← 前端构建产物(由 Rust 静态 serve)
|
||||
├── migrations/ ← SQLite 迁移
|
||||
├── config.toml ← 运行配置
|
||||
├── Dockerfile
|
||||
└── Cargo.toml
|
||||
|
||||
## ComfyUI Agent
|
||||
|
||||
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
|
||||
- 【WORKFLOW】 → 输出 ComfyUI 工作流 JSON
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
# 开发
|
||||
cargo run --release
|
||||
|
||||
# 环境变量
|
||||
BOOSAPI_SERVER__HOST=0.0.0.0
|
||||
BOOSAPI_SERVER__PORT=4000
|
||||
BOOSAPI_UPSTREAM_BASE=https://api.boos.lat/v1
|
||||
BOOSAPI_UPSTREAM_KEY=sk-boos4721
|
||||
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
File diff suppressed because it is too large
Load Diff
-112
@@ -1,112 +0,0 @@
|
||||
[package]
|
||||
name = "boosapi"
|
||||
version = "1.3.0"
|
||||
edition = "2021"
|
||||
description = "AI API Gateway — Rust rewrite of BoosAPI"
|
||||
|
||||
[dependencies]
|
||||
# Web framework
|
||||
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", "multipart"] }
|
||||
lettre = { version = "0.11", default-features = false, features = ["builder", "hostname", "pool", "rustls-tls", "smtp-transport"] }
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
regex = "1"
|
||||
|
||||
# Auth
|
||||
jsonwebtoken = "9"
|
||||
argon2 = "0.5"
|
||||
|
||||
# Config
|
||||
figment = { version = "0.10", features = ["env", "toml"] }
|
||||
dotenvy = "0.15"
|
||||
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
|
||||
# Error handling
|
||||
thiserror = "2"
|
||||
anyhow = "1"
|
||||
|
||||
# CLI
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
|
||||
# Time
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
# UUID
|
||||
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"
|
||||
|
||||
# Misc
|
||||
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"
|
||||
wiremock = "0.6"
|
||||
tracing-test = "0.2"
|
||||
tempfile = "3"
|
||||
axum-test = "16"
|
||||
|
||||
[profile.release]
|
||||
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
|
||||
@@ -1,263 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
FROM rust:1.83-alpine AS builder
|
||||
RUN apk add --no-cache musl-dev pkgconfig openssl-dev
|
||||
WORKDIR /app
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
RUN mkdir src && echo "fn main() {}" > src/main.rs
|
||||
RUN cargo build --release 2>/dev/null || true
|
||||
COPY . .
|
||||
RUN cargo build --release
|
||||
|
||||
FROM alpine:3.20
|
||||
RUN apk add --no-cache ca-certificates tzdata sqlite-libs
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/target/release/boosapi .
|
||||
COPY --from=builder /app/migrations ./migrations
|
||||
EXPOSE 4000
|
||||
CMD ["./boosapi"]
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Metapi contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,576 +1,3 @@
|
||||
<div align="center">
|
||||
# metapi
|
||||
|
||||
# BoosAPI
|
||||
|
||||
**AI 聚合网关 + Workflow Studio — 将分散的中转站整合为一个统一入口**
|
||||
|
||||
<p>
|
||||
把你各处注册的 New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API 等站点,
|
||||
<br>
|
||||
汇聚成 <strong>一个 API Key、一个入口</strong>,自动发现模型、智能路由、成本最优。
|
||||
<br>
|
||||
<strong>Workflow Studio / 视频助手</strong> — 用内嵌 ComfyUI 前端、多 API 后端和自然语言助手组合图像、视频、语音与文本能力。
|
||||
<br>
|
||||
<strong>SaaS 多租户架构</strong>,组织管理、成员邀请、套餐限制,开箱即用。
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Rust" src="https://img.shields.io/badge/Rust-1.85%2B-000000?logo=rust&style=flat">
|
||||
<img alt="SQLx" src="https://img.shields.io/badge/SQLx-0.8-3E6E93?style=flat">
|
||||
<img alt="License" src="https://img.shields.io/badge/license-MIT-brightgreen?style=flat">
|
||||
<img alt="React" src="https://img.shields.io/badge/React-19-61DAFB?logo=react&style=flat">
|
||||
<img alt="Axum" src="https://img.shields.io/badge/Axum-0.8-3182F6?style=flat">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md"><strong>中文</strong></a> |
|
||||
<a href="README_EN.md">English</a> |
|
||||
<a href="DEPLOY.md">部署指南</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 📖 介绍
|
||||
|
||||
现在 AI 生态里出现了越来越多基于 New API / One API 系列的聚合中转站,要管理多个站点的余额、模型列表和 API 密钥,往往既分散又费时。
|
||||
|
||||
**BoosAPI** 作为中转站之上的**元聚合层**,把多个站点统一到 **一个入口**——下游所有工具即可无感接入全部模型。
|
||||
|
||||
支持的上游范围:
|
||||
|
||||
- **聚合面板**:New API、One API、OneHub、DoneHub、Veloera、AnyRouter、Sub2API
|
||||
- **通用兼容接口**:OpenAI / Claude / Gemini compatible,cliproxyapi / CPA
|
||||
- **官方预设**:阿里云、智谱、豆包 Coding Plan、DeepSeek、Moonshot、MiniMax、ModelScope
|
||||
- **OAuth 连接**:Codex、Claude、Gemini CLI、Antigravity
|
||||
|
||||
| 痛点 | BoosAPI 怎么解决 |
|
||||
|------|-----------------|
|
||||
| 🔑 每个站点一个 Key,下游配置一堆 | **统一代理入口 + 多下游 Key 策略**,模型自动聚合 |
|
||||
| 💸 不知道哪个站点最便宜 | **智能路由** 按成本、余额、使用率选最优通道 |
|
||||
| 🔄 站点挂了手动切换 | **自动故障转移**,失败通道自动冷却并切换 |
|
||||
| 📊 余额分散各处 | **集中看板** 一目了然,余额不足自动告警 |
|
||||
| ✅ 每天签到领额度 | **自动签到** 定时执行,奖励自动追踪 |
|
||||
| 🤷 不知道各站有什么模型 | **自动模型发现**,上游新增模型零配置 |
|
||||
| 🎨 想编排图像/视频流程但不想跑本地模型 | **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 解析后再转成本地显示。
|
||||
|
||||
## ✨ 核心功能
|
||||
|
||||
### 🎨 Workflow Studio 与视频助手(Rust)
|
||||
|
||||
- **自然语言描述脚本**:说想法 → AI 自动提取角色、场景、对白
|
||||
- **自动生成四视图**:每个角色正/背/左/右 4 角度,调用 gpt-image-2 生成图片
|
||||
- **场景生成**:自动为每个场景生成配图
|
||||
- **TTS 语音合成**:对话台词自动转语音
|
||||
- **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 运行时依赖
|
||||
- 侧边栏「视频助手」入口,与聚合网关同一界面
|
||||
- **默认走本地模式**:复用本服务自身的 `/v1/*` 路由 + 启动时引导出来的内置 downstream key(`comfyui-agent`),开箱即用,无需额外配置
|
||||
- **回退远端**:设置 `BOOSAPI_UPSTREAM_BASE`(可选 `BOOSAPI_UPSTREAM_KEY`)即切换为外部上游;`/api/comfyui-agent/status` 的 `mode` 字段会反映当前模式
|
||||
|
||||
### 🌐 统一代理网关
|
||||
|
||||
- 兼容 **OpenAI** 与 **Claude** 下游格式
|
||||
- 支持 Responses / Chat Completions / Messages / Completions / Embeddings / Images / Models / Files
|
||||
- SSE 流式传输,自动格式转换
|
||||
|
||||
### 🧠 智能路由引擎
|
||||
|
||||
- 自动发现所有上游站点的可用模型,**零配置**生成路由表
|
||||
- 四级成本信号:实测成本 → 配置成本 → 目录参考价 → 默认兜底
|
||||
- 多通道概率分摊(成本 40% / 余额 30% / 使用率 30%)
|
||||
- 失败自动冷却(默认 10 分钟),自动切换通道
|
||||
|
||||
### 📡 多平台聚合管理
|
||||
|
||||
| 平台 | 适配器 |
|
||||
|------|--------|
|
||||
| New API | `new-api` |
|
||||
| One API | `one-api` |
|
||||
| OneHub | `onehub` |
|
||||
| DoneHub | `done-hub` |
|
||||
| Veloera | `veloera` |
|
||||
| AnyRouter | `anyrouter` |
|
||||
| Sub2API | `sub2api` |
|
||||
|
||||
### 👥 账号与 Token 管理
|
||||
|
||||
- 多站点多账号,四级健康状态追踪(healthy / unhealthy / degraded / disabled)
|
||||
- 凭证加密存储,自动续签,站点联动禁用
|
||||
|
||||
### ✅ 自动签到
|
||||
|
||||
- Cron 定时执行(默认每日 08:00)
|
||||
- 智能解析奖励,签到失败自动通知
|
||||
- 完整签到日志与并发锁防重
|
||||
|
||||
### 💰 余额管理
|
||||
|
||||
- 定时刷新(默认每小时),收入与消费趋势分析
|
||||
- 余额兜底估算,凭证过期自动重新登录
|
||||
|
||||
### 🔔 告警通知
|
||||
|
||||
五种渠道:Webhook / Bark / Server酱 / Telegram Bot / SMTP 邮件
|
||||
|
||||
告警场景:余额不足、站点异常、签到失败、代理异常、Token 过期、每日摘要
|
||||
|
||||
### 🎮 模型操练场
|
||||
|
||||
- 交互式聊天测试,流式/非流式双模式
|
||||
- 对比不同通道输出
|
||||
|
||||
### 👥 SaaS 多租户管理
|
||||
|
||||
- **组织管理**:创建组织,每个组织独立管理成员和配额
|
||||
- **成员邀请**:通过邮件邀请加入组织,支持管理员/成员角色
|
||||
- **三级套餐**:Free(3人)/ Pro(10人)/ Enterprise(100人)
|
||||
- **权限控制**:Owner / Admin / Member 三级角色,细粒度权限
|
||||
- **套餐限制检查**:成员数、API Key 数、日请求量自动限制
|
||||
- **邀请链接**:带 token 的邀请链接,48 小时有效期
|
||||
|
||||
### 🔌 REST API 总览
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/organizations` | GET/POST | 组织列表 / 创建组织 |
|
||||
| `/api/organizations/plans` | GET | 可用套餐列表 |
|
||||
| `/api/organizations/:id` | GET/PATCH | 组织详情 / 更新 |
|
||||
| `/api/organizations/:id/members` | GET | 成员列表 |
|
||||
| `/api/organizations/:id/members/:uid` | PATCH/DELETE | 改角色 / 移除成员 |
|
||||
| `/api/organizations/:id/invitations` | GET/POST | 邀请列表 / 创建邀请 |
|
||||
| `/api/organizations/:id/invitations/:invId` | DELETE | 撤销邀请 |
|
||||
| `/api/invitations/:token` | GET | 公开邀请查询 |
|
||||
| `/api/invitations/:token/accept` | POST | 接受邀请 |
|
||||
| `/api/users/register` | POST | 注册新用户 |
|
||||
| `/api/users/login` | POST | 登录获取 JWT |
|
||||
| `/api/users/me` | GET/PATCH | 个人信息 / 更新 |
|
||||
| `/api/user-api-keys` | GET/POST | API Key 列表 / 创建 |
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ 硬件配置建议
|
||||
|
||||
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(推荐)
|
||||
|
||||
```bash
|
||||
mkdir boosapi && cd boosapi
|
||||
|
||||
cat > docker-compose.yml << 'EOF'
|
||||
services:
|
||||
boosapi:
|
||||
image: ghcr.io/boos/boosapi:latest
|
||||
ports:
|
||||
- "4000:4000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
BOOSAPI_AUTH__AUTH_TOKEN: ${AUTH_TOKEN?required}
|
||||
BOOSAPI_AUTH__PROXY_TOKEN: ${PROXY_TOKEN?required}
|
||||
BOOSAPI_UPSTREAM_BASE: https://api.boos.lat/v1
|
||||
BOOSAPI_UPSTREAM_KEY: ${UPSTREAM_KEY}
|
||||
BOOSAPI_SERVER__PORT: "4000"
|
||||
restart: unless-stopped
|
||||
EOF
|
||||
|
||||
# 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/boos`,用 `AUTH_TOKEN` 登录管理员后台;普通用户从用户登录页使用账号密码进入用户工作台。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 请务必修改 `AUTH_TOKEN` 和 `PROXY_TOKEN`,不要使用默认值。
|
||||
|
||||
---
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
.
|
||||
├── Cargo.toml # Rust 项目定义
|
||||
├── config.toml # 默认配置文件
|
||||
├── Dockerfile # 多阶段构建
|
||||
├── docker/
|
||||
│ └── docker-compose.yml
|
||||
├── src/
|
||||
│ ├── main.rs # 入口
|
||||
│ ├── lib.rs # 库入口
|
||||
│ ├── config.rs # 配置加载(环境变量 + config.toml)
|
||||
│ ├── error.rs # 统一错误处理
|
||||
│ ├── db/ # 数据库模型与查询
|
||||
│ ├── routes/
|
||||
│ │ ├── mod.rs # 路由组装 + 静态文件 serve
|
||||
│ │ ├── comfyui_agent.rs # 视频/工作流助手 SSE 聊天
|
||||
│ │ ├── comfyui/ # 外部 ComfyUI 代理(prompt/history/view)
|
||||
│ │ ├── proxy/ # OpenAI 兼容代理入口
|
||||
│ │ ├── auth.rs # 用户注册/登录 JWT
|
||||
│ │ ├── checkin.rs # 自动签到
|
||||
│ │ └── ...
|
||||
│ ├── proxy/ # 代理引擎核心
|
||||
│ │ ├── conductor/ # Proxy Conductor(故障转移)
|
||||
│ │ ├── token_router/ # Token 路由引擎
|
||||
│ │ └── executors/ # 执行器(chat/image/embedding)
|
||||
│ ├── transformers/ # 协议转换
|
||||
│ │ ├── openai/ # OpenAI Compatible
|
||||
│ │ ├── anthropic/ # Claude Messages
|
||||
│ │ └── gemini/ # Gemini GenerateContent
|
||||
│ └── services/ # 后台服务
|
||||
│ ├── cron/ # 定时任务
|
||||
│ ├── billing/ # 计费逻辑
|
||||
│ └── notification/ # 通知推送
|
||||
├── migrations/ # SQLite 迁移
|
||||
├── src/web/ # 管理台前端源码(React + Vite)
|
||||
│ ├── authSession.ts # 统一会话工具:commitSession/clearSession/getSessionToken(按 'admin' | 'user' 分桶)
|
||||
│ ├── loginError.ts # 登录错误归一化(resolveLoginErrorMessage)
|
||||
│ ├── pages/
|
||||
│ │ ├── 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)
|
||||
```
|
||||
|
||||
## 🔧 环境变量
|
||||
|
||||
所有配置通过环境变量覆盖,命名规则:`BOOSAPI_<SECTION>__<KEY>`(双下划线分隔层级):
|
||||
|
||||
```bash
|
||||
# 服务
|
||||
BOOSAPI_SERVER__HOST=0.0.0.0
|
||||
BOOSAPI_SERVER__PORT=4000
|
||||
BOOSAPI_SERVER__LOG_LEVEL=info
|
||||
|
||||
# 认证
|
||||
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
|
||||
|
||||
# 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
|
||||
```
|
||||
|
||||
## 🏗️ 技术栈
|
||||
|
||||
| 层 | 技术 |
|
||||
|----|------|
|
||||
| 后端框架 | **Axum 0.8**(Rust) |
|
||||
| 运行时 | **Tokio**(异步运行时) |
|
||||
| 数据库 | **SQLite** 运行主路径 + **PostgreSQL** schema/迁移准备(SQLx) |
|
||||
| 并行计算 | **Rayon**(CPU 密集任务) |
|
||||
| 认证鉴权 | **JWT** + API Key 双模式 |
|
||||
| 代理引擎 | 自定义 Token Router + Proxy Conductor |
|
||||
| 协议转换 | OpenAI / Claude / Gemini 全兼容 |
|
||||
| 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 |
|
||||
| 构建工具 | Cargo(后端)/ npm(前端静态构建) |
|
||||
|
||||
> **运行时零 Node.js 依赖**,Node.js 仅用作前端构建工具。生产环境只需 Rust 编译后的单二进制文件。
|
||||
|
||||
> 独立官网位于 `website/`。默认构建会把官网静态导出到 `dist/website/`,Rust 后端会让根路径 `/` 显示官网;管理台仍由 `dist/web/` 提供,管理员入口是 `/boos`,登录后进入 `/dashboard`。
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 本地开发
|
||||
|
||||
### 后端(Rust)
|
||||
|
||||
```bash
|
||||
# 最快验证:只检查 Rust 后端,不构建前端、不启动服务
|
||||
./build-and-run.sh --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
|
||||
|
||||
# 环境变量配置
|
||||
export BOOSAPI_SERVER__HOST=0.0.0.0
|
||||
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-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:
|
||||
|
||||
```bash
|
||||
# 安装依赖(首次)
|
||||
npm install
|
||||
|
||||
# 开发模式(热更新,需要 Node.js)
|
||||
cd src/web && npx vite --host 0.0.0.0
|
||||
|
||||
# 类型检查与构建生产版本
|
||||
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
|
||||
# 构建
|
||||
docker build -t boosapi .
|
||||
|
||||
# 运行
|
||||
docker run -d \
|
||||
-p 4000:4000 \
|
||||
-e BOOSAPI_AUTH__AUTH_TOKEN=your-admin-token \
|
||||
-e BOOSAPI_AUTH__PROXY_TOKEN=sk-your-proxy-token \
|
||||
-v $(pwd)/data:/app/data \
|
||||
boosapi
|
||||
```
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```yaml
|
||||
services:
|
||||
boosapi:
|
||||
build: .
|
||||
ports:
|
||||
- "4000:4000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
BOOSAPI_AUTH__AUTH_TOKEN: ${AUTH_TOKEN?required}
|
||||
BOOSAPI_AUTH__PROXY_TOKEN: ${PROXY_TOKEN?required}
|
||||
BOOSAPI_UPSTREAM_BASE: https://api.boos.lat/v1
|
||||
BOOSAPI_UPSTREAM_KEY: sk-your-upstream-key
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📜 License
|
||||
|
||||
[MIT](LICENSE)
|
||||
Meta-layer management and unified proxy for AI API aggregation
|
||||
-196
@@ -1,196 +0,0 @@
|
||||
<div align="center">
|
||||
|
||||
# BoosAPI
|
||||
|
||||
**AI Aggregation Gateway + Workflow Studio — one endpoint for many AI relay stations**
|
||||
|
||||
<p>
|
||||
Aggregate New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API stations
|
||||
<br>
|
||||
into <strong>one API key and one compatible endpoint</strong>, with model discovery, smart routing, logs, billing, and failover.
|
||||
<br>
|
||||
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="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="DEPLOY.md">Deployment</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Introduction
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
## Key Features
|
||||
|
||||
### 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
|
||||
|
||||
### Workflow Studio
|
||||
|
||||
Workflow Studio uses an embedded ComfyUI frontend. The execution backend is selected by `comfyui.backend`:
|
||||
|
||||
| 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 |
|
||||
|
||||
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.
|
||||
|
||||
### Assistants
|
||||
|
||||
- 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
|
||||
|
||||
## Hardware Guide
|
||||
|
||||
| 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 |
|
||||
|
||||
Recommended SaaS gateway server:
|
||||
|
||||
```txt
|
||||
4 CPU cores
|
||||
8GB RAM
|
||||
50GB SSD
|
||||
No GPU required
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
cp config.toml.example config.toml
|
||||
./build-and-run.sh
|
||||
```
|
||||
|
||||
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 | 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
|
||||
|
||||
```bash
|
||||
cargo check
|
||||
cargo run
|
||||
pnpm install
|
||||
pnpm run dev:web
|
||||
```
|
||||
|
||||
Build checks:
|
||||
|
||||
```bash
|
||||
cargo check
|
||||
pnpm run typecheck:web
|
||||
npx vite build
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
File diff suppressed because one or more lines are too long
BIN
Binary file not shown.
@@ -1,625 +0,0 @@
|
||||
#!/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"
|
||||
@@ -1,304 +0,0 @@
|
||||
"""
|
||||
BoosAPI ComfyUI Custom Nodes
|
||||
|
||||
让 ComfyUI workflow 调用 BoosAPI 的 AI 模型(图像生成、LLM 等)。
|
||||
配置:通过环境变量设置 BoosAPI 地址,或使用默认 http://127.0.0.1:4000/v1
|
||||
|
||||
环境变量:
|
||||
BOOSAPI_BASE — BoosAPI 地址(默认 http://127.0.0.1:4000)
|
||||
BOOSAPI_KEY — API Key(默认 sk-boos4721)
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import requests
|
||||
|
||||
# ── Config ───────────────────────────────────────────────────────────────
|
||||
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 = {}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {}
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
# Node: BoosAPIImageGen — 图像生成 (OpenAI DALL-E / 兼容格式)
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class BoosAPIImageGen:
|
||||
"""调用 BoosAPI /v1/images/generations 生成图像"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"prompt": ("STRING", {"multiline": True, "default": ""}),
|
||||
"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}),
|
||||
},
|
||||
"optional": {
|
||||
"style": (["vivid", "natural"], {"default": "vivid"}),
|
||||
},
|
||||
}
|
||||
|
||||
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_V1}/images/generations"
|
||||
payload = {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"n": n,
|
||||
"size": size,
|
||||
"quality": quality,
|
||||
"style": style,
|
||||
"response_format": "b64_json",
|
||||
}
|
||||
try:
|
||||
resp = requests.post(url, headers=HEADERS, json=payload, timeout=120)
|
||||
raise_for_status_with_body(resp)
|
||||
data = resp.json()
|
||||
images = data.get("data", [])
|
||||
# Return first image as b64
|
||||
from io import BytesIO
|
||||
import base64
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
b64_data = images[0]["b64_json"]
|
||||
img_bytes = base64.b64decode(b64_data)
|
||||
pil_img = Image.open(BytesIO(img_bytes)).convert("RGB")
|
||||
img_tensor = torch.from_numpy(
|
||||
__import__("numpy").array(pil_img).astype("float32") / 255.0
|
||||
).unsqueeze(0)
|
||||
|
||||
metadata = json.dumps({
|
||||
"model": model,
|
||||
"size": size,
|
||||
"revised_prompt": data.get("data", [{}])[0].get("revised_prompt", ""),
|
||||
})
|
||||
return (img_tensor, metadata)
|
||||
except Exception as e:
|
||||
print(f"[BoosAPI] ImageGen failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
# Node: BoosAPILLM — LLM Chat (流式文本生成)
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class BoosAPILLM:
|
||||
"""调用 BoosAPI /v1/chat/completions 生成文本"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"prompt": ("STRING", {"multiline": True, "default": ""}),
|
||||
"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}),
|
||||
},
|
||||
}
|
||||
|
||||
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_V1}/chat/completions"
|
||||
messages = []
|
||||
if system_prompt:
|
||||
messages.append({"role": "system", "content": system_prompt})
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"stream": False,
|
||||
}
|
||||
try:
|
||||
resp = requests.post(url, headers=HEADERS, json=payload, timeout=120)
|
||||
raise_for_status_with_body(resp)
|
||||
data = resp.json()
|
||||
text = data["choices"][0]["message"]["content"]
|
||||
return {"ui": {"text": (text,)}, "result": (text,)}
|
||||
except Exception as e:
|
||||
print(f"[BoosAPI] LLM chat failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
# Node: BoosAPITextToImage — 文生图 (ComfyUI 原生格式)
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class BoosAPITextToImage:
|
||||
"""输入文本提示词,通过 BoosAPI 生成图像,输出 ComfyUI IMAGE 格式"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"positive": ("STRING", {"multiline": True, "default": ""}),
|
||||
"model": (model_choices("image"), {"default": default_model("image")}),
|
||||
},
|
||||
"optional": {
|
||||
"negative": ("STRING", {"multiline": True, "default": ""}),
|
||||
"size": (["1024x1024", "1792x1024", "1024x1792"], {"default": "1024x1024"}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "generate"
|
||||
OUTPUT_NODE = True
|
||||
CATEGORY = "BoosAPI"
|
||||
|
||||
def generate(self, positive, model, negative="", size="1024x1024"):
|
||||
# Build prompt with negative guidance
|
||||
prompt = positive
|
||||
if negative:
|
||||
prompt = f"{positive}\n避免: {negative}"
|
||||
|
||||
url = f"{BOOSAPI_V1}/images/generations"
|
||||
payload = {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"n": 1,
|
||||
"size": size,
|
||||
"response_format": "b64_json",
|
||||
}
|
||||
try:
|
||||
resp = requests.post(url, headers=HEADERS, json=payload, timeout=120)
|
||||
raise_for_status_with_body(resp)
|
||||
data = resp.json()
|
||||
|
||||
import base64
|
||||
from io import BytesIO
|
||||
import torch
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
|
||||
b64 = data["data"][0]["b64_json"]
|
||||
img = Image.open(BytesIO(base64.b64decode(b64))).convert("RGB")
|
||||
tensor = torch.from_numpy(np.array(img).astype("float32") / 255.0).unsqueeze(0)
|
||||
return (tensor,)
|
||||
except Exception as e:
|
||||
print(f"[BoosAPI] T2I failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
# Register Nodes
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"BoosAPIImageGen": BoosAPIImageGen,
|
||||
"BoosAPILLM": BoosAPILLM,
|
||||
"BoosAPITextToImage": BoosAPITextToImage,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"BoosAPIImageGen": "BoosAPI 图像生成",
|
||||
"BoosAPILLM": "BoosAPI LLM 对话",
|
||||
"BoosAPITextToImage": "BoosAPI 文生图",
|
||||
}
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
-76
@@ -1,76 +0,0 @@
|
||||
[server]
|
||||
host = "0.0.0.0"
|
||||
port = 4000
|
||||
log_level = "info"
|
||||
cors_origins = ["*"]
|
||||
|
||||
[db]
|
||||
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 = "boos4721"
|
||||
proxy_token = "sk-boos4721"
|
||||
|
||||
[proxy]
|
||||
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"
|
||||
@@ -1,21 +0,0 @@
|
||||
# Meta API Hub - Environment Variables
|
||||
# Copy this file to .env and fill in your values
|
||||
# cp .env.example .env
|
||||
|
||||
# Admin authentication token (required, change this!)
|
||||
AUTH_TOKEN=change-me-admin-token
|
||||
|
||||
# Proxy API token used for OpenAI-compatible proxy endpoint (required, change this!)
|
||||
PROXY_TOKEN=change-me-proxy-sk-token
|
||||
PORT=4000
|
||||
NOTIFY_COOLDOWN_SEC=300
|
||||
ADMIN_IP_ALLOWLIST=
|
||||
TELEGRAM_ENABLED=false
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
TELEGRAM_CHAT_ID=
|
||||
|
||||
# Auto check-in cron (default: 8:00 AM every day)
|
||||
CHECKIN_CRON=0 8 * * *
|
||||
|
||||
# Balance refresh cron (default: every hour)
|
||||
BALANCE_REFRESH_CRON=0 * * * *
|
||||
@@ -1,18 +0,0 @@
|
||||
# Build stage
|
||||
FROM rust:1.85-slim-bookworm AS builder
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y pkg-config libssl-dev
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
RUN mkdir src && echo "fn main() {}" > src/main.rs
|
||||
RUN cargo build --release 2>/dev/null || true
|
||||
COPY . .
|
||||
RUN cargo build --release
|
||||
|
||||
# Runtime stage
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/target/release/boosapi /app/
|
||||
COPY --from=builder /app/migrations /app/migrations
|
||||
EXPOSE 4000
|
||||
CMD ["/app/boosapi"]
|
||||
@@ -1,33 +0,0 @@
|
||||
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
|
||||
Binary file not shown.
@@ -1,9 +0,0 @@
|
||||
# docker-compose.override.yml
|
||||
# This file is automatically picked up by Docker Compose during local development.
|
||||
# It overrides the image reference in docker-compose.yml with a local build.
|
||||
services:
|
||||
boosapi:
|
||||
image: ~
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
@@ -1,694 +0,0 @@
|
||||
# 计费闭环 实现计划
|
||||
|
||||
> **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`。
|
||||
@@ -1,149 +0,0 @@
|
||||
# 计费闭环系统设计
|
||||
|
||||
日期: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 余额对账校准任务。
|
||||
@@ -1,100 +0,0 @@
|
||||
# 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 风格。
|
||||
@@ -1,195 +0,0 @@
|
||||
# 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 风格。
|
||||
@@ -1,868 +0,0 @@
|
||||
-- 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 INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
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 INTEGER DEFAULT false,
|
||||
custom_headers TEXT,
|
||||
external_checkin_url TEXT,
|
||||
global_weight REAL DEFAULT 1,
|
||||
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,
|
||||
is_pinned INTEGER DEFAULT false,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
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 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'))
|
||||
);
|
||||
|
||||
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 INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
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 INTEGER DEFAULT true,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
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 INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
site_id INTEGER NOT NULL,
|
||||
username TEXT,
|
||||
access_token TEXT NOT NULL,
|
||||
api_token TEXT,
|
||||
balance REAL DEFAULT 0,
|
||||
balance_used REAL DEFAULT 0,
|
||||
quota REAL DEFAULT 0,
|
||||
unit_cost REAL,
|
||||
value_score REAL DEFAULT 0,
|
||||
status TEXT DEFAULT 'active',
|
||||
checkin_enabled INTEGER DEFAULT true,
|
||||
last_checkin_at TEXT,
|
||||
last_balance_refresh TEXT,
|
||||
extra_config TEXT,
|
||||
is_pinned INTEGER DEFAULT false,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
oauth_provider TEXT,
|
||||
oauth_account_key TEXT,
|
||||
oauth_project_id TEXT,
|
||||
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 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 INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
description TEXT,
|
||||
group_name TEXT,
|
||||
tags TEXT,
|
||||
enabled INTEGER DEFAULT true,
|
||||
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 REFERENCES users(id),
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
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 INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
site_id INTEGER NOT NULL,
|
||||
model_name TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
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 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);
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- ACCOUNT TOKENS
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS account_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
account_id INTEGER 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 INTEGER DEFAULT true,
|
||||
is_default INTEGER DEFAULT false,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
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 INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
account_id INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
message TEXT,
|
||||
reward TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
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 INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
account_id INTEGER NOT NULL,
|
||||
model_name TEXT NOT NULL,
|
||||
available INTEGER,
|
||||
latency_ms INTEGER,
|
||||
checked_at TEXT DEFAULT (datetime('now')),
|
||||
is_manual INTEGER 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 INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
token_id INTEGER NOT NULL,
|
||||
model_name TEXT NOT NULL,
|
||||
available INTEGER,
|
||||
latency_ms INTEGER,
|
||||
checked_at TEXT DEFAULT (datetime('now')),
|
||||
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 INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
route_id INTEGER NOT NULL,
|
||||
account_id INTEGER NOT NULL,
|
||||
token_id INTEGER,
|
||||
oauth_route_unit_id INTEGER,
|
||||
source_model TEXT,
|
||||
priority INTEGER DEFAULT 0,
|
||||
weight INTEGER DEFAULT 10,
|
||||
enabled INTEGER DEFAULT true,
|
||||
manual_override INTEGER DEFAULT false,
|
||||
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,
|
||||
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 INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
group_route_id INTEGER NOT NULL,
|
||||
source_route_id INTEGER 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 INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
route_id INTEGER,
|
||||
channel_id INTEGER,
|
||||
account_id INTEGER,
|
||||
downstream_api_key_id INTEGER,
|
||||
model_requested TEXT,
|
||||
model_actual TEXT,
|
||||
status TEXT,
|
||||
http_status INTEGER,
|
||||
latency_ms INTEGER,
|
||||
first_byte_latency_ms INTEGER,
|
||||
prompt_tokens INTEGER,
|
||||
completion_tokens INTEGER,
|
||||
total_tokens INTEGER,
|
||||
estimated_cost REAL,
|
||||
error_message TEXT,
|
||||
retry_count INTEGER DEFAULT 0,
|
||||
is_stream INTEGER,
|
||||
client_family TEXT,
|
||||
client_app_id TEXT,
|
||||
client_app_name TEXT,
|
||||
client_confidence TEXT,
|
||||
billing_details TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
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 INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
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 INTEGER,
|
||||
account_id INTEGER,
|
||||
status_snapshot TEXT,
|
||||
upstream_response_meta TEXT,
|
||||
last_upstream_status INTEGER,
|
||||
last_polled_at TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
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 INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
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 INTEGER NOT NULL,
|
||||
sha256 TEXT NOT NULL,
|
||||
content_base64 TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
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 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);
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- PROXY DEBUG ATTEMPTS
|
||||
-- ============================================================
|
||||
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);
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- EVENTS
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
message TEXT,
|
||||
level TEXT NOT NULL DEFAULT 'info',
|
||||
read INTEGER DEFAULT false,
|
||||
related_id INTEGER,
|
||||
related_type TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
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 INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
site_id INTEGER 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 (datetime('now')),
|
||||
last_seen_at TEXT DEFAULT (datetime('now')),
|
||||
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 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);
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- OAUTH ROUTE UNIT MEMBERS
|
||||
-- ============================================================
|
||||
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);
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 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 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);
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 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 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);
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- MODEL DAY USAGE
|
||||
-- ============================================================
|
||||
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,
|
||||
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 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,
|
||||
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 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,
|
||||
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);
|
||||
@@ -1,24 +0,0 @@
|
||||
-- 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 INTEGER DEFAULT true,
|
||||
notify_on_failure INTEGER DEFAULT true,
|
||||
result_json TEXT,
|
||||
progress INTEGER DEFAULT 0,
|
||||
progress_total INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
started_at TEXT,
|
||||
finished_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS background_tasks_status_idx
|
||||
ON background_tasks (status);
|
||||
@@ -1,53 +0,0 @@
|
||||
-- Organizations, members, and invitations for multi-tenant support
|
||||
|
||||
CREATE TABLE IF NOT EXISTS organizations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
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 (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
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 INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
org_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL DEFAULT 'member',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
joined_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
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 INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
org_id INTEGER 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 INTEGER REFERENCES users(id),
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS invitations_token_unique
|
||||
ON invitations (token);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS invitations_status_idx
|
||||
ON invitations (status);
|
||||
@@ -1,37 +0,0 @@
|
||||
-- Usage tracking tables migrated from the Node.js backend.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS usage_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
downstream_key_id INTEGER,
|
||||
model TEXT,
|
||||
prompt_tokens INTEGER DEFAULT 0,
|
||||
completion_tokens INTEGER DEFAULT 0,
|
||||
total_tokens INTEGER DEFAULT 0,
|
||||
cost REAL DEFAULT 0.0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
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 INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
local_day TEXT NOT NULL,
|
||||
downstream_key_id INTEGER,
|
||||
request_count INTEGER NOT NULL DEFAULT 0,
|
||||
prompt_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
completion_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
total_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
total_cost REAL NOT NULL DEFAULT 0.0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
UNIQUE(local_day, downstream_key_id),
|
||||
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);
|
||||
@@ -1,33 +0,0 @@
|
||||
-- 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);
|
||||
@@ -1,34 +0,0 @@
|
||||
-- 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);
|
||||
@@ -1,64 +0,0 @@
|
||||
-- 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);
|
||||
@@ -1,3 +0,0 @@
|
||||
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;
|
||||
@@ -1,51 +0,0 @@
|
||||
-- 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);
|
||||
@@ -1,27 +0,0 @@
|
||||
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);
|
||||
@@ -1,39 +0,0 @@
|
||||
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);
|
||||
@@ -1,868 +0,0 @@
|
||||
-- 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);
|
||||
@@ -1,24 +0,0 @@
|
||||
-- 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);
|
||||
@@ -1,53 +0,0 @@
|
||||
-- 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);
|
||||
@@ -1,37 +0,0 @@
|
||||
-- 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);
|
||||
@@ -1,33 +0,0 @@
|
||||
-- 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);
|
||||
@@ -1,34 +0,0 @@
|
||||
-- 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);
|
||||
@@ -1,64 +0,0 @@
|
||||
-- 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);
|
||||
@@ -1,3 +0,0 @@
|
||||
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;
|
||||
@@ -1,45 +0,0 @@
|
||||
-- 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);
|
||||
@@ -1,86 +0,0 @@
|
||||
{
|
||||
"name": "boosapi",
|
||||
"version": "1.3.0",
|
||||
"description": "Meta-layer management and unified proxy for AI API aggregation platforms",
|
||||
"main": "dist/web/index.html",
|
||||
"keywords": [
|
||||
"ai",
|
||||
"api-gateway",
|
||||
"proxy",
|
||||
"rust",
|
||||
"openai",
|
||||
"claude",
|
||||
"aggregator"
|
||||
],
|
||||
"homepage": "https://github.com/cita-777/metapi#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/cita-777/metapi/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/cita-777/metapi.git"
|
||||
},
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"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": "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",
|
||||
"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",
|
||||
"@visactor/react-vchart": "^2.0.21",
|
||||
"gsap": "^3.15.0",
|
||||
"marked": "^17.0.5",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/node": "^22.10.1",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/react-test-renderer": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"jsdom": "^29.0.2",
|
||||
"mermaid": "^11.14.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^7.14.0",
|
||||
"react-test-renderer": "^18.3.1",
|
||||
"sharp": "^0.34.5",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^6.0.3",
|
||||
"vitepress": "^1.6.4",
|
||||
"vitepress-plugin-mermaid": "^2.0.17",
|
||||
"vitest": "^2.1.8"
|
||||
},
|
||||
"overrides": {
|
||||
"minimist": "^1.2.8"
|
||||
},
|
||||
"author": {
|
||||
"name": "cita-777",
|
||||
"email": "cita-777@users.noreply.github.com",
|
||||
"url": "https://github.com/cita-777"
|
||||
}
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
# 2026-05-15 技术报告 / Technical Report
|
||||
|
||||
## Executive Summary
|
||||
|
||||
本日完成 BoosAPI 品牌迁移、ComfyUI Agent 对话助手开发、Gitea CI/CD Runner 部署、Notification Service 能力梳理及部署文档重写。核心变更包括:将项目内 `Metapi` 全局替换为 `BoosAPI`,新增 `/comfyui-agent` 对话式 AI 视频生成入口,完成 SSE streaming 前后端链路,并完善生产部署文档与通知服务说明。
|
||||
|
||||
---
|
||||
|
||||
## 1. Metapi → BoosAPI 全局改名 / Global Rename
|
||||
|
||||
### 1.1 变更范围 / Change Scope
|
||||
|
||||
| 区域 / Area | 变更内容 / Changes |
|
||||
|---|---|
|
||||
| UI 界面 / UI | About 页面、侧边栏、登录页品牌名更新为 `BoosAPI` |
|
||||
| i18n 翻译 / Internationalization | 所有语言文件中的 `Metapi` 替换为 `BoosAPI` |
|
||||
| 后端通知 / Backend Notification | `notifyService.ts` 通知前缀由 `[metapi]` 改为 `[BoosAPI]` |
|
||||
| 桌面元数据 / Desktop Metadata | Electron / Docker 描述信息更新 |
|
||||
|
||||
### 1.2 已完成的技术标识替换 / Completed Technical Identifier Changes
|
||||
|
||||
| 项目 / Item | 变更说明 / Description |
|
||||
|---|---|
|
||||
| HTTP Header | `x-metapi-*` → `x-boosapi-*` |
|
||||
| 推理签名前缀 / Inference Signature Prefix | `metapi:` → `boosapi:` |
|
||||
| 运行时属性 / Runtime Properties | `__metapiTruncated` 等 → `__boosapi*` |
|
||||
| 环境变量 / Environment Variables | `METAPI_*` → `BOOSAPI_*` |
|
||||
| Package Name | `package.json` 中 `name` 更新为 `boosapi` |
|
||||
|
||||
### 1.3 验证方式 / Verification
|
||||
|
||||
```bash
|
||||
grep -rn '[mM]etapi' src/ --include='*.ts' --include='*.tsx' --include='*.html' \
|
||||
| grep -v '.test.' | grep -v node_modules | grep -iv 'boosapi'
|
||||
```
|
||||
|
||||
预期结果:无输出。
|
||||
|
||||
---
|
||||
|
||||
## 2. ComfyUI Agent 对话助手 / Conversational Assistant
|
||||
|
||||
### 2.1 功能概述 / Feature Overview
|
||||
|
||||
侧边栏新增「对话助手」入口,路由为 `/comfyui-agent`,提供与 AI 视频生成助手的对话式交互,支持文本、角色提示词、图像、场景、语音和 ComfyUI workflow 等多类型内容的流式渲染。
|
||||
|
||||
### 2.2 技术架构 / Architecture
|
||||
|
||||
```
|
||||
用户 / User → POST /api/comfyui-agent/chat → BoosAPI → LLM 本地 API (streaming)
|
||||
→ SSE 事件流 → BoosAPI 转发 → 浏览器
|
||||
```
|
||||
|
||||
### 2.3 后端实现 / Backend
|
||||
|
||||
| 文件 / File | 作用 / Responsibility |
|
||||
|---|---|
|
||||
| `src/server/routes/api/comfyuiAgent.ts` | SSE 流式聊天接口,调用本地 LLM API |
|
||||
| `src/server/services/boosapi_comfy_nodes.py` | ComfyUI 自定义节点,默认连接 `http://127.0.0.1:4000/v1` |
|
||||
|
||||
### 2.4 前端实现 / Frontend
|
||||
|
||||
| 文件 / File | 作用 / Responsibility |
|
||||
|---|---|
|
||||
| `src/web/pages/ComfyUIAgent.tsx` | 对话界面组件,SSE 流式渲染 |
|
||||
| `src/web/App.tsx` | 注册 `/comfyui-agent` 路由及侧边栏入口 |
|
||||
|
||||
### 2.5 SSE 事件类型 / SSE Event Types
|
||||
|
||||
| `type` | 含义 / Meaning | 前端渲染 / Rendering |
|
||||
|---|---|---|
|
||||
| `text` | 流式文本 | 追加到对话内容 |
|
||||
| `character_prompts` | 四角度角色提示词 | 角色卡片 |
|
||||
| `image` | 生成的角色图像 | 内联图片 |
|
||||
| `scene` | 场景提示词 | 场景块 |
|
||||
| `tts` | 语音音频 | 音频播放器 |
|
||||
| `comfyui_workflow` | 完整 ComfyUI 工作流 JSON | 下载按钮 |
|
||||
| `done` | 流结束 | 停止等待 |
|
||||
|
||||
### 2.6 验证结果 / Verification
|
||||
|
||||
| 命令 | 结果 |
|
||||
|---|---|
|
||||
| `npm install` | ✅ |
|
||||
| `npm run typecheck:web` | ✅ 0 error |
|
||||
|
||||
---
|
||||
|
||||
## 3. Gitea CI/CD Runner 部署 / Runner Deployment
|
||||
|
||||
### 3.1 部署信息 / Deployment Info
|
||||
|
||||
| 项目 | 详情 |
|
||||
|---|---|
|
||||
| 系统 | Alpine Linux, OpenRC |
|
||||
| 部署方式 | 裸机直跑,无 DinD |
|
||||
| Runner 版本 | v0.1.3 |
|
||||
| 注册模式 | `:host` 标签 |
|
||||
| OpenRC 服务 | `/etc/init.d/gitea-runner` |
|
||||
| 代理 | `HTTP_PROXY=http://127.0.0.1:7890` |
|
||||
| `NO_PROXY` | 包含 `git.boos.lat` |
|
||||
| 持久化 | `ephemeral: false` |
|
||||
|
||||
### 3.2 已知限制 / Known Limitations
|
||||
|
||||
- 不支持 Docker in Docker;CI 使用 `runs-on: host`
|
||||
- 无原生 artifact 存储;构建产物需手动备份
|
||||
- `actions/checkout` 偶发 timeout;可重试或使用本地 git 镜像
|
||||
|
||||
---
|
||||
|
||||
## 4. Notification Service
|
||||
|
||||
### 4.1 支持通道 / Supported Channels
|
||||
|
||||
| 通道 | 配置前缀 | 协议 |
|
||||
|---|---|---|
|
||||
| Telegram | `telegram*` | Bot API |
|
||||
| 企业微信 | `wecom*` | Webhook |
|
||||
| 飞书 | `feishu*` | Webhook |
|
||||
| 通用 Webhook | `webhook*` | JSON POST |
|
||||
| Bark | `bark*` | HTTP, iOS |
|
||||
| Server酱 | `serverchan*` | HTTP |
|
||||
| SMTP | `smtp*` | Email |
|
||||
|
||||
### 4.2 Telegram 消息格式
|
||||
|
||||
- 纯文本,不启用 HTML/Markdown 解析
|
||||
- 单条最大 3900 字符,超长截断 + `...(truncated)`
|
||||
- 支持 Telegram Topics(`message_thread_id`)
|
||||
|
||||
### 4.3 节流机制 / Throttling
|
||||
|
||||
- 基于通知签名的全局冷静期:`notifyCooldownSec`
|
||||
- 同一签名在冷静期内重复触发时合并为一次
|
||||
- 冷静期为全局共享,不按通道独立
|
||||
|
||||
---
|
||||
|
||||
## 5. 部署文档完善 / Deployment Documentation
|
||||
|
||||
Commit: `5bd847d`
|
||||
|
||||
对 `docs/deployment.md` 全量重写,修复所有 `metapi` 残留:
|
||||
|
||||
- 完整 `docker-compose.yml` 示例(含 healthcheck)
|
||||
- `.env` 变量插值模式
|
||||
- 全部环境变量参考表(20+ 变量)
|
||||
- 扩展配置:外部 MySQL/PostgreSQL、Redis、资源限制
|
||||
- 生产部署检查清单
|
||||
- Nginx 反向代理(WebSocket + SSE)
|
||||
- 管理后台 IP 白名单限制示例
|
||||
- Docker run 常用选项表
|
||||
- 升级/回滚操作流程
|
||||
|
||||
---
|
||||
|
||||
## 6. 待办事项 / TODO
|
||||
|
||||
| 优先级 | 事项 | 说明 |
|
||||
|---|---|---|
|
||||
| High | 服务未运行 | CI 崩溃后容器未恢复,需确定 Docker vs `npm run dev` |
|
||||
| Medium | Telegram 格式优化 | 待确认 HTML/Markdown/简化 |
|
||||
| Medium | CI pipeline 稳定性 | Docker 镜像拉取超时,需排查网络 |
|
||||
@@ -1,340 +0,0 @@
|
||||
# 内置 ComfyUI 视频助手 + SaaS 多租户方案
|
||||
|
||||
> 生成: gpt-5.5 via api.boos.lat | 2026-05-18
|
||||
|
||||
---
|
||||
|
||||
## 1. 数据表设计 (SQLite migrations)
|
||||
|
||||
### 1.1 `tenants` 租户
|
||||
|
||||
```sql
|
||||
CREATE TABLE tenants (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT UNIQUE NOT NULL,
|
||||
plan TEXT NOT NULL DEFAULT 'free',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
owner_user_id TEXT NOT NULL,
|
||||
settings TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
```
|
||||
|
||||
### 1.2 `tenant_members` 租户成员
|
||||
|
||||
```sql
|
||||
CREATE TABLE tenant_members (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'member',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(tenant_id, user_id)
|
||||
);
|
||||
```
|
||||
|
||||
### 1.3 `subscriptions` 订阅与额度
|
||||
|
||||
```sql
|
||||
CREATE TABLE subscriptions (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
plan TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
monthly_generations_limit INTEGER NOT NULL DEFAULT 100,
|
||||
monthly_video_seconds_limit INTEGER NOT NULL DEFAULT 300,
|
||||
storage_bytes_limit INTEGER NOT NULL DEFAULT 5368709120,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
```
|
||||
|
||||
### 1.4 `usage_counters` 用量统计
|
||||
|
||||
```sql
|
||||
CREATE TABLE usage_counters (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
period TEXT NOT NULL,
|
||||
generations_used INTEGER NOT NULL DEFAULT 0,
|
||||
video_seconds_used INTEGER NOT NULL DEFAULT 0,
|
||||
storage_bytes_used INTEGER NOT NULL DEFAULT 0,
|
||||
ai_tokens_used INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(tenant_id, period)
|
||||
);
|
||||
```
|
||||
|
||||
### 1.5 `comfy_workflows` 工作流模板
|
||||
|
||||
```sql
|
||||
CREATE TABLE comfy_workflows (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
type TEXT NOT NULL DEFAULT 'video',
|
||||
visibility TEXT NOT NULL DEFAULT 'private',
|
||||
workflow_json TEXT NOT NULL,
|
||||
input_schema TEXT NOT NULL DEFAULT '{}',
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
created_by TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
```
|
||||
|
||||
### 1.6 `video_jobs` 视频任务
|
||||
|
||||
```sql
|
||||
CREATE TABLE video_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
user_id TEXT,
|
||||
workflow_id TEXT REFERENCES comfy_workflows(id),
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
priority INTEGER NOT NULL DEFAULT 100,
|
||||
prompt TEXT,
|
||||
negative_prompt TEXT,
|
||||
params TEXT NOT NULL DEFAULT '{}',
|
||||
comfy_prompt_id TEXT,
|
||||
progress REAL NOT NULL DEFAULT 0,
|
||||
error_message TEXT,
|
||||
result_asset_id TEXT,
|
||||
estimated_video_seconds INTEGER DEFAULT 0,
|
||||
actual_video_seconds INTEGER DEFAULT 0,
|
||||
started_at TEXT,
|
||||
completed_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
```
|
||||
|
||||
### 1.7 `assets` 文件资产
|
||||
|
||||
```sql
|
||||
CREATE TABLE assets (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
user_id TEXT,
|
||||
job_id TEXT REFERENCES video_jobs(id) ON DELETE SET NULL,
|
||||
type TEXT NOT NULL,
|
||||
mime_type TEXT,
|
||||
file_name TEXT,
|
||||
storage_path TEXT NOT NULL,
|
||||
public_url TEXT,
|
||||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
metadata TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. API 路由设计
|
||||
|
||||
### 租户管理
|
||||
```
|
||||
POST /api/tenants → 创建租户
|
||||
GET /api/tenants → 我的租户列表
|
||||
GET /api/tenants/:id → 租户详情
|
||||
PATCH /api/tenants/:id → 更新租户
|
||||
```
|
||||
|
||||
### 成员管理
|
||||
```
|
||||
GET /api/tenants/:id/members → 成员列表
|
||||
POST /api/tenants/:id/members/invite → 邀请成员
|
||||
DELETE /api/tenants/:id/members/:uid → 移除成员
|
||||
PATCH /api/tenants/:id/members/:uid/role → 修改角色
|
||||
```
|
||||
|
||||
### 视频助手
|
||||
```
|
||||
POST /api/tenants/:id/video/analyze → 解析脚本(角色/场景/对话)
|
||||
POST /api/tenants/:id/video/generate → 生成视频任务
|
||||
GET /api/tenants/:id/video/jobs → 任务列表
|
||||
GET /api/tenants/:id/video/jobs/:jid → 任务详情
|
||||
POST /api/tenants/:id/video/jobs/:jid/cancel → 取消任务
|
||||
GET /api/tenants/:id/video/jobs/:jid/events → SSE 进度
|
||||
```
|
||||
|
||||
### ComfyUI 工作流
|
||||
```
|
||||
GET /api/tenants/:id/workflows → 工作流模板列表
|
||||
POST /api/tenants/:id/workflows → 创建模板
|
||||
GET /api/tenants/:id/workflows/:wid → 模板详情
|
||||
DELETE /api/tenants/:id/workflows/:wid → 删除模板
|
||||
```
|
||||
|
||||
### 资产生成(ai Helper)
|
||||
```
|
||||
POST /api/tenants/:id/ai/character → gpt-image-2 生成角色四视图
|
||||
POST /api/tenants/:id/ai/scene → 生成场景图
|
||||
POST /api/tenants/:id/ai/tts → TTS 语音
|
||||
POST /api/tenants/:id/ai/prompt-optimize → 优化视频 prompt
|
||||
```
|
||||
|
||||
### 用量与订阅
|
||||
```
|
||||
GET /api/tenants/:id/usage → 当前用量
|
||||
GET /api/tenants/:id/subscription → 订阅信息
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 前端页面/组件结构
|
||||
|
||||
```
|
||||
src/web/pages/
|
||||
├── VideoAssistant.tsx ← 视频助手主页面(prompt→生成→预览)
|
||||
├── VideoJobs.tsx ← 历史任务列表
|
||||
├── WorkflowLibrary.tsx ← 工作流模板库
|
||||
├── TenantSettings.tsx ← 租户设置
|
||||
└── TenantMembers.tsx ← 成员管理
|
||||
|
||||
src/web/components/
|
||||
├── video/
|
||||
│ ├── ScriptEditor.tsx ← 自然语言脚本编辑
|
||||
│ ├── ScriptPreview.tsx ← 脚本解析预览(角色/场景/对话)
|
||||
│ ├── CharacterCard.tsx ← 角色四视图卡片
|
||||
│ ├── SceneCard.tsx ← 场景图卡片
|
||||
│ ├── TTSPlayer.tsx ← TTS 语音播放
|
||||
│ ├── WorkflowExport.tsx ← ComfyUI 工作流导出
|
||||
│ └── VideoPlayer.tsx ← 视频播放/下载
|
||||
├── tenant/
|
||||
│ ├── TenantSwitcher.tsx ← 租户切换
|
||||
│ ├── MemberList.tsx ← 成员列表
|
||||
│ ├── InviteDialog.tsx ← 邀请对话框
|
||||
│ └── UsageQuota.tsx ← 用量/配额显示
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 核心业务流程
|
||||
|
||||
### 注册 → 创作 → 导出
|
||||
|
||||
```
|
||||
1. 注册/登录
|
||||
2. 自动创建个人租户(或个人空间)
|
||||
3. 进入视频助手页面
|
||||
4. 输入自然语言脚本
|
||||
5. 系统解析 → 提取角色/场景/对话
|
||||
6. 自动调用 AI 生成:
|
||||
- 【CHARACTER】→ gpt-image-2 四视图
|
||||
- 【SCENE】 → 场景图
|
||||
- 【TTS】 → 语音
|
||||
- 【WORKFLOW】 → ComfyUI 工作流 JSON
|
||||
7. 预览所有生成内容
|
||||
8. 导出/下载/提交 ComfyUI 执行
|
||||
9. 视频生成 → 预览 → 下载
|
||||
|
||||
如需协作: → 创建组织 → 邀请成员 → 设置角色
|
||||
```
|
||||
|
||||
### 租户管理流程
|
||||
|
||||
```
|
||||
创建组织
|
||||
→ 成为 owner
|
||||
→ 可创建 API Key
|
||||
→ 可邀请成员(邮箱/链接)
|
||||
→ 角色: owner / admin / member
|
||||
→ 每个成员有独立用量跟踪
|
||||
|
||||
套餐限制:
|
||||
free: 50 次/月, 5GB 存储, 基础功能
|
||||
pro: 500 次/月, 50GB, 全功能+更高配额
|
||||
custom: 定制
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 文件变更清单
|
||||
|
||||
### 新增文件
|
||||
|
||||
**后端 Rust:**
|
||||
```
|
||||
src/routes/tenants/mod.rs → 租户 CRUD
|
||||
src/routes/tenants/{handlers,types}.rs
|
||||
src/routes/video_jobs/ → 视频任务管理
|
||||
src/routes/video_jobs/{handlers,sse}.rs
|
||||
src/routes/ai_assist.rs → AI 辅助生成(角色/场景/TTS/prompt)
|
||||
src/routes/subscriptions.rs → 订阅/用量
|
||||
src/services/tenant_service.rs → 租户业务逻辑
|
||||
src/services/video_service.rs → 视频任务调度
|
||||
src/services/usage_service.rs → 用量统计
|
||||
src/services/comfy_client.rs → ComfyUI API 客户端
|
||||
src/workers/video_worker.rs → 后台视频任务 Worker
|
||||
src/middleware/tenant_context.rs → 租户上下文中间件
|
||||
migrations/0010_tenants.sql
|
||||
migrations/0011_tenant_members.sql
|
||||
migrations/0012_subscriptions.sql
|
||||
migrations/0013_usage_counters.sql
|
||||
migrations/0014_comfy_workflows.sql
|
||||
migrations/0015_video_jobs.sql
|
||||
migrations/0016_assets.sql
|
||||
```
|
||||
|
||||
**前端:**
|
||||
```
|
||||
src/web/pages/VideoAssistant.tsx
|
||||
src/web/pages/VideoJobs.tsx
|
||||
src/web/pages/WorkflowLibrary.tsx
|
||||
src/web/pages/TenantSettings.tsx
|
||||
src/web/pages/TenantMembers.tsx
|
||||
src/web/components/video/*.tsx
|
||||
src/web/components/tenant/*.tsx
|
||||
src/web/hooks/useVideoJobs.ts
|
||||
src/web/hooks/useTenant.ts
|
||||
src/web/hooks/useAssets.ts
|
||||
src/web/hooks/useUsage.ts
|
||||
```
|
||||
|
||||
### 修改文件
|
||||
|
||||
```
|
||||
src/routes/mod.rs → 注册新路由
|
||||
src/web/App.tsx → 添加导航项和路由
|
||||
src/routes/video_agent.rs → 增强现有 Agent SSE
|
||||
src/services/mod.rs → 注册新服务
|
||||
src/config.rs → 添加 ComfyUI/套餐配置
|
||||
src/main.rs → 注册 Worker
|
||||
src/db/mod.rs → 新增表类型
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 实施步骤(按优先级)
|
||||
|
||||
### P0 - 基础链路
|
||||
1. migrations: tenants + video_jobs + assets 三张核心表
|
||||
2. 租户上下文中间件
|
||||
3. 视频助手 Agent SSE 增强 → 脚本解析 + AI 生成调用
|
||||
4. 前端 VideoAssistant 页面(脚本输入→解析→预览→导出)
|
||||
5. ComfyUI client → 提交 prompt + 获取结果
|
||||
|
||||
### P1 - 多租户
|
||||
6. 租户 CRUD + 成员管理 + 邀请
|
||||
7. 资源 tenant_id 隔离
|
||||
8. 用量统计 + 套餐限制
|
||||
9. API Key 管理
|
||||
10. 顶栏租户切换 + 用量显示
|
||||
|
||||
### P2 - 生产增强
|
||||
11. SSE 实时进度
|
||||
12. Worker 后台任务队列
|
||||
13. 视频任务历史 + 重试
|
||||
14. ComfyUI 工作流模板库
|
||||
15. AI prompt 优化(调用 gpt-5.5)
|
||||
|
||||
### P3 - 商业化
|
||||
16. 订阅管理(Stripe/内部)
|
||||
17. 多 ComfyUI 节点调度
|
||||
18. 对象存储集成
|
||||
File diff suppressed because it is too large
Load Diff
Generated
-6055
File diff suppressed because it is too large
Load Diff
-31
@@ -1,31 +0,0 @@
|
||||
services:
|
||||
- type: web
|
||||
name: boosapi
|
||||
runtime: docker
|
||||
repo: https://github.com/cita-777/boosapi
|
||||
dockerfilePath: ./docker/Dockerfile
|
||||
dockerContext: .
|
||||
plan: free
|
||||
envVars:
|
||||
- key: AUTH_TOKEN
|
||||
sync: false
|
||||
- key: PROXY_TOKEN
|
||||
sync: false
|
||||
- key: TZ
|
||||
value: Asia/Shanghai
|
||||
- key: PORT
|
||||
value: "4000"
|
||||
- key: DATA_DIR
|
||||
value: /app/data
|
||||
- key: NODE_ENV
|
||||
value: production
|
||||
- key: DB_TYPE
|
||||
value: mysql
|
||||
- key: DB_URL
|
||||
sync: false
|
||||
- key: DB_SSL
|
||||
value: "true"
|
||||
- key: CHECKIN_CRON
|
||||
value: "0 8 * * *"
|
||||
- key: BALANCE_REFRESH_CRON
|
||||
value: "0 * * * *"
|
||||
@@ -1,147 +0,0 @@
|
||||
#!/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);
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,260 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,138 +0,0 @@
|
||||
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
File diff suppressed because it is too large
Load Diff
@@ -1,122 +0,0 @@
|
||||
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
|
||||
use argon2::Argon2;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::db::models::downstream_api_key::DownstreamApiKey;
|
||||
|
||||
/// Result of downstream token authorization.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DownstreamAuthResult {
|
||||
/// Found in downstream_api_keys table.
|
||||
Managed { key: Box<DownstreamApiKey> },
|
||||
/// Matched the global proxy token from config.
|
||||
Global { token: String },
|
||||
}
|
||||
|
||||
/// Authorize a downstream token (sk-xxx or global proxy token).
|
||||
///
|
||||
/// Looks up the token in:
|
||||
/// 1. downstream_api_keys table (for managed keys)
|
||||
/// 2. Falls back to checking against the global proxy token
|
||||
pub async fn authorize_downstream_token(
|
||||
token: &str,
|
||||
pool: &SqlitePool,
|
||||
global_proxy_token: Option<&str>,
|
||||
) -> Result<DownstreamAuthResult, String> {
|
||||
if global_proxy_token
|
||||
.map(|global| !global.is_empty() && token == global)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Ok(DownstreamAuthResult::Global {
|
||||
token: token.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Try managed keys first
|
||||
if token.starts_with("sk-") {
|
||||
let key: Option<DownstreamApiKey> = sqlx::query_as::<_, DownstreamApiKey>(
|
||||
"SELECT * FROM downstream_api_keys WHERE key = ? AND enabled = 1",
|
||||
)
|
||||
.bind(token)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|e| format!("Database error: {e}"))?;
|
||||
|
||||
if let Some(k) = key {
|
||||
return Ok(DownstreamAuthResult::Managed { key: Box::new(k) });
|
||||
}
|
||||
|
||||
return Err("Invalid API key".to_string());
|
||||
}
|
||||
|
||||
Err("Invalid API key format".to_string())
|
||||
}
|
||||
|
||||
/// Generate 16 random bytes for a salt using `uuid::Uuid::new_v4()`.
|
||||
fn random_salt_bytes() -> [u8; 16] {
|
||||
*uuid::Uuid::new_v4().as_bytes()
|
||||
}
|
||||
|
||||
/// Hash an API key using Argon2id with a random salt.
|
||||
pub fn hash_api_key(key: &str) -> anyhow::Result<String> {
|
||||
let salt = SaltString::encode_b64(&random_salt_bytes())
|
||||
.map_err(|e| anyhow::anyhow!("Failed to generate salt: {e}"))?;
|
||||
let hash = Argon2::default()
|
||||
.hash_password(key.as_bytes(), &salt)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to hash API key: {e}"))?;
|
||||
Ok(hash.to_string())
|
||||
}
|
||||
|
||||
/// Verify an API key against an Argon2id hash.
|
||||
pub fn verify_api_key(key: &str, hash: &str) -> anyhow::Result<bool> {
|
||||
let parsed_hash =
|
||||
PasswordHash::new(hash).map_err(|e| anyhow::anyhow!("Invalid hash format: {e}"))?;
|
||||
Ok(Argon2::default()
|
||||
.verify_password(key.as_bytes(), &parsed_hash)
|
||||
.is_ok())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_hash_and_verify_api_key() {
|
||||
let key = "sk-test-api-key-12345";
|
||||
let hash = hash_api_key(key).unwrap();
|
||||
assert!(verify_api_key(key, &hash).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrong_key_fails() {
|
||||
let key = "sk-test-api-key-12345";
|
||||
let hash = hash_api_key(key).unwrap();
|
||||
assert!(!verify_api_key("wrong-key", &hash).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_hash_fails() {
|
||||
let result = verify_api_key("some-key", "not-a-valid-hash");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_different_hashes_for_same_key() {
|
||||
let key = "sk-test-key";
|
||||
let hash1 = hash_api_key(key).unwrap();
|
||||
let hash2 = hash_api_key(key).unwrap();
|
||||
assert_ne!(hash1, hash2);
|
||||
assert!(verify_api_key(key, &hash1).unwrap());
|
||||
assert!(verify_api_key(key, &hash2).unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_global_proxy_token_authorizes_without_db_lookup() {
|
||||
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
|
||||
let result = authorize_downstream_token("sk-global", &pool, Some("sk-global"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(result, DownstreamAuthResult::Global { .. }));
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
use chrono::{Duration, Utc};
|
||||
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
pub sub: String, // user_id
|
||||
pub role: String, // "admin" | "user"
|
||||
pub exp: usize, // expiry timestamp
|
||||
pub iat: usize, // issued at
|
||||
}
|
||||
|
||||
pub fn generate_jwt(
|
||||
secret: &str,
|
||||
user_id: &str,
|
||||
role: &str,
|
||||
expiry_hours: i64,
|
||||
) -> anyhow::Result<String> {
|
||||
let now = Utc::now();
|
||||
let claims = Claims {
|
||||
sub: user_id.to_string(),
|
||||
role: role.to_string(),
|
||||
iat: now.timestamp() as usize,
|
||||
exp: (now + Duration::hours(expiry_hours)).timestamp() as usize,
|
||||
};
|
||||
Ok(encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(secret.as_bytes()),
|
||||
)?)
|
||||
}
|
||||
|
||||
pub fn validate_jwt(secret: &str, token: &str) -> anyhow::Result<Claims> {
|
||||
let data = decode::<Claims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(secret.as_bytes()),
|
||||
&Validation::default(),
|
||||
)?;
|
||||
Ok(data.claims)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_generate_and_validate_jwt() {
|
||||
let secret = "test-secret-key";
|
||||
let token = generate_jwt(secret, "user-123", "admin", 24).unwrap();
|
||||
let claims = validate_jwt(secret, &token).unwrap();
|
||||
assert_eq!(claims.sub, "user-123");
|
||||
assert_eq!(claims.role, "admin");
|
||||
assert!(claims.iat > 0);
|
||||
assert!(claims.exp > claims.iat);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_token_fails() {
|
||||
let secret = "test-secret-key";
|
||||
let result = validate_jwt(secret, "invalid-token");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrong_secret_fails() {
|
||||
let token = generate_jwt("secret1", "user-123", "user", 24).unwrap();
|
||||
let result = validate_jwt("secret2", &token);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
// Auth module — JWT, API key, and password authentication
|
||||
|
||||
pub mod api_key;
|
||||
pub mod jwt;
|
||||
pub mod password;
|
||||
|
||||
/// Represents the authentication context for a request.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AuthContext {
|
||||
/// Admin token-based authentication.
|
||||
Admin { token: String },
|
||||
/// Authenticated user session with JWT.
|
||||
User { user_id: String, role: String },
|
||||
/// Downstream API key authentication.
|
||||
DownstreamKey { key_id: i64, name: String },
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
|
||||
use argon2::Argon2;
|
||||
|
||||
/// Generate 16 random bytes for a salt using `uuid::Uuid::new_v4()`.
|
||||
fn random_salt_bytes() -> [u8; 16] {
|
||||
*uuid::Uuid::new_v4().as_bytes()
|
||||
}
|
||||
|
||||
/// Hash a user password using Argon2id with a random salt.
|
||||
pub fn hash_password(password: &str) -> anyhow::Result<String> {
|
||||
let salt = SaltString::encode_b64(&random_salt_bytes())
|
||||
.map_err(|e| anyhow::anyhow!("Failed to generate salt: {e}"))?;
|
||||
let hash = Argon2::default()
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to hash password: {e}"))?;
|
||||
Ok(hash.to_string())
|
||||
}
|
||||
|
||||
/// Verify a password against an Argon2id hash.
|
||||
pub fn verify_password(password: &str, hash: &str) -> anyhow::Result<bool> {
|
||||
let parsed_hash =
|
||||
PasswordHash::new(hash).map_err(|e| anyhow::anyhow!("Invalid hash format: {e}"))?;
|
||||
Ok(Argon2::default()
|
||||
.verify_password(password.as_bytes(), &parsed_hash)
|
||||
.is_ok())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_hash_and_verify_password() {
|
||||
let password = "secure-password-123!";
|
||||
let hash = hash_password(password).unwrap();
|
||||
assert!(verify_password(password, &hash).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrong_password_fails() {
|
||||
let password = "correct-password";
|
||||
let hash = hash_password(password).unwrap();
|
||||
assert!(!verify_password("wrong-password", &hash).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_different_hashes_for_same_password() {
|
||||
let password = "my-password";
|
||||
let hash1 = hash_password(password).unwrap();
|
||||
let hash2 = hash_password(password).unwrap();
|
||||
assert_ne!(hash1, hash2);
|
||||
assert!(verify_password(password, &hash1).unwrap());
|
||||
assert!(verify_password(password, &hash2).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_password() {
|
||||
let password = "";
|
||||
let hash = hash_password(password).unwrap();
|
||||
assert!(verify_password(password, &hash).unwrap());
|
||||
}
|
||||
}
|
||||
-336
@@ -1,336 +0,0 @@
|
||||
use figment::{
|
||||
providers::{Env, Format, Toml},
|
||||
Figment,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct AppConfig {
|
||||
pub server: ServerConfig,
|
||||
pub db: DbConfig,
|
||||
pub auth: AuthConfig,
|
||||
pub proxy: ProxyConfig,
|
||||
#[serde(default)]
|
||||
pub comfyui: ComfyUIConfig,
|
||||
#[serde(default)]
|
||||
pub pixelle_video: PixelleVideoConfig,
|
||||
#[serde(default)]
|
||||
pub payment: PaymentConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct ServerConfig {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub log_level: String,
|
||||
pub cors_origins: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct DbConfig {
|
||||
pub url: String,
|
||||
pub max_connections: u32,
|
||||
pub ssl: bool,
|
||||
pub runtime_db_url: Option<String>,
|
||||
pub runtime_db_dialect: Option<String>,
|
||||
pub runtime_db_ssl: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct AuthConfig {
|
||||
pub jwt_secret: String,
|
||||
pub jwt_expiry_hours: i64,
|
||||
pub auth_token: String,
|
||||
pub proxy_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct ProxyConfig {
|
||||
pub upstream_timeout_secs: u64,
|
||||
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,
|
||||
pub extra_args: Vec<String>,
|
||||
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,
|
||||
extra_args: Vec::new(),
|
||||
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()
|
||||
.or(self.base_url.as_deref())
|
||||
.map(str::trim)
|
||||
.filter(|url| !url.is_empty())
|
||||
.unwrap_or("http://127.0.0.1:8188")
|
||||
.trim_end_matches('/')
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
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(path))
|
||||
.merge(Env::prefixed("BOOSAPI_").split("__"))
|
||||
.extract()?)
|
||||
}
|
||||
}
|
||||
-129
@@ -1,129 +0,0 @@
|
||||
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?)
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
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,
|
||||
pub username: Option<String>,
|
||||
pub access_token: String,
|
||||
pub api_token: Option<String>,
|
||||
pub balance: Option<f64>,
|
||||
pub balance_used: Option<f64>,
|
||||
pub quota: Option<f64>,
|
||||
pub unit_cost: Option<f64>,
|
||||
pub value_score: Option<f64>,
|
||||
pub status: String,
|
||||
pub checkin_enabled: Option<bool>,
|
||||
pub last_checkin_at: Option<String>,
|
||||
pub last_balance_refresh: Option<String>,
|
||||
pub extra_config: Option<String>,
|
||||
pub is_pinned: Option<bool>,
|
||||
pub sort_order: Option<i64>,
|
||||
pub oauth_provider: Option<String>,
|
||||
pub oauth_account_key: Option<String>,
|
||||
pub oauth_project_id: Option<String>,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
pub name: String,
|
||||
pub token: String,
|
||||
pub source: Option<String>,
|
||||
pub token_group: Option<String>,
|
||||
pub value_status: String,
|
||||
pub enabled: Option<bool>,
|
||||
pub is_default: Option<bool>,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
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,
|
||||
pub title: String,
|
||||
pub status: String,
|
||||
pub message: Option<String>,
|
||||
pub error: Option<String>,
|
||||
pub dedupe_key: Option<String>,
|
||||
pub success_title: Option<String>,
|
||||
pub failure_title: Option<String>,
|
||||
pub notify_on_success: Option<bool>,
|
||||
pub notify_on_failure: Option<bool>,
|
||||
pub result_json: Option<String>,
|
||||
pub progress: Option<i64>,
|
||||
pub progress_total: Option<i64>,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
pub started_at: Option<String>,
|
||||
pub finished_at: Option<String>,
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
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,
|
||||
pub key: String,
|
||||
pub description: Option<String>,
|
||||
pub group_name: Option<String>,
|
||||
pub tags: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
pub expires_at: Option<String>,
|
||||
pub max_cost: Option<f64>,
|
||||
pub used_cost: Option<f64>,
|
||||
pub max_requests: Option<i64>,
|
||||
pub used_requests: Option<i64>,
|
||||
pub supported_models: Option<String>,
|
||||
pub allowed_route_ids: Option<String>,
|
||||
pub site_weight_multipliers: Option<String>,
|
||||
pub excluded_site_ids: Option<String>,
|
||||
pub excluded_credential_refs: Option<String>,
|
||||
pub last_used_at: Option<String>,
|
||||
pub user_id: Option<i64>,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
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")]
|
||||
pub event_type: String,
|
||||
pub title: String,
|
||||
pub message: Option<String>,
|
||||
pub level: Option<String>,
|
||||
#[sqlx(rename = "read")]
|
||||
pub is_read: Option<bool>,
|
||||
pub related_id: Option<i64>,
|
||||
pub related_type: Option<String>,
|
||||
pub created_at: Option<String>,
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
// Database models
|
||||
|
||||
pub mod account;
|
||||
pub mod background_task;
|
||||
pub mod downstream_api_key;
|
||||
pub mod event;
|
||||
pub mod organization;
|
||||
pub mod route_channel;
|
||||
pub mod setting;
|
||||
pub mod site;
|
||||
pub mod site_announcement;
|
||||
pub mod token_route;
|
||||
pub mod user;
|
||||
@@ -1,42 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
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,
|
||||
pub slug: String,
|
||||
pub plan: Option<String>,
|
||||
pub status: Option<String>,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
/// Member of an organization.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OrganizationMember {
|
||||
pub id: i64,
|
||||
pub org_id: i64,
|
||||
pub user_id: i64,
|
||||
pub role: Option<String>,
|
||||
pub status: Option<String>,
|
||||
pub joined_at: Option<String>,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
pub email: String,
|
||||
pub token: String,
|
||||
pub role: Option<String>,
|
||||
pub invited_by: Option<i64>,
|
||||
pub status: Option<String>,
|
||||
pub expires_at: Option<String>,
|
||||
pub created_at: Option<String>,
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
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,
|
||||
pub account_id: i64,
|
||||
pub token_id: Option<i64>,
|
||||
pub oauth_route_unit_id: Option<i64>,
|
||||
pub source_model: Option<String>,
|
||||
pub priority: Option<i64>,
|
||||
pub weight: Option<i64>,
|
||||
pub enabled: Option<bool>,
|
||||
pub manual_override: Option<bool>,
|
||||
pub success_count: Option<i64>,
|
||||
pub fail_count: Option<i64>,
|
||||
pub total_latency_ms: Option<i64>,
|
||||
pub total_cost: Option<f64>,
|
||||
pub last_used_at: Option<String>,
|
||||
pub last_selected_at: Option<String>,
|
||||
pub last_fail_at: Option<String>,
|
||||
pub consecutive_fail_count: i64,
|
||||
pub cooldown_level: i64,
|
||||
pub cooldown_until: Option<String>,
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
|
||||
/// Represents a key-value setting.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct Setting {
|
||||
pub key: String,
|
||||
pub value: Option<String>,
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
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,
|
||||
pub url: String,
|
||||
pub platform: String,
|
||||
pub status: String,
|
||||
pub api_key: Option<String>,
|
||||
pub proxy_url: Option<String>,
|
||||
pub use_system_proxy: Option<bool>,
|
||||
pub custom_headers: Option<String>,
|
||||
pub external_checkin_url: Option<String>,
|
||||
pub global_weight: Option<f64>,
|
||||
pub post_refresh_probe_enabled: Option<bool>,
|
||||
pub post_refresh_probe_model: Option<String>,
|
||||
pub post_refresh_probe_scope: Option<String>,
|
||||
pub post_refresh_probe_latency_threshold_ms: Option<i64>,
|
||||
pub is_pinned: Option<bool>,
|
||||
pub sort_order: Option<i64>,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
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,
|
||||
pub platform: String,
|
||||
pub source_key: String,
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
pub level: Option<String>,
|
||||
pub source_url: Option<String>,
|
||||
pub starts_at: Option<String>,
|
||||
pub ends_at: Option<String>,
|
||||
pub upstream_created_at: Option<String>,
|
||||
pub upstream_updated_at: Option<String>,
|
||||
pub first_seen_at: Option<String>,
|
||||
pub last_seen_at: Option<String>,
|
||||
pub read_at: Option<String>,
|
||||
pub dismissed_at: Option<String>,
|
||||
pub raw_payload: Option<String>,
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
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,
|
||||
pub model_mapping: Option<String>,
|
||||
pub display_name: Option<String>,
|
||||
pub display_icon: Option<String>,
|
||||
pub decision_snapshot: Option<String>,
|
||||
pub decision_refreshed_at: Option<String>,
|
||||
pub routing_strategy: Option<String>,
|
||||
pub route_mode: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
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,
|
||||
pub email: String,
|
||||
#[serde(skip_serializing)]
|
||||
pub password_hash: String,
|
||||
pub role: String,
|
||||
pub status: String,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
use crate::{
|
||||
db::models::{downstream_api_key::DownstreamApiKey, user::User},
|
||||
error::AppResult,
|
||||
};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
pub async fn find_user_by_id(db: &SqlitePool, id: i64) -> AppResult<Option<User>> {
|
||||
sqlx::query_as("SELECT * FROM users WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(db)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn find_user_by_email(db: &SqlitePool, email: &str) -> AppResult<Option<User>> {
|
||||
sqlx::query_as("SELECT * FROM users WHERE email = ?")
|
||||
.bind(email)
|
||||
.fetch_optional(db)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn list_user_api_keys(db: &SqlitePool, user_id: i64) -> AppResult<Vec<DownstreamApiKey>> {
|
||||
sqlx::query_as("SELECT * FROM downstream_api_keys WHERE user_id = ? ORDER BY created_at DESC")
|
||||
.bind(user_id)
|
||||
.fetch_all(db)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn find_downstream_key_by_token(
|
||||
db: &SqlitePool,
|
||||
token: &str,
|
||||
) -> AppResult<Option<DownstreamApiKey>> {
|
||||
sqlx::query_as("SELECT * FROM downstream_api_keys WHERE key = ?")
|
||||
.bind(token)
|
||||
.fetch_optional(db)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
@@ -1,383 +0,0 @@
|
||||
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)",
|
||||
];
|
||||
@@ -1,657 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Json, Response},
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AppError {
|
||||
#[error("Not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("Unauthorized: {0}")]
|
||||
Unauthorized(String),
|
||||
|
||||
#[error("Forbidden: {0}")]
|
||||
Forbidden(String),
|
||||
|
||||
#[error("Bad request: {0}")]
|
||||
BadRequest(String),
|
||||
|
||||
#[error("Upstream error: {0}")]
|
||||
UpstreamError(String),
|
||||
|
||||
#[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),
|
||||
|
||||
#[error("Database error: {0}")]
|
||||
Database(#[from] sqlx::Error),
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, message) = match &self {
|
||||
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
|
||||
AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg.clone()),
|
||||
AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, msg.clone()),
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
pub type AppResult<T> = Result<T, AppError>;
|
||||
-77
@@ -1,77 +0,0 @@
|
||||
pub mod auth;
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod error;
|
||||
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, 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,
|
||||
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.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()
|
||||
}
|
||||
}
|
||||
-220
@@ -1,220 +0,0 @@
|
||||
use anyhow::Context;
|
||||
use axum::serve;
|
||||
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::{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<()> {
|
||||
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 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
|
||||
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, builtin, runtime_auth);
|
||||
if let Err(err) = state.comfyui.start().await {
|
||||
tracing::warn!(%err, "ComfyUI embedded process did not start");
|
||||
}
|
||||
let comfyui_process = state.comfyui.clone();
|
||||
|
||||
// Build router
|
||||
let app = boosapi::routes::build_router(state);
|
||||
|
||||
// Start server
|
||||
let addr = SocketAddr::new(config.server.host.parse()?, config.server.port);
|
||||
tracing::info!("Starting server on {addr}");
|
||||
|
||||
serve(
|
||||
tokio::net::TcpListener::bind(addr).await?,
|
||||
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
)
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.await?;
|
||||
|
||||
if let Err(err) = comfyui_process.stop().await {
|
||||
tracing::warn!(%err, "ComfyUI embedded process did not stop cleanly");
|
||||
}
|
||||
|
||||
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()
|
||||
.await
|
||||
.expect("failed to install Ctrl+C handler");
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
let terminate = async {
|
||||
signal::unix::signal(signal::unix::SignalKind::terminate())
|
||||
.expect("failed to install SIGTERM handler")
|
||||
.recv()
|
||||
.await;
|
||||
};
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let terminate = std::future::pending::<()>();
|
||||
|
||||
tokio::select! {
|
||||
_ = ctrl_c => {},
|
||||
_ = terminate => {},
|
||||
}
|
||||
|
||||
tracing::info!("Shutdown signal received, starting graceful shutdown");
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
use crate::proxy::providers::types::ProviderCategory;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ProviderCapabilities {
|
||||
pub category: ProviderCategory,
|
||||
pub supports_chat: bool,
|
||||
pub supports_responses: bool,
|
||||
pub supports_images: bool,
|
||||
pub supports_audio: bool,
|
||||
pub supports_streaming: bool,
|
||||
pub supports_token_refresh: bool,
|
||||
}
|
||||
|
||||
pub fn detect_capabilities(platform: &str) -> ProviderCapabilities {
|
||||
let normalized = platform.to_ascii_lowercase();
|
||||
let category = match normalized.as_str() {
|
||||
"openai" => ProviderCategory::OpenAi,
|
||||
"anthropic" | "claude" => ProviderCategory::Anthropic,
|
||||
"gemini" | "google" => ProviderCategory::Gemini,
|
||||
"codex" => ProviderCategory::Codex,
|
||||
"gemini_cli" | "gemini-cli" => ProviderCategory::GeminiCli,
|
||||
"antigravity" => ProviderCategory::Antigravity,
|
||||
_ => ProviderCategory::GenericOpenAi,
|
||||
};
|
||||
|
||||
match category {
|
||||
ProviderCategory::Anthropic => ProviderCapabilities {
|
||||
category,
|
||||
supports_chat: true,
|
||||
supports_responses: false,
|
||||
supports_images: false,
|
||||
supports_audio: false,
|
||||
supports_streaming: true,
|
||||
supports_token_refresh: true,
|
||||
},
|
||||
ProviderCategory::Gemini | ProviderCategory::GeminiCli => ProviderCapabilities {
|
||||
category,
|
||||
supports_chat: true,
|
||||
supports_responses: false,
|
||||
supports_images: true,
|
||||
supports_audio: false,
|
||||
supports_streaming: true,
|
||||
supports_token_refresh: true,
|
||||
},
|
||||
_ => ProviderCapabilities {
|
||||
category,
|
||||
supports_chat: true,
|
||||
supports_responses: true,
|
||||
supports_images: true,
|
||||
supports_audio: true,
|
||||
supports_streaming: true,
|
||||
supports_token_refresh: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum CliProfileKind {
|
||||
Codex,
|
||||
ClaudeCode,
|
||||
GeminiCli,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct CliProfile {
|
||||
pub id: &'static str,
|
||||
pub kind: CliProfileKind,
|
||||
pub display_name: &'static str,
|
||||
pub default_model: &'static str,
|
||||
pub env_key_name: &'static str,
|
||||
}
|
||||
|
||||
pub fn builtin_profiles() -> Vec<CliProfile> {
|
||||
vec![
|
||||
CliProfile {
|
||||
id: "codex",
|
||||
kind: CliProfileKind::Codex,
|
||||
display_name: "Codex CLI",
|
||||
default_model: "gpt-5.5",
|
||||
env_key_name: "OPENAI_API_KEY",
|
||||
},
|
||||
CliProfile {
|
||||
id: "claude-code",
|
||||
kind: CliProfileKind::ClaudeCode,
|
||||
display_name: "Claude Code",
|
||||
default_model: "claude-sonnet-4",
|
||||
env_key_name: "ANTHROPIC_API_KEY",
|
||||
},
|
||||
CliProfile {
|
||||
id: "gemini-cli",
|
||||
kind: CliProfileKind::GeminiCli,
|
||||
display_name: "Gemini CLI",
|
||||
default_model: "gemini-2.0-flash",
|
||||
env_key_name: "GEMINI_API_KEY",
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -1,414 +0,0 @@
|
||||
#![allow(clippy::type_complexity)]
|
||||
|
||||
use super::retry_policy::classify_failure;
|
||||
use super::types::{
|
||||
AttemptFailure, AttemptFailureAction, AttemptResult, ExecuteAttemptContext, ExecuteInput,
|
||||
ExecuteResult, FailureInfo, FailureReason, SelectedChannel, SuccessMetrics,
|
||||
};
|
||||
use futures::future::BoxFuture;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
|
||||
const MAX_ATTEMPTS: usize = 5;
|
||||
|
||||
pub struct ProxyConductor {
|
||||
pub select_channel: Arc<
|
||||
dyn Fn(String, Option<Value>) -> BoxFuture<'static, Option<SelectedChannel>> + Send + Sync,
|
||||
>,
|
||||
pub select_next_channel: Arc<
|
||||
dyn Fn(String, Vec<i64>, Option<Value>) -> BoxFuture<'static, Option<SelectedChannel>>
|
||||
+ Send
|
||||
+ Sync,
|
||||
>,
|
||||
pub record_success: Arc<dyn Fn(i64, SuccessMetrics) -> BoxFuture<'static, ()> + Send + Sync>,
|
||||
pub record_failure: Arc<dyn Fn(i64, FailureInfo) -> BoxFuture<'static, ()> + Send + Sync>,
|
||||
pub refresh_auth: Option<
|
||||
Arc<
|
||||
dyn Fn(SelectedChannel, FailureInfo) -> BoxFuture<'static, Option<SelectedChannel>>
|
||||
+ Send
|
||||
+ Sync,
|
||||
>,
|
||||
>,
|
||||
}
|
||||
|
||||
impl ProxyConductor {
|
||||
pub async fn execute(&self, input: ExecuteInput) -> ExecuteResult {
|
||||
let mut attempts = 0;
|
||||
let mut exclude_channel_ids = Vec::new();
|
||||
let mut selected = match (self.select_channel)(
|
||||
input.requested_model.clone(),
|
||||
input.downstream_policy.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Some(selected) => selected,
|
||||
None => {
|
||||
return ExecuteResult::Failure {
|
||||
reason: FailureReason::NoChannel,
|
||||
selected: None,
|
||||
status: None,
|
||||
raw_error_text: None,
|
||||
attempts,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
loop {
|
||||
if attempts >= MAX_ATTEMPTS {
|
||||
return exhausted_failure(
|
||||
selected,
|
||||
None,
|
||||
None,
|
||||
attempts,
|
||||
input.on_terminal_failure.as_deref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let ctx = ExecuteAttemptContext {
|
||||
selected: selected.clone(),
|
||||
attempt_index: attempts,
|
||||
exclude_channel_ids: exclude_channel_ids.clone(),
|
||||
};
|
||||
attempts += 1;
|
||||
|
||||
match (input.attempt_fn)(ctx).await {
|
||||
AttemptResult::Success {
|
||||
response,
|
||||
latency_ms,
|
||||
cost,
|
||||
} => {
|
||||
(self.record_success)(
|
||||
selected.channel_id,
|
||||
SuccessMetrics {
|
||||
latency_ms,
|
||||
cost,
|
||||
metadata: Default::default(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return ExecuteResult::Success {
|
||||
selected,
|
||||
response,
|
||||
attempts,
|
||||
};
|
||||
}
|
||||
AttemptResult::Failure {
|
||||
action,
|
||||
status,
|
||||
raw_error_text,
|
||||
} => {
|
||||
let classified = if matches!(action, AttemptFailureAction::Stop) {
|
||||
AttemptFailureAction::Stop
|
||||
} else {
|
||||
classify_failure(status, raw_error_text.as_deref())
|
||||
};
|
||||
let info = FailureInfo {
|
||||
action: classified.as_str().to_string(),
|
||||
status,
|
||||
raw_error_text: raw_error_text.clone(),
|
||||
};
|
||||
(self.record_failure)(selected.channel_id, info.clone()).await;
|
||||
|
||||
match classified {
|
||||
AttemptFailureAction::RetrySameChannel => {
|
||||
continue;
|
||||
}
|
||||
AttemptFailureAction::RefreshAuth => {
|
||||
if let Some(refresh_auth) = &self.refresh_auth {
|
||||
if let Some(refreshed) =
|
||||
refresh_auth(selected.clone(), info.clone()).await
|
||||
{
|
||||
selected = refreshed;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
exclude_channel_ids.push(selected.channel_id);
|
||||
match (self.select_next_channel)(
|
||||
input.requested_model.clone(),
|
||||
exclude_channel_ids.clone(),
|
||||
input.downstream_policy.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Some(next) => {
|
||||
selected = next;
|
||||
}
|
||||
None => {
|
||||
return terminal_failure(
|
||||
selected,
|
||||
FailureReason::Failed,
|
||||
status,
|
||||
raw_error_text,
|
||||
attempts,
|
||||
input.on_terminal_failure.as_deref(),
|
||||
&info,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
AttemptFailureAction::Failover => {
|
||||
exclude_channel_ids.push(selected.channel_id);
|
||||
match (self.select_next_channel)(
|
||||
input.requested_model.clone(),
|
||||
exclude_channel_ids.clone(),
|
||||
input.downstream_policy.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Some(next) => {
|
||||
selected = next;
|
||||
}
|
||||
None => {
|
||||
return terminal_failure(
|
||||
selected,
|
||||
FailureReason::Failed,
|
||||
status,
|
||||
raw_error_text,
|
||||
attempts,
|
||||
input.on_terminal_failure.as_deref(),
|
||||
&info,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
AttemptFailureAction::Terminal => {
|
||||
return terminal_failure(
|
||||
selected,
|
||||
FailureReason::Terminal,
|
||||
status,
|
||||
raw_error_text,
|
||||
attempts,
|
||||
input.on_terminal_failure.as_deref(),
|
||||
&info,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
AttemptFailureAction::Stop => {
|
||||
return terminal_failure(
|
||||
selected,
|
||||
FailureReason::Failed,
|
||||
status,
|
||||
raw_error_text,
|
||||
attempts,
|
||||
input.on_terminal_failure.as_deref(),
|
||||
&info,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn terminal_failure(
|
||||
selected: SelectedChannel,
|
||||
reason: FailureReason,
|
||||
status: Option<u16>,
|
||||
raw_error_text: Option<String>,
|
||||
attempts: usize,
|
||||
on_terminal_failure: Option<
|
||||
&(dyn Fn(SelectedChannel, AttemptFailure) -> BoxFuture<'static, ()> + Send + Sync),
|
||||
>,
|
||||
info: &FailureInfo,
|
||||
) -> ExecuteResult {
|
||||
if let Some(callback) = on_terminal_failure {
|
||||
callback(selected.clone(), AttemptFailure::from(info)).await;
|
||||
}
|
||||
ExecuteResult::Failure {
|
||||
reason,
|
||||
selected: Some(selected),
|
||||
status,
|
||||
raw_error_text,
|
||||
attempts,
|
||||
}
|
||||
}
|
||||
|
||||
async fn exhausted_failure(
|
||||
selected: SelectedChannel,
|
||||
status: Option<u16>,
|
||||
raw_error_text: Option<String>,
|
||||
attempts: usize,
|
||||
on_terminal_failure: Option<
|
||||
&(dyn Fn(SelectedChannel, AttemptFailure) -> BoxFuture<'static, ()> + Send + Sync),
|
||||
>,
|
||||
) -> ExecuteResult {
|
||||
let info = FailureInfo {
|
||||
action: AttemptFailureAction::Stop.as_str().to_string(),
|
||||
status,
|
||||
raw_error_text: raw_error_text.clone(),
|
||||
};
|
||||
terminal_failure(
|
||||
selected,
|
||||
FailureReason::Failed,
|
||||
status,
|
||||
raw_error_text,
|
||||
attempts,
|
||||
on_terminal_failure,
|
||||
&info,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use futures::FutureExt;
|
||||
use serde_json::json;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
fn channel(id: i64) -> SelectedChannel {
|
||||
SelectedChannel {
|
||||
channel_id: id,
|
||||
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(),
|
||||
actual_model: "gpt-5.5".into(),
|
||||
token_name: None,
|
||||
token_value: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retries_same_channel_then_succeeds() {
|
||||
let attempts_seen = Arc::new(AtomicUsize::new(0));
|
||||
let recorded_failures = Arc::new(AtomicUsize::new(0));
|
||||
let recorded_successes = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let conductor = ProxyConductor {
|
||||
select_channel: Arc::new(|_, _| async { Some(channel(1)) }.boxed()),
|
||||
select_next_channel: Arc::new(|_, _, _| async { Some(channel(2)) }.boxed()),
|
||||
record_success: {
|
||||
let recorded_successes = Arc::clone(&recorded_successes);
|
||||
Arc::new(move |_, _| {
|
||||
let recorded_successes = Arc::clone(&recorded_successes);
|
||||
async move {
|
||||
recorded_successes.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
.boxed()
|
||||
})
|
||||
},
|
||||
record_failure: {
|
||||
let recorded_failures = Arc::clone(&recorded_failures);
|
||||
Arc::new(move |_, _| {
|
||||
let recorded_failures = Arc::clone(&recorded_failures);
|
||||
async move {
|
||||
recorded_failures.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
.boxed()
|
||||
})
|
||||
},
|
||||
refresh_auth: None,
|
||||
};
|
||||
|
||||
let result = conductor
|
||||
.execute(ExecuteInput {
|
||||
requested_model: "gpt-5.5".into(),
|
||||
downstream_policy: None,
|
||||
attempt_fn: Box::new({
|
||||
let attempts_seen = Arc::clone(&attempts_seen);
|
||||
move |_| {
|
||||
let attempt = attempts_seen.fetch_add(1, Ordering::SeqCst);
|
||||
async move {
|
||||
if attempt == 0 {
|
||||
AttemptResult::Failure {
|
||||
action: AttemptFailureAction::RetrySameChannel,
|
||||
status: Some(429),
|
||||
raw_error_text: Some("rate limited".into()),
|
||||
}
|
||||
} else {
|
||||
AttemptResult::Success {
|
||||
response: json!({"ok": true}),
|
||||
latency_ms: Some(12),
|
||||
cost: Some(0.1),
|
||||
}
|
||||
}
|
||||
}
|
||||
.boxed()
|
||||
}
|
||||
}),
|
||||
on_terminal_failure: None,
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
ExecuteResult::Success {
|
||||
selected, attempts, ..
|
||||
} => {
|
||||
assert_eq!(selected.channel_id, 1);
|
||||
assert_eq!(attempts, 2);
|
||||
}
|
||||
other => panic!("expected success, got {other:?}"),
|
||||
}
|
||||
assert_eq!(recorded_failures.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(recorded_successes.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failover_selects_next_channel() {
|
||||
let conductor = ProxyConductor {
|
||||
select_channel: Arc::new(|_, _| async { Some(channel(1)) }.boxed()),
|
||||
select_next_channel: Arc::new(|_, excluded, _| {
|
||||
async move {
|
||||
assert_eq!(excluded, vec![1]);
|
||||
Some(channel(2))
|
||||
}
|
||||
.boxed()
|
||||
}),
|
||||
record_success: Arc::new(|_, _| async {}.boxed()),
|
||||
record_failure: Arc::new(|_, _| async {}.boxed()),
|
||||
refresh_auth: None,
|
||||
};
|
||||
|
||||
let attempts_seen = Arc::new(AtomicUsize::new(0));
|
||||
let result = conductor
|
||||
.execute(ExecuteInput {
|
||||
requested_model: "gpt-5.5".into(),
|
||||
downstream_policy: None,
|
||||
attempt_fn: Box::new({
|
||||
let attempts_seen = Arc::clone(&attempts_seen);
|
||||
move |ctx| {
|
||||
let attempt = attempts_seen.fetch_add(1, Ordering::SeqCst);
|
||||
async move {
|
||||
if attempt == 0 {
|
||||
assert_eq!(ctx.selected.channel_id, 1);
|
||||
AttemptResult::Failure {
|
||||
action: AttemptFailureAction::Failover,
|
||||
status: Some(500),
|
||||
raw_error_text: None,
|
||||
}
|
||||
} else {
|
||||
assert_eq!(ctx.selected.channel_id, 2);
|
||||
AttemptResult::Success {
|
||||
response: json!({"ok": true}),
|
||||
latency_ms: None,
|
||||
cost: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
.boxed()
|
||||
}
|
||||
}),
|
||||
on_terminal_failure: None,
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
ExecuteResult::Success {
|
||||
selected, attempts, ..
|
||||
} => {
|
||||
assert_eq!(selected.channel_id, 2);
|
||||
assert_eq!(attempts, 2);
|
||||
}
|
||||
other => panic!("expected success, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
#![allow(clippy::module_inception, clippy::type_complexity)]
|
||||
|
||||
pub mod conductor;
|
||||
pub mod retry_policy;
|
||||
pub mod types;
|
||||
|
||||
pub use conductor::ProxyConductor;
|
||||
pub use retry_policy::{
|
||||
classify_failure, is_terminal, should_failover, should_refresh_auth, should_retry_same_channel,
|
||||
};
|
||||
pub use types::{
|
||||
AttemptFailure, AttemptFailureAction, AttemptResult, ExecuteAttemptContext, ExecuteInput,
|
||||
ExecuteResult, FailureInfo, FailureReason, SelectedChannel, SuccessMetrics,
|
||||
};
|
||||
@@ -1,82 +0,0 @@
|
||||
use super::types::AttemptFailureAction;
|
||||
|
||||
pub fn classify_failure(status: Option<u16>, _error_text: Option<&str>) -> AttemptFailureAction {
|
||||
match status {
|
||||
Some(429 | 503) => AttemptFailureAction::RetrySameChannel,
|
||||
Some(401) => AttemptFailureAction::RefreshAuth,
|
||||
Some(code) if (500..600).contains(&code) => AttemptFailureAction::Failover,
|
||||
Some(code) if (400..500).contains(&code) => AttemptFailureAction::Terminal,
|
||||
_ => AttemptFailureAction::Failover,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_terminal(action: AttemptFailureAction) -> bool {
|
||||
matches!(action, AttemptFailureAction::Terminal)
|
||||
}
|
||||
|
||||
pub fn should_retry_same_channel(action: AttemptFailureAction) -> bool {
|
||||
matches!(action, AttemptFailureAction::RetrySameChannel)
|
||||
}
|
||||
|
||||
pub fn should_refresh_auth(action: AttemptFailureAction) -> bool {
|
||||
matches!(action, AttemptFailureAction::RefreshAuth)
|
||||
}
|
||||
|
||||
pub fn should_failover(action: AttemptFailureAction) -> bool {
|
||||
matches!(action, AttemptFailureAction::Failover)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn classifies_retryable_rate_limit_and_unavailable() {
|
||||
assert_eq!(
|
||||
classify_failure(Some(429), None),
|
||||
AttemptFailureAction::RetrySameChannel
|
||||
);
|
||||
assert_eq!(
|
||||
classify_failure(Some(503), Some("busy")),
|
||||
AttemptFailureAction::RetrySameChannel
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_auth_server_and_terminal_failures() {
|
||||
assert_eq!(
|
||||
classify_failure(Some(401), None),
|
||||
AttemptFailureAction::RefreshAuth
|
||||
);
|
||||
assert_eq!(
|
||||
classify_failure(Some(500), None),
|
||||
AttemptFailureAction::Failover
|
||||
);
|
||||
assert_eq!(
|
||||
classify_failure(Some(502), None),
|
||||
AttemptFailureAction::Failover
|
||||
);
|
||||
assert_eq!(
|
||||
classify_failure(Some(400), None),
|
||||
AttemptFailureAction::Terminal
|
||||
);
|
||||
assert_eq!(
|
||||
classify_failure(Some(404), None),
|
||||
AttemptFailureAction::Terminal
|
||||
);
|
||||
assert_eq!(
|
||||
classify_failure(None, Some("timeout")),
|
||||
AttemptFailureAction::Failover
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helper_predicates_match_actions() {
|
||||
assert!(is_terminal(AttemptFailureAction::Terminal));
|
||||
assert!(should_retry_same_channel(
|
||||
AttemptFailureAction::RetrySameChannel
|
||||
));
|
||||
assert!(should_refresh_auth(AttemptFailureAction::RefreshAuth));
|
||||
assert!(should_failover(AttemptFailureAction::Failover));
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
#![allow(clippy::type_complexity)]
|
||||
|
||||
use futures::future::BoxFuture;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SelectedChannel {
|
||||
pub channel_id: i64,
|
||||
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,
|
||||
pub actual_model: String,
|
||||
pub token_name: Option<String>,
|
||||
pub token_value: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AttemptFailureAction {
|
||||
RetrySameChannel,
|
||||
RefreshAuth,
|
||||
Failover,
|
||||
Terminal,
|
||||
Stop,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum AttemptResult {
|
||||
Success {
|
||||
response: Value,
|
||||
latency_ms: Option<u64>,
|
||||
cost: Option<f64>,
|
||||
},
|
||||
Failure {
|
||||
action: AttemptFailureAction,
|
||||
status: Option<u16>,
|
||||
raw_error_text: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct ExecuteInput {
|
||||
pub requested_model: String,
|
||||
pub downstream_policy: Option<Value>,
|
||||
pub attempt_fn: Box<dyn Fn(ExecuteAttemptContext) -> BoxFuture<'static, AttemptResult> + Send>,
|
||||
pub on_terminal_failure: Option<
|
||||
Box<dyn Fn(SelectedChannel, AttemptFailure) -> BoxFuture<'static, ()> + Send + Sync>,
|
||||
>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExecuteAttemptContext {
|
||||
pub selected: SelectedChannel,
|
||||
pub attempt_index: usize,
|
||||
pub exclude_channel_ids: Vec<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ExecuteResult {
|
||||
Success {
|
||||
selected: SelectedChannel,
|
||||
response: Value,
|
||||
attempts: usize,
|
||||
},
|
||||
Failure {
|
||||
reason: FailureReason,
|
||||
selected: Option<SelectedChannel>,
|
||||
status: Option<u16>,
|
||||
raw_error_text: Option<String>,
|
||||
attempts: usize,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FailureReason {
|
||||
NoChannel,
|
||||
Failed,
|
||||
Terminal,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct SuccessMetrics {
|
||||
pub latency_ms: Option<u64>,
|
||||
pub cost: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub metadata: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FailureInfo {
|
||||
pub action: String,
|
||||
pub status: Option<u16>,
|
||||
pub raw_error_text: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AttemptFailure {
|
||||
pub status: Option<u16>,
|
||||
pub raw_error_text: Option<String>,
|
||||
pub action: String,
|
||||
}
|
||||
|
||||
impl From<&FailureInfo> for AttemptFailure {
|
||||
fn from(value: &FailureInfo) -> Self {
|
||||
Self {
|
||||
status: value.status,
|
||||
raw_error_text: value.raw_error_text.clone(),
|
||||
action: value.action.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AttemptFailureAction {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::RetrySameChannel => "retry_same_channel",
|
||||
Self::RefreshAuth => "refresh_auth",
|
||||
Self::Failover => "failover",
|
||||
Self::Terminal => "terminal",
|
||||
Self::Stop => "stop",
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,15 +0,0 @@
|
||||
// Proxy core — OpenAI-compatible request routing
|
||||
//
|
||||
// Architecture:
|
||||
// /v1/chat/completions → auth (downstream key) → route resolution → upstream call → response
|
||||
// /v1/images/generations → ...same flow...
|
||||
// /v1/audio/speech → ...same flow...
|
||||
|
||||
pub mod capabilities;
|
||||
pub mod cli_profiles;
|
||||
pub mod conductor;
|
||||
pub mod executors;
|
||||
pub mod providers;
|
||||
pub mod runtime;
|
||||
pub mod surfaces;
|
||||
pub mod token_router;
|
||||
@@ -1,6 +0,0 @@
|
||||
// Provider profiles & registry
|
||||
pub mod registry;
|
||||
pub mod types;
|
||||
|
||||
pub use registry::ProviderRegistry;
|
||||
pub use types::{ProviderCategory, ProviderProfile, RouteResolution};
|
||||
@@ -1,134 +0,0 @@
|
||||
// Provider registry — holds all configured upstream provider profiles
|
||||
use crate::proxy::providers::types::{ProviderCategory, ProviderProfile, RouteResolution};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Registry of all available provider profiles
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProviderRegistry {
|
||||
providers: HashMap<String, ProviderProfile>,
|
||||
}
|
||||
|
||||
impl ProviderRegistry {
|
||||
/// Create a new registry with default providers
|
||||
pub fn new() -> Self {
|
||||
let mut registry = Self {
|
||||
providers: HashMap::new(),
|
||||
};
|
||||
registry.register_defaults();
|
||||
registry
|
||||
}
|
||||
|
||||
/// Register the built-in default providers
|
||||
fn register_defaults(&mut self) {
|
||||
// OpenAI
|
||||
self.register(ProviderProfile::openai_compatible(
|
||||
"openai",
|
||||
"OpenAI",
|
||||
"https://api.openai.com/v1",
|
||||
));
|
||||
// Anthropic
|
||||
self.register(ProviderProfile::anthropic(
|
||||
"anthropic",
|
||||
"Anthropic",
|
||||
"https://api.anthropic.com/v1",
|
||||
));
|
||||
// Google Gemini
|
||||
self.register(ProviderProfile::gemini(
|
||||
"gemini",
|
||||
"Google Gemini",
|
||||
"https://generativelanguage.googleapis.com/v1beta",
|
||||
));
|
||||
// Codex
|
||||
self.register(ProviderProfile {
|
||||
id: "codex".into(),
|
||||
name: "OpenAI Codex".into(),
|
||||
category: ProviderCategory::Codex,
|
||||
base_url: "https://api.openai.com/v1".into(),
|
||||
default_model: Some("codex".into()),
|
||||
requires_api_key: true,
|
||||
supports_streaming: true,
|
||||
headers: HashMap::new(),
|
||||
});
|
||||
// Claude Code
|
||||
self.register(ProviderProfile {
|
||||
id: "claude".into(),
|
||||
name: "Claude Code".into(),
|
||||
category: ProviderCategory::Claude,
|
||||
base_url: "https://api.anthropic.com/v1".into(),
|
||||
default_model: Some("claude-sonnet-4".into()),
|
||||
requires_api_key: true,
|
||||
supports_streaming: true,
|
||||
headers: HashMap::new(),
|
||||
});
|
||||
// Gemini CLI
|
||||
self.register(ProviderProfile {
|
||||
id: "gemini_cli".into(),
|
||||
name: "Gemini CLI".into(),
|
||||
category: ProviderCategory::GeminiCli,
|
||||
base_url: "https://generativelanguage.googleapis.com/v1beta".into(),
|
||||
default_model: Some("gemini-2.0-flash".into()),
|
||||
requires_api_key: true,
|
||||
supports_streaming: true,
|
||||
headers: HashMap::new(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Register a new provider profile
|
||||
pub fn register(&mut self, profile: ProviderProfile) {
|
||||
self.providers.insert(profile.id.clone(), profile);
|
||||
}
|
||||
|
||||
/// Get a provider by ID
|
||||
pub fn get(&self, id: &str) -> Option<&ProviderProfile> {
|
||||
self.providers.get(id)
|
||||
}
|
||||
|
||||
/// List all registered providers
|
||||
pub fn list(&self) -> Vec<&ProviderProfile> {
|
||||
self.providers.values().collect()
|
||||
}
|
||||
|
||||
/// Remove a provider by ID
|
||||
pub fn remove(&mut self, id: &str) -> Option<ProviderProfile> {
|
||||
self.providers.remove(id)
|
||||
}
|
||||
|
||||
/// Resolve a model name to an upstream route.
|
||||
/// Tries to match the model to a provider category, falls back to first OpenAI-compatible provider.
|
||||
pub fn resolve(&self, model: &str, api_key: &str) -> Option<RouteResolution> {
|
||||
// First try to find a provider that supports this model
|
||||
for provider in self.providers.values() {
|
||||
if provider.supports_model(model) {
|
||||
let base_url = provider.base_url.clone();
|
||||
return Some(RouteResolution {
|
||||
provider: provider.clone(),
|
||||
api_key: api_key.to_string(),
|
||||
model: model.to_string(),
|
||||
base_url,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to the first OpenAI-compatible provider
|
||||
self.providers
|
||||
.values()
|
||||
.find(|p| {
|
||||
matches!(
|
||||
p.category,
|
||||
ProviderCategory::OpenAi | ProviderCategory::GenericOpenAi
|
||||
)
|
||||
})
|
||||
.map(|provider| RouteResolution {
|
||||
provider: provider.clone(),
|
||||
api_key: api_key.to_string(),
|
||||
model: model.to_string(),
|
||||
base_url: provider.base_url.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ProviderRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
// Provider profile types — upstream API provider definitions
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Provider category — determines request transformation and routing behavior
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum ProviderCategory {
|
||||
/// OpenAI-compatible chat/completions API
|
||||
OpenAi,
|
||||
/// Anthropic Messages API
|
||||
Anthropic,
|
||||
/// Google Gemini API
|
||||
Gemini,
|
||||
/// Codex CLI / Responses API
|
||||
Codex,
|
||||
/// Claude Code CLI
|
||||
Claude,
|
||||
/// Gemini CLI
|
||||
GeminiCli,
|
||||
/// Antigravity / assistant-style API
|
||||
Antigravity,
|
||||
/// Generic OpenAI-compatible proxy (new-api, one-api, etc.)
|
||||
GenericOpenAi,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ProviderCategory {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::OpenAi => write!(f, "openai"),
|
||||
Self::Anthropic => write!(f, "anthropic"),
|
||||
Self::Gemini => write!(f, "gemini"),
|
||||
Self::Codex => write!(f, "codex"),
|
||||
Self::Claude => write!(f, "claude"),
|
||||
Self::GeminiCli => write!(f, "gemini_cli"),
|
||||
Self::Antigravity => write!(f, "antigravity"),
|
||||
Self::GenericOpenAi => write!(f, "generic_openai"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider configuration profile
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProviderProfile {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub category: ProviderCategory,
|
||||
pub base_url: String,
|
||||
pub default_model: Option<String>,
|
||||
pub requires_api_key: bool,
|
||||
pub supports_streaming: bool,
|
||||
pub headers: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl ProviderProfile {
|
||||
/// Create a standard OpenAI-compatible provider
|
||||
pub fn openai_compatible(id: &str, name: &str, base_url: &str) -> Self {
|
||||
let mut headers = HashMap::new();
|
||||
headers.insert("Content-Type".into(), "application/json".into());
|
||||
Self {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
category: ProviderCategory::GenericOpenAi,
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
default_model: None,
|
||||
requires_api_key: true,
|
||||
supports_streaming: true,
|
||||
headers,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an Anthropic Messages API provider
|
||||
pub fn anthropic(id: &str, name: &str, base_url: &str) -> Self {
|
||||
let mut headers = HashMap::new();
|
||||
headers.insert("Content-Type".into(), "application/json".into());
|
||||
headers.insert("anthropic-version".into(), "2023-06-01".into());
|
||||
Self {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
category: ProviderCategory::Anthropic,
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
default_model: None,
|
||||
requires_api_key: true,
|
||||
supports_streaming: true,
|
||||
headers,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a Gemini API provider
|
||||
pub fn gemini(id: &str, name: &str, base_url: &str) -> Self {
|
||||
let mut headers = HashMap::new();
|
||||
headers.insert("Content-Type".into(), "application/json".into());
|
||||
Self {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
category: ProviderCategory::Gemini,
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
default_model: None,
|
||||
requires_api_key: true,
|
||||
supports_streaming: true,
|
||||
headers,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this provider handles the given model
|
||||
pub fn supports_model(&self, model: &str) -> bool {
|
||||
let model_lower = model.to_lowercase();
|
||||
match self.category {
|
||||
ProviderCategory::OpenAi | ProviderCategory::GenericOpenAi => {
|
||||
model_lower.contains("gpt")
|
||||
|| model_lower.contains("o1")
|
||||
|| model_lower.contains("o3")
|
||||
|| model_lower.contains("dall-e")
|
||||
}
|
||||
ProviderCategory::Anthropic => model_lower.contains("claude"),
|
||||
ProviderCategory::Gemini => model_lower.contains("gemini"),
|
||||
ProviderCategory::Codex => model_lower.contains("codex"),
|
||||
ProviderCategory::Claude => {
|
||||
model_lower.contains("claude") && model_lower.contains("code")
|
||||
}
|
||||
ProviderCategory::GeminiCli => {
|
||||
model_lower.contains("gemini") && model_lower.contains("cli")
|
||||
}
|
||||
ProviderCategory::Antigravity => {
|
||||
model_lower.contains("antigravity") || model_lower.contains("assistant")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of resolving a model to an upstream route
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RouteResolution {
|
||||
pub provider: ProviderProfile,
|
||||
pub api_key: String,
|
||||
pub model: String,
|
||||
pub base_url: String,
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
use crate::proxy::conductor::{FailureInfo, ProxyConductor, SelectedChannel, SuccessMetrics};
|
||||
use crate::proxy::token_router::TokenRouter;
|
||||
use futures::FutureExt;
|
||||
use sqlx::SqlitePool;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ProxyRuntime {
|
||||
pub router: TokenRouter,
|
||||
}
|
||||
|
||||
impl ProxyRuntime {
|
||||
pub fn new(db: Arc<SqlitePool>) -> Self {
|
||||
Self {
|
||||
router: TokenRouter::new(db),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn conductor(&self) -> ProxyConductor {
|
||||
let router = self.router.clone();
|
||||
let next_router = self.router.clone();
|
||||
let success_router = self.router.clone();
|
||||
let failure_router = self.router.clone();
|
||||
|
||||
ProxyConductor {
|
||||
select_channel: Arc::new(move |model, _policy| {
|
||||
let router = router.clone();
|
||||
async move { router.resolve_selected(&model).await.ok() }.boxed()
|
||||
}),
|
||||
select_next_channel: Arc::new(move |model, excluded, _policy| {
|
||||
let router = next_router.clone();
|
||||
async move { router.resolve_next_selected(&model, &excluded).await.ok() }.boxed()
|
||||
}),
|
||||
record_success: Arc::new(move |channel_id: i64, metrics: SuccessMetrics| {
|
||||
let router = success_router.clone();
|
||||
async move {
|
||||
let _ = router.record_success(channel_id, metrics).await;
|
||||
}
|
||||
.boxed()
|
||||
}),
|
||||
record_failure: Arc::new(move |channel_id: i64, info: FailureInfo| {
|
||||
let router = failure_router.clone();
|
||||
async move {
|
||||
let _ = router.record_failure(channel_id, info).await;
|
||||
}
|
||||
.boxed()
|
||||
}),
|
||||
refresh_auth: Some(Arc::new(|selected: SelectedChannel, _info: FailureInfo| {
|
||||
async move { Some(selected) }.boxed()
|
||||
})),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ProxySurfaceState {
|
||||
sessions: Arc<Mutex<HashMap<String, SurfaceSession>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SurfaceSession {
|
||||
pub id: String,
|
||||
pub model: String,
|
||||
pub channel_id: Option<i64>,
|
||||
pub metadata: Value,
|
||||
}
|
||||
|
||||
impl ProxySurfaceState {
|
||||
pub fn upsert_session(&self, session: SurfaceSession) {
|
||||
self.sessions.lock().insert(session.id.clone(), session);
|
||||
}
|
||||
|
||||
pub fn get_session(&self, id: &str) -> Option<SurfaceSession> {
|
||||
self.sessions.lock().get(id).cloned()
|
||||
}
|
||||
|
||||
pub fn remove_session(&self, id: &str) -> Option<SurfaceSession> {
|
||||
self.sessions.lock().remove(id)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
pub mod router;
|
||||
pub mod types;
|
||||
|
||||
pub use router::TokenRouter;
|
||||
pub use types::{RouteDecision, RouteMatch};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +0,0 @@
|
||||
use crate::proxy::conductor::SelectedChannel;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RouteMatch {
|
||||
Exact,
|
||||
Wildcard,
|
||||
Regex,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RouteDecision {
|
||||
pub requested_model: String,
|
||||
pub matched_pattern: String,
|
||||
pub match_kind: RouteMatch,
|
||||
pub selected: SelectedChannel,
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user