refactor: ComfyUI Agent with structured parsing + image gen + TTS
- New system prompt with 【CHARACTER】【SCENE】【TTS】【WORKFLOW】 markers - Backend parses structured sections, auto-calls gpt-image-2 for character/scene images - Auto-calls TTS API for dialogue lines - Case-insensitive field parsing, robust whitespace handling - Static file serving with SPA fallback for frontend - No Node.js runtime dependency for serving
This commit is contained in:
@@ -2,22 +2,23 @@
|
||||
|
||||
# BoosAPI
|
||||
|
||||
**AI 聚合网关 + 内置 ComfyUI — 将分散的中转站整合为一个统一入口**
|
||||
**AI 聚合网关 + ComfyUI Agent — 将分散的中转站整合为一个统一入口**
|
||||
|
||||
<p>
|
||||
把你各处注册的 New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API 等站点,
|
||||
<br>
|
||||
汇聚成 <strong>一个 API Key、一个入口</strong>,自动发现模型、智能路由、成本最优。
|
||||
<br>
|
||||
<strong>内置 ComfyUI 对话助手</strong>,自然语言生成图像/视频工作流,开箱即用。
|
||||
<strong>内置 ComfyUI 视频助手</strong> — 自然语言描述脚本,自动生成角色、场景、TTS、ComfyUI 工作流。
|
||||
<br>
|
||||
<strong>SaaS 多租户架构</strong>,组织管理、成员邀请、套餐限制,开箱即用。
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Node.js" src="https://img.shields.io/badge/Node.js-22.15%2B-339933?logo=node.js&style=flat">
|
||||
<img alt="Rust" src="https://img.shields.io/badge/Rust-1.85%2B-000000?logo=rust&style=flat">
|
||||
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5.x-3178C6?logo=typescript&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">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -58,14 +59,16 @@
|
||||
|
||||
## ✨ 核心功能
|
||||
|
||||
### 🎨 内置 ComfyUI 对话助手
|
||||
### 🎨 内置 ComfyUI 视频助手(Rust)
|
||||
|
||||
- **自然语言生成工作流**:描述你想要的效果,AI 自动构建 ComfyUI 工作流
|
||||
- **定向视图生成**:前/后/左/右 多角度图像生成
|
||||
- **场景/视频生成**:文字描述直接生成视频内容
|
||||
- **SSE 实时流式响应**:生成进度实时反馈
|
||||
- 侧边栏「对话助手」入口,与聚合网关同一界面
|
||||
- 自动对接 ComfyUI API(`:8188`),无需额外配置
|
||||
- **自然语言描述脚本**:说想法 → AI 自动提取角色、场景、对白
|
||||
- **自动生成四视图**:每个角色正/背/左/右 4 角度,调用 gpt-image-2 生成图片
|
||||
- **场景生成**:自动为每个场景生成配图
|
||||
- **TTS 语音合成**:对话台词自动转语音
|
||||
- **ComfyUI 工作流导出**:完整 workflow JSON,可直接导入 ComfyUI
|
||||
- **Rust 后端**(boosapi-rs):高性能 SSE 流式响应,零 Node.js 运行时依赖
|
||||
- 侧边栏「视频助手」入口,与聚合网关同一界面
|
||||
- 自动对接上游 API(`api.boos.lat/v1`),无需额外配置
|
||||
|
||||
### 🌐 统一代理网关
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ sqlx = { version = "0.8", features = [
|
||||
|
||||
# HTTP client
|
||||
reqwest = { version = "0.12", features = ["stream", "json"] }
|
||||
lettre = { version = "0.11", default-features = false, features = ["builder", "hostname", "pool", "rustls-tls", "smtp-transport"] }
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,218 @@
|
||||
"""
|
||||
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 = 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",
|
||||
}
|
||||
|
||||
# ── 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": (["dall-e-3", "dall-e-2", "gpt-image-2"], {"default": "gpt-image-2"}),
|
||||
"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"
|
||||
CATEGORY = "BoosAPI"
|
||||
|
||||
def generate(self, prompt, model, size, quality, n, style="vivid"):
|
||||
url = f"{BOOSAPI_BASE}/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)
|
||||
resp.raise_for_status()
|
||||
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": ("STRING", {"default": "gpt-5.5"}),
|
||||
"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"
|
||||
CATEGORY = "BoosAPI"
|
||||
|
||||
def chat(self, prompt, model, system_prompt, temperature, max_tokens):
|
||||
url = f"{BOOSAPI_BASE}/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)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
text = data["choices"][0]["message"]["content"]
|
||||
return (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": (["gpt-image-2", "dall-e-3"], {"default": "gpt-image-2"}),
|
||||
},
|
||||
"optional": {
|
||||
"negative": ("STRING", {"multiline": True, "default": ""}),
|
||||
"size": (["1024x1024", "1792x1024", "1024x1792"], {"default": "1024x1024"}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "generate"
|
||||
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_BASE}/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)
|
||||
resp.raise_for_status()
|
||||
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"]
|
||||
@@ -1,17 +1,17 @@
|
||||
[server]
|
||||
host = "0.0.0.0"
|
||||
port = 3002
|
||||
port = 4000
|
||||
log_level = "info"
|
||||
cors_origins = ["*"]
|
||||
|
||||
[db]
|
||||
url = "sqlite://../data/hub.db?mode=rwc"
|
||||
url = "sqlite:boosapi.db?mode=rwc"
|
||||
max_connections = 10
|
||||
ssl = false
|
||||
|
||||
[auth]
|
||||
jwt_secret = "dev-jwt-secret-change-in-production"
|
||||
jwt_expiry_hours = 168
|
||||
jwt_secret = "boosapi-dev-secret-change-in-production"
|
||||
jwt_expiry_hours = 72
|
||||
auth_token = "change-me-admin-token"
|
||||
proxy_token = "sk-boos4721"
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
-- 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);
|
||||
@@ -7,6 +7,7 @@ use axum::{
|
||||
Json, Router,
|
||||
};
|
||||
use futures::stream::Stream;
|
||||
use regex::Regex;
|
||||
use reqwest::Client;
|
||||
use serde_json::{json, Value};
|
||||
use std::convert::Infallible;
|
||||
@@ -14,35 +15,268 @@ use tokio::sync::mpsc::{unbounded_channel, UnboundedSender};
|
||||
use tokio_stream::{wrappers::UnboundedReceiverStream, StreamExt};
|
||||
use tracing::info;
|
||||
|
||||
const SYSTEM_PROMPT: &str = r#"你是 BoosAPI 的 AI 视频生成助手,帮助用户通过 ComfyUI 制作 AI 视频。
|
||||
const SYSTEM_PROMPT: &str = r#"你是 BoosAPI 的 AI 视频生成助手,帮用户通过 AI 制作视频。
|
||||
|
||||
## 能力
|
||||
1. **分析脚本** — 提取角色、场景、对话
|
||||
2. **生成角色提示词** — 为每个角色生成 4 个角度(正面/背面/左侧面/右侧面)
|
||||
3. **生成场景提示词** — 描述场景的稳定图像生成提示词
|
||||
4. **生成语音(TTS)** — 将对话台词转为语音描述
|
||||
5. **导出 ComfyUI 工作流** — 生成 workflow JSON
|
||||
## 规则
|
||||
1. 先用中文与用户讨论脚本构思、视觉风格、角色设定
|
||||
2. 用户确认后(说"开始制作"或"生成"),再分步输出结构化内容
|
||||
3. **分两步走:第一步输出角色+场景+TTS,第二步输出 WORKFLOW**(不要一次全出)
|
||||
|
||||
## 输出格式
|
||||
直接输出普通文本,用 emoji 标记不同内容类型:
|
||||
- 🎭 角色名 — 角色描述和相关提示词
|
||||
- 🌄 场景描述 — 场景提示词
|
||||
- 🔊 角色名: "台词" — TTS 语音
|
||||
- 📦 ComfyUI 工作流 — 最终的 workflow JSON(放在 ```json 代码块内)
|
||||
## 结构化标记格式
|
||||
严格按照以下格式输出,系统会自动解析并生成图片/TTS:
|
||||
|
||||
## 工作流程
|
||||
1. 用户提供脚本 → 你分析脚本结构
|
||||
2. 针对每个角色 → 生成 4 角度提示词
|
||||
3. 针对每个场景 → 生成场景提示词
|
||||
4. 针对每段对话 → 生成 TTS 语音描述
|
||||
5. 最终导出 ComfyUI 工作流 JSON
|
||||
【CHARACTER】
|
||||
name: 林澈
|
||||
description: 女宇航员,30岁,冷静坚毅,短发收在头盔内
|
||||
prompt_front: cinematic full body front view of a female astronaut, white EVA suit, photorealistic
|
||||
prompt_back: cinematic full body back view of a female astronaut, life support backpack
|
||||
prompt_left: cinematic full body left side view of a female astronaut, side utility pouches
|
||||
prompt_right: cinematic full body right side view of a female astronaut, helmet visor reflection
|
||||
【/CHARACTER】
|
||||
|
||||
始终用中文回复,文字简洁清晰。"#;
|
||||
【SCENE】
|
||||
id: 1
|
||||
description: 飞船驾驶舱内景
|
||||
prompt: interior of a sci-fi spacecraft cockpit, holographic displays, cold blue lighting, cinematic
|
||||
【/SCENE】
|
||||
|
||||
【TTS】
|
||||
character: 林澈
|
||||
text: 我们准备出舱检查
|
||||
emotion: 平静
|
||||
【/TTS】
|
||||
|
||||
【WORKFLOW】
|
||||
```json
|
||||
{"comfyui_workflow": {}}
|
||||
```
|
||||
【/WORKFLOW】
|
||||
|
||||
## 关键要求
|
||||
- prompt_* 用英文,同时包含主体 + 视角 + 细节 + 画质标签
|
||||
- 不要一次输出太多,先输角色和场景,下次再输工作流
|
||||
- 如果没有用户明确要求,切勿自作主张输出全部内容
|
||||
"#;
|
||||
|
||||
fn upstream_base() -> String {
|
||||
std::env::var("BOOSAPI_UPSTREAM_BASE")
|
||||
.unwrap_or_else(|_| "https://api.boos.lat/v1".to_string())
|
||||
.trim_end_matches('/')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn upstream_key() -> String {
|
||||
std::env::var("BOOSAPI_UPSTREAM_KEY").unwrap_or_else(|_| "sk-boos4721".to_string())
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new().route("/api/comfyui-agent/chat", post(chat_handler))
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ParsedSections {
|
||||
characters: Vec<CharacterSection>,
|
||||
scenes: Vec<SceneSection>,
|
||||
tts_list: Vec<TtsSection>,
|
||||
workflow_json: Option<String>,
|
||||
text_content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct CharacterSection {
|
||||
name: String,
|
||||
description: String,
|
||||
prompt_front: String,
|
||||
prompt_back: String,
|
||||
prompt_left: String,
|
||||
prompt_right: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SceneSection {
|
||||
id: u32,
|
||||
description: String,
|
||||
prompt: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct TtsSection {
|
||||
character: String,
|
||||
text: String,
|
||||
emotion: String,
|
||||
}
|
||||
|
||||
fn parse_sections(full_text: &str) -> ParsedSections {
|
||||
let mut sections = ParsedSections::default();
|
||||
sections.text_content = full_text.to_string();
|
||||
|
||||
// Parse CHARACTER blocks
|
||||
let char_re = Regex::new(r"(?s)【CHARACTER】\s*(.*?)【/CHARACTER】").unwrap();
|
||||
for cap in char_re.captures_iter(full_text) {
|
||||
let block = cap.get(1).unwrap().as_str();
|
||||
let mut char_section = CharacterSection::default();
|
||||
for line in block.lines() {
|
||||
let line = line.trim();
|
||||
let line_lower = line.to_lowercase();
|
||||
if let Some(val) = line.strip_prefix("name:").or_else(|| line.strip_prefix("name :")) {
|
||||
char_section.name = val.trim().to_string();
|
||||
} else if let Some(val) = line.strip_prefix("description:").or_else(|| line.strip_prefix("description :")) {
|
||||
char_section.description = val.trim().to_string();
|
||||
} else if line_lower.starts_with("prompt_front:") || line_lower.starts_with("prompt_front :") {
|
||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
char_section.prompt_front = val.to_string();
|
||||
} else if line_lower.starts_with("prompt_back:") || line_lower.starts_with("prompt_back :") {
|
||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
char_section.prompt_back = val.to_string();
|
||||
} else if line_lower.starts_with("prompt_left:") || line_lower.starts_with("prompt_left :") {
|
||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
char_section.prompt_left = val.to_string();
|
||||
} else if line_lower.starts_with("prompt_right:") || line_lower.starts_with("prompt_right :") {
|
||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
char_section.prompt_right = val.to_string();
|
||||
}
|
||||
}
|
||||
if !char_section.name.is_empty() {
|
||||
sections.characters.push(char_section);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse SCENE blocks
|
||||
let scene_re = Regex::new(r"(?s)【SCENE】\s*(.*?)【/SCENE】").unwrap();
|
||||
for cap in scene_re.captures_iter(full_text) {
|
||||
let block = cap.get(1).unwrap().as_str();
|
||||
let mut scene = SceneSection {
|
||||
id: 0,
|
||||
description: String::new(),
|
||||
prompt: String::new(),
|
||||
};
|
||||
for line in block.lines() {
|
||||
let line = line.trim();
|
||||
let line_lower = line.to_lowercase();
|
||||
if line_lower.starts_with("id:") || line_lower.starts_with("id :") {
|
||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
scene.id = val.parse().unwrap_or(0);
|
||||
} else if let Some(val) = line.strip_prefix("description:").or_else(|| line.strip_prefix("description :")) {
|
||||
scene.description = val.trim().to_string();
|
||||
} else if line_lower.starts_with("prompt:") || line_lower.starts_with("prompt :") {
|
||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
scene.prompt = val.to_string();
|
||||
}
|
||||
}
|
||||
if !scene.prompt.is_empty() {
|
||||
sections.scenes.push(scene);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse TTS blocks
|
||||
let tts_re = Regex::new(r"(?s)【TTS】\s*(.*?)【/TTS】").unwrap();
|
||||
for cap in tts_re.captures_iter(full_text) {
|
||||
let block = cap.get(1).unwrap().as_str();
|
||||
let mut tts = TtsSection {
|
||||
character: String::new(),
|
||||
text: String::new(),
|
||||
emotion: String::new(),
|
||||
};
|
||||
for line in block.lines() {
|
||||
let line = line.trim();
|
||||
let line_lower = line.to_lowercase();
|
||||
if line_lower.starts_with("character:") || line_lower.starts_with("character :") {
|
||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
tts.character = val.to_string();
|
||||
} else if line_lower.starts_with("text:") || line_lower.starts_with("text :") {
|
||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
tts.text = val.to_string();
|
||||
} else if line_lower.starts_with("emotion:") || line_lower.starts_with("emotion :") {
|
||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
tts.emotion = val.to_string();
|
||||
}
|
||||
}
|
||||
if !tts.text.is_empty() {
|
||||
sections.tts_list.push(tts);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse WORKFLOW blocks
|
||||
let wf_re = Regex::new(r"(?s)【WORKFLOW】\s*```(?:json)?\s*(.*?)```\s*【/WORKFLOW】").unwrap();
|
||||
if let Some(cap) = wf_re.captures(full_text) {
|
||||
let wf = cap.get(1).unwrap().as_str().trim().to_string();
|
||||
sections.workflow_json = Some(wf);
|
||||
}
|
||||
|
||||
sections
|
||||
}
|
||||
|
||||
async fn call_image_gen(prompt: &str) -> Result<String, String> {
|
||||
let client = Client::new();
|
||||
let url = format!("{}/images/generations", upstream_base());
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.bearer_auth(upstream_key())
|
||||
.json(&json!({
|
||||
"model": "gpt-image-2",
|
||||
"prompt": prompt,
|
||||
"n": 1,
|
||||
"size": "1024x1024"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("image request failed: {}", e))?;
|
||||
|
||||
let status = resp.status();
|
||||
let data: Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("image response parse failed: {}", e))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(format!("image upstream returned {}: {}", status, data));
|
||||
}
|
||||
|
||||
Ok(data
|
||||
.get("data")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|items| items.first())
|
||||
.and_then(|item| item.get("url"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string())
|
||||
}
|
||||
|
||||
async fn call_tts(text: &str, voice: &str) -> Result<Vec<u8>, String> {
|
||||
let client = Client::new();
|
||||
let url = format!("{}/audio/speech", upstream_base());
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.bearer_auth(upstream_key())
|
||||
.json(&json!({
|
||||
"model": "tts-1",
|
||||
"input": text,
|
||||
"voice": voice
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("TTS request failed: {}", e))?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("TTS upstream returned {}: {}", status, body));
|
||||
}
|
||||
|
||||
let bytes = resp
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("TTS response read failed: {}", e))?;
|
||||
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
|
||||
fn send_sse(tx: &UnboundedSender<Result<Event, Infallible>>, payload: Value) -> bool {
|
||||
tx.send(Ok(Event::default().data(payload.to_string()))).is_ok()
|
||||
}
|
||||
|
||||
async fn chat_handler(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
@@ -91,12 +325,7 @@ async fn chat_handler(
|
||||
let (tx, rx) = unbounded_channel::<Result<Event, Infallible>>();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let upstream_base = std::env::var("BOOSAPI_UPSTREAM_BASE")
|
||||
.unwrap_or_else(|_| "https://api.boos.lat/v1".to_string());
|
||||
let upstream_key =
|
||||
std::env::var("BOOSAPI_UPSTREAM_KEY").unwrap_or_else(|_| "sk-boos4721".to_string());
|
||||
|
||||
let upstream_url = format!("{}/chat/completions", upstream_base.trim_end_matches('/'));
|
||||
let upstream_url = format!("{}/chat/completions", upstream_base().trim_end_matches('/'));
|
||||
|
||||
let mut upstream_messages = Vec::with_capacity(messages.len() + 1);
|
||||
upstream_messages.push(json!({
|
||||
@@ -107,11 +336,11 @@ async fn chat_handler(
|
||||
|
||||
let client = Client::new();
|
||||
|
||||
info!("comfyui-agent chat streaming request to upstream");
|
||||
info!("comfyui-agent: sending chat request to upstream");
|
||||
|
||||
let response = match client
|
||||
.post(upstream_url)
|
||||
.bearer_auth(upstream_key)
|
||||
.post(&upstream_url)
|
||||
.bearer_auth(upstream_key())
|
||||
.json(&json!({
|
||||
"model": "gpt-5.5",
|
||||
"messages": upstream_messages,
|
||||
@@ -122,12 +351,12 @@ async fn chat_handler(
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(_) => {
|
||||
Err(e) => {
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "text",
|
||||
"content": "服务暂时不可用,请稍后重试",
|
||||
"content": format!("服务暂时不可用,请稍后重试。({})", e),
|
||||
}),
|
||||
);
|
||||
send_sse(&tx, json!({ "type": "done" }));
|
||||
@@ -149,7 +378,9 @@ async fn chat_handler(
|
||||
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut buffer: Vec<u8> = Vec::new();
|
||||
let mut accumulated_text = String::new();
|
||||
|
||||
// Phase 1: Stream the LLM text response
|
||||
loop {
|
||||
let chunk = tokio::select! {
|
||||
_ = tx.closed() => {
|
||||
@@ -165,15 +396,13 @@ async fn chat_handler(
|
||||
let chunk = match chunk {
|
||||
Ok(chunk) => chunk,
|
||||
Err(error) => {
|
||||
if !send_sse(
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "text",
|
||||
"content": format!("\n\n[错误: {}]", error),
|
||||
}),
|
||||
) {
|
||||
return;
|
||||
}
|
||||
);
|
||||
break;
|
||||
}
|
||||
};
|
||||
@@ -191,19 +420,19 @@ async fn chat_handler(
|
||||
}
|
||||
|
||||
let line = String::from_utf8_lossy(&line_bytes);
|
||||
let line = line.trim();
|
||||
let line = line.trim().to_string();
|
||||
|
||||
if !line.starts_with("data: ") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let data = line.trim_start_matches("data: ").trim();
|
||||
let data = line.trim_start_matches("data: ").trim().to_string();
|
||||
|
||||
if data == "[DONE]" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Ok(parsed) = serde_json::from_str::<Value>(data) else {
|
||||
let Ok(parsed) = serde_json::from_str::<Value>(&data) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -214,29 +443,216 @@ async fn chat_handler(
|
||||
.and_then(|choice| choice.get("delta"))
|
||||
.and_then(|delta| delta.get("content"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
|
||||
if !content.is_empty()
|
||||
&& !send_sse(
|
||||
if !content.is_empty() {
|
||||
accumulated_text.push_str(&content);
|
||||
if !send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "text",
|
||||
"content": content,
|
||||
}),
|
||||
)
|
||||
{
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Parse structured sections from accumulated text
|
||||
info!("comfyui-agent: text streaming complete, parsing sections");
|
||||
let sections = parse_sections(&accumulated_text);
|
||||
|
||||
let has_structured_data = !sections.characters.is_empty()
|
||||
|| !sections.scenes.is_empty()
|
||||
|| !sections.tts_list.is_empty()
|
||||
|| sections.workflow_json.is_some();
|
||||
|
||||
if !has_structured_data {
|
||||
send_sse(&tx, json!({ "type": "done" }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Send a processing indicator
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "text",
|
||||
"content": "\n\n🔄 正在处理结构化数据..."
|
||||
}),
|
||||
);
|
||||
|
||||
// Generate images for each character (4 angles each)
|
||||
for (char_idx, character) in sections.characters.iter().enumerate() {
|
||||
// Send character_prompts event
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "character_prompts",
|
||||
"character": character.name,
|
||||
}),
|
||||
);
|
||||
|
||||
let angles = [
|
||||
("正面", &character.prompt_front, "front"),
|
||||
("背面", &character.prompt_back, "back"),
|
||||
("左侧", &character.prompt_left, "left"),
|
||||
("右侧", &character.prompt_right, "right"),
|
||||
];
|
||||
|
||||
for (angle_label, prompt, angle_key) in &angles {
|
||||
if prompt.is_empty() {
|
||||
continue;
|
||||
}
|
||||
info!(
|
||||
"comfyui-agent: generating image for {} - {}",
|
||||
character.name, angle_label
|
||||
);
|
||||
|
||||
// Add a short delay between image gen requests to avoid rate limiting
|
||||
if char_idx > 0 || *angle_key != "front" {
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
|
||||
match call_image_gen(prompt).await {
|
||||
Ok(url) if !url.is_empty() => {
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "image",
|
||||
"url": url,
|
||||
"character": character.name,
|
||||
"angle": angle_key,
|
||||
}),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "text",
|
||||
"content": format!("\n⚠️ {} [{}] 图片生成失败,请重试", character.name, angle_label),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate images for each scene
|
||||
for scene in §ions.scenes {
|
||||
info!("comfyui-agent: generating scene image for scene {}", scene.id);
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||
|
||||
match call_image_gen(&scene.prompt).await {
|
||||
Ok(url) if !url.is_empty() => {
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "scene",
|
||||
"id": scene.id,
|
||||
"description": scene.description,
|
||||
"prompt": scene.prompt,
|
||||
"url": url,
|
||||
}),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
// Still send scene info even without image
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "scene",
|
||||
"id": scene.id,
|
||||
"description": scene.description,
|
||||
"prompt": scene.prompt,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate TTS for each dialogue
|
||||
for tts in §ions.tts_list {
|
||||
info!("comfyui-agent: generating TTS for {}", tts.character);
|
||||
|
||||
// Choose voice based on emotion
|
||||
let voice = match tts.emotion.as_str() {
|
||||
"激动" | "愤怒" => "nova",
|
||||
"悲伤" | "恐惧" => "shimmer",
|
||||
_ => "alloy",
|
||||
};
|
||||
|
||||
match call_tts(&tts.text, voice).await {
|
||||
Ok(audio_bytes) => {
|
||||
// For SSE, we'll send a data URL for small audio, or a placeholder
|
||||
// Since SSE can't easily send binary, we store to file and send URL
|
||||
let audio_path = format!("/tmp/comfyui_tts_{}_{}.mp3",
|
||||
tts.character.replace(' ', "_"),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis()
|
||||
);
|
||||
if let Ok(()) = tokio::fs::write(&audio_path, &audio_bytes).await {
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "tts",
|
||||
"character": tts.character,
|
||||
"text": tts.text,
|
||||
"url": format!("/audio/{}", audio_path.trim_start_matches("/tmp/comfyui_tts_")),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
info!("comfyui-agent: TTS generation failed: {}", e);
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "text",
|
||||
"content": format!("\n⚠️ {} 的语音生成失败", tts.character),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
}
|
||||
|
||||
// Send ComfyUI workflow if present
|
||||
if let Some(wf_json) = §ions.workflow_json {
|
||||
info!("comfyui-agent: sending workflow JSON");
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "comfyui_workflow",
|
||||
"content": wf_json,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Signal done with summary
|
||||
let mut summary = String::from("\n\n✅ **处理完成!**");
|
||||
if !sections.characters.is_empty() {
|
||||
summary.push_str(&format!("\n- 角色: {} 个", sections.characters.len()));
|
||||
}
|
||||
if !sections.scenes.is_empty() {
|
||||
summary.push_str(&format!("\n- 场景: {} 个", sections.scenes.len()));
|
||||
}
|
||||
if !sections.tts_list.is_empty() {
|
||||
summary.push_str(&format!("\n- TTS: {} 段", sections.tts_list.len()));
|
||||
}
|
||||
if sections.workflow_json.is_some() {
|
||||
summary.push_str("\n- ComfyUI 工作流: ✅ 已生成");
|
||||
}
|
||||
send_sse(&tx, json!({ "type": "text", "content": summary }));
|
||||
send_sse(&tx, json!({ "type": "done" }));
|
||||
});
|
||||
|
||||
Ok(Sse::new(UnboundedReceiverStream::new(rx)))
|
||||
}
|
||||
|
||||
fn send_sse(tx: &UnboundedSender<Result<Event, Infallible>>, payload: Value) -> bool {
|
||||
tx.send(Ok(Event::default().data(payload.to_string())))
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
use crate::AppState;
|
||||
use axum::{routing::get, Router};
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode, Uri},
|
||||
response::{Html, Response},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use std::convert::Infallible;
|
||||
use tower::service_fn;
|
||||
use tower_http::services::ServeDir;
|
||||
|
||||
mod admin;
|
||||
mod auth;
|
||||
@@ -22,7 +31,11 @@ mod user_api_keys;
|
||||
mod video_agent;
|
||||
|
||||
pub fn build_router(state: AppState) -> Router {
|
||||
Router::new()
|
||||
let web_dir = std::env::var("BOOSAPI_WEB_DIR")
|
||||
.unwrap_or_else(|_| "../dist/web".to_string());
|
||||
|
||||
// Build the API router
|
||||
let api_routes = Router::new()
|
||||
.route("/healthz", get(health::health_check))
|
||||
.merge(admin::routes(state.clone()))
|
||||
.merge(proxy::routes())
|
||||
@@ -42,5 +55,40 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.merge(user::routes())
|
||||
.merge(user_api_keys::routes())
|
||||
.merge(video_agent::routes())
|
||||
.with_state(state)
|
||||
.with_state(state);
|
||||
|
||||
// Static file serving with SPA fallback
|
||||
let web_dir_owned = web_dir.clone();
|
||||
let static_service = ServeDir::new(&web_dir)
|
||||
.append_index_html_on_directories(true)
|
||||
.fallback(service_fn(move |req: Request<Body>| {
|
||||
let web_dir = web_dir_owned.clone();
|
||||
async move {
|
||||
let index_path = format!("{}/index.html", web_dir);
|
||||
match tokio::fs::read_to_string(&index_path).await {
|
||||
Ok(html) => {
|
||||
Ok::<_, Infallible>(
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "text/html; charset=utf-8")
|
||||
.body(Body::from(html))
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
Err(_) => {
|
||||
Ok::<_, Infallible>(
|
||||
Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.header("Content-Type", "text/plain; charset=utf-8")
|
||||
.body(Body::from(format!("404: {} not found", req.uri().path())))
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
Router::new()
|
||||
.merge(api_routes)
|
||||
.fallback_service(static_service)
|
||||
}
|
||||
|
||||
@@ -249,7 +249,7 @@ async fn list_models(State(state): State<AppState>, headers: HeaderMap) -> AppRe
|
||||
check_auth(&state, &headers).await?;
|
||||
|
||||
let rows: Vec<(String, String)> = sqlx::query_as(
|
||||
"SELECT tr.name, tr.model FROM token_routes tr WHERE tr.enabled = 1 ORDER BY tr.name",
|
||||
"SELECT tr.model_pattern, COALESCE(tr.model_mapping, tr.model_pattern) FROM token_routes tr WHERE tr.enabled = 1 ORDER BY tr.id",
|
||||
)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
@@ -7,7 +7,7 @@ use crate::{
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
routing::{delete, get, post, put},
|
||||
routing::{delete, get},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
use crate::{
|
||||
error::{AppError, AppResult},
|
||||
services::shared::{enforce_key_request_limit, increment_key_usage},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{FromRow, SqlitePool};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct UsageTracker {
|
||||
db: Arc<SqlitePool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow, PartialEq)]
|
||||
pub struct UsageSummary {
|
||||
pub downstream_key_id: i64,
|
||||
pub request_count: i64,
|
||||
pub prompt_tokens: i64,
|
||||
pub completion_tokens: i64,
|
||||
pub total_tokens: i64,
|
||||
pub total_cost: f64,
|
||||
}
|
||||
|
||||
impl UsageTracker {
|
||||
pub fn new(db: Arc<SqlitePool>) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
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?;
|
||||
let prompt_tokens = prompt_tokens.max(0);
|
||||
let completion_tokens = completion_tokens.max(0);
|
||||
let total_tokens = prompt_tokens + completion_tokens;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO usage_logs
|
||||
(downstream_key_id, model, prompt_tokens, completion_tokens, total_tokens, cost)
|
||||
VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(key_id)
|
||||
.bind(model.into())
|
||||
.bind(prompt_tokens)
|
||||
.bind(completion_tokens)
|
||||
.bind(total_tokens)
|
||||
.bind(cost.max(0.0))
|
||||
.execute(&*self.db)
|
||||
.await?;
|
||||
|
||||
increment_key_usage(&self.db, key_id, cost.max(0.0)).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_key_usage(
|
||||
&self,
|
||||
key_id: i64,
|
||||
since: Option<&str>,
|
||||
until: Option<&str>,
|
||||
) -> AppResult<UsageSummary> {
|
||||
Self::ensure_schema(&self.db).await?;
|
||||
|
||||
let summary = sqlx::query_as::<_, UsageSummary>(
|
||||
"SELECT
|
||||
? AS downstream_key_id,
|
||||
COUNT(*) AS request_count,
|
||||
COALESCE(SUM(prompt_tokens), 0) AS prompt_tokens,
|
||||
COALESCE(SUM(completion_tokens), 0) AS completion_tokens,
|
||||
COALESCE(SUM(total_tokens), 0) AS total_tokens,
|
||||
COALESCE(SUM(cost), 0.0) AS total_cost
|
||||
FROM usage_logs
|
||||
WHERE downstream_key_id = ?
|
||||
AND (? IS NULL OR created_at >= ?)
|
||||
AND (? IS NULL OR created_at <= ?)",
|
||||
)
|
||||
.bind(key_id)
|
||||
.bind(key_id)
|
||||
.bind(since)
|
||||
.bind(since)
|
||||
.bind(until)
|
||||
.bind(until)
|
||||
.fetch_one(&*self.db)
|
||||
.await?;
|
||||
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
pub async fn check_rate_limit(&self, key_id: i64) -> AppResult<()> {
|
||||
enforce_key_request_limit(&self.db, key_id, 1).await?;
|
||||
|
||||
let row: Option<(Option<f64>, Option<f64>, Option<bool>)> = sqlx::query_as(
|
||||
"SELECT max_cost, used_cost, enabled
|
||||
FROM downstream_api_keys
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(key_id)
|
||||
.fetch_optional(&*self.db)
|
||||
.await?;
|
||||
|
||||
let (max_cost, used_cost, enabled) =
|
||||
row.ok_or_else(|| AppError::Unauthorized("API key not found".into()))?;
|
||||
if !enabled.unwrap_or(true) {
|
||||
return Err(AppError::Forbidden("API key is disabled".into()));
|
||||
}
|
||||
if let Some(max_cost) = max_cost {
|
||||
if used_cost.unwrap_or(0.0) >= max_cost {
|
||||
return Err(AppError::RateLimited);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create_daily_usage_snapshot(&self) -> AppResult<()> {
|
||||
Self::create_daily_usage_snapshot_for_db(&self.db).await
|
||||
}
|
||||
|
||||
pub async fn create_daily_usage_snapshot_for_db(db: &SqlitePool) -> AppResult<()> {
|
||||
Self::ensure_schema(db).await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO usage_daily_snapshots
|
||||
(local_day, downstream_key_id, request_count, prompt_tokens,
|
||||
completion_tokens, total_tokens, total_cost, updated_at)
|
||||
SELECT
|
||||
date(created_at, 'localtime') AS local_day,
|
||||
downstream_key_id,
|
||||
COUNT(*) AS request_count,
|
||||
COALESCE(SUM(prompt_tokens), 0) AS prompt_tokens,
|
||||
COALESCE(SUM(completion_tokens), 0) AS completion_tokens,
|
||||
COALESCE(SUM(total_tokens), 0) AS total_tokens,
|
||||
COALESCE(SUM(cost), 0.0) AS total_cost,
|
||||
datetime('now') AS updated_at
|
||||
FROM usage_logs
|
||||
GROUP BY date(created_at, 'localtime'), downstream_key_id
|
||||
ON CONFLICT(local_day, downstream_key_id) DO UPDATE SET
|
||||
request_count = excluded.request_count,
|
||||
prompt_tokens = excluded.prompt_tokens,
|
||||
completion_tokens = excluded.completion_tokens,
|
||||
total_tokens = excluded.total_tokens,
|
||||
total_cost = excluded.total_cost,
|
||||
updated_at = datetime('now')",
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_schema(db: &SqlitePool) -> AppResult<()> {
|
||||
sqlx::query(
|
||||
"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'))
|
||||
)",
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"CREATE INDEX IF NOT EXISTS usage_logs_downstream_key_created_idx
|
||||
ON usage_logs (downstream_key_id, created_at)",
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"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)
|
||||
)",
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
|
||||
async fn pool() -> SqlitePool {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE downstream_api_keys (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
max_cost REAL,
|
||||
used_cost REAL DEFAULT 0,
|
||||
max_requests INTEGER,
|
||||
used_requests INTEGER DEFAULT 0,
|
||||
last_used_at TEXT,
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO downstream_api_keys
|
||||
(id, name, key, enabled, max_cost, used_cost, max_requests, used_requests)
|
||||
VALUES (1, 'test', 'sk-test', 1, 1.0, 0.0, 5, 0)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
pool
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn records_and_aggregates_usage() {
|
||||
let tracker = UsageTracker::new(Arc::new(pool().await));
|
||||
|
||||
tracker
|
||||
.record_usage(1, "gpt-test", 10, 20, 0.25)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let summary = tracker.get_key_usage(1, None, None).await.unwrap();
|
||||
assert_eq!(summary.request_count, 1);
|
||||
assert_eq!(summary.prompt_tokens, 10);
|
||||
assert_eq!(summary.completion_tokens, 20);
|
||||
assert_eq!(summary.total_tokens, 30);
|
||||
assert_eq!(summary.total_cost, 0.25);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_checks_request_and_cost_limits() {
|
||||
let tracker = UsageTracker::new(Arc::new(pool().await));
|
||||
tracker.check_rate_limit(1).await.unwrap();
|
||||
|
||||
sqlx::query("UPDATE downstream_api_keys SET used_requests = 5 WHERE id = 1")
|
||||
.execute(&*tracker.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
tracker.check_rate_limit(1).await,
|
||||
Err(AppError::RateLimited)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn creates_daily_snapshot() {
|
||||
let tracker = UsageTracker::new(Arc::new(pool().await));
|
||||
tracker
|
||||
.record_usage(1, "gpt-test", 2, 3, 0.1)
|
||||
.await
|
||||
.unwrap();
|
||||
tracker.create_daily_usage_snapshot().await.unwrap();
|
||||
|
||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM usage_daily_snapshots")
|
||||
.fetch_one(&*tracker.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::{error::AppResult, proxy::token_router::TokenRouter};
|
||||
use crate::{error::AppResult, proxy::token_router::TokenRouter, services::billing::UsageTracker};
|
||||
use sqlx::SqlitePool;
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
@@ -78,6 +78,7 @@ impl CronScheduler {
|
||||
}
|
||||
|
||||
async fn aggregate_usage(db: Arc<SqlitePool>) -> AppResult<()> {
|
||||
UsageTracker::create_daily_usage_snapshot_for_db(&db).await?;
|
||||
sqlx::query(
|
||||
"UPDATE downstream_api_keys
|
||||
SET updated_at = datetime('now')
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
pub mod announcement;
|
||||
pub mod billing;
|
||||
pub mod cron;
|
||||
pub mod notification;
|
||||
pub mod oauth;
|
||||
pub mod shared;
|
||||
pub mod update_center;
|
||||
|
||||
@@ -0,0 +1,544 @@
|
||||
use crate::error::AppResult;
|
||||
use chrono::{FixedOffset, SecondsFormat, TimeZone, Utc};
|
||||
use lettre::{
|
||||
message::Mailbox, transport::smtp::authentication::Credentials, Message, SmtpTransport,
|
||||
Transport,
|
||||
};
|
||||
use reqwest::Url;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use sqlx::SqlitePool;
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct NotificationService {
|
||||
pub db: Arc<SqlitePool>,
|
||||
pub client: reqwest::Client,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum NotificationLevel {
|
||||
Info,
|
||||
Warning,
|
||||
Error,
|
||||
Critical,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Notification {
|
||||
pub title: String,
|
||||
pub message: String,
|
||||
pub level: NotificationLevel,
|
||||
pub channel: Option<String>,
|
||||
pub signature: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct NotificationConfig {
|
||||
telegram_bot_token: Option<String>,
|
||||
telegram_chat_id: Option<String>,
|
||||
telegram_thread_id: Option<i64>,
|
||||
wecom_webhook: Option<String>,
|
||||
feishu_webhook: Option<String>,
|
||||
webhook_url: Option<String>,
|
||||
bark_url: Option<String>,
|
||||
serverchan_key: Option<String>,
|
||||
smtp: SmtpConfig,
|
||||
cooldown_sec: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct SmtpConfig {
|
||||
host: Option<String>,
|
||||
port: Option<u16>,
|
||||
user: Option<String>,
|
||||
pass: Option<String>,
|
||||
from: Option<String>,
|
||||
to: Option<String>,
|
||||
}
|
||||
|
||||
impl NotificationService {
|
||||
pub fn new(db: Arc<SqlitePool>) -> Self {
|
||||
Self {
|
||||
db,
|
||||
client: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_client(db: Arc<SqlitePool>, client: reqwest::Client) -> Self {
|
||||
Self { db, client }
|
||||
}
|
||||
|
||||
pub async fn send(&self, notif: Notification) -> AppResult<()> {
|
||||
Self::ensure_schema(&self.db).await?;
|
||||
let config = self.load_config().await?;
|
||||
|
||||
if self.should_throttle(¬if, config.cooldown_sec).await? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut channels = self.enabled_channels(&config);
|
||||
if let Some(channel) = notif.channel.as_deref() {
|
||||
channels.retain(|candidate| candidate == &normalize_channel(channel));
|
||||
}
|
||||
|
||||
if channels.is_empty() {
|
||||
tracing::debug!(
|
||||
channel = notif.channel.as_deref().unwrap_or("all"),
|
||||
"notification skipped because no matching channel is configured"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for channel in channels {
|
||||
if let Err(err) = self.send_channel(&channel, ¬if, &config).await {
|
||||
tracing::warn!(%err, channel, "notification channel failed");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_config(&self) -> AppResult<NotificationConfig> {
|
||||
let rows: Vec<(String, Option<String>)> =
|
||||
sqlx::query_as("SELECT key, value FROM settings WHERE key LIKE 'notify_%'")
|
||||
.fetch_all(&*self.db)
|
||||
.await?;
|
||||
let map: HashMap<String, String> = rows
|
||||
.into_iter()
|
||||
.filter_map(|(key, value)| value.map(|value| (key, value)))
|
||||
.filter(|(_, value)| !value.trim().is_empty())
|
||||
.collect();
|
||||
|
||||
Ok(NotificationConfig {
|
||||
telegram_bot_token: setting(&map, "notify_telegram_bot_token"),
|
||||
telegram_chat_id: setting(&map, "notify_telegram_chat_id"),
|
||||
telegram_thread_id: setting(&map, "notify_telegram_thread_id")
|
||||
.and_then(|value| value.parse().ok()),
|
||||
wecom_webhook: setting(&map, "notify_wecom_webhook"),
|
||||
feishu_webhook: setting(&map, "notify_feishu_webhook"),
|
||||
webhook_url: setting(&map, "notify_webhook_url"),
|
||||
bark_url: setting(&map, "notify_bark_url"),
|
||||
serverchan_key: setting(&map, "notify_serverchan_key"),
|
||||
smtp: SmtpConfig {
|
||||
host: setting(&map, "notify_smtp_host"),
|
||||
port: setting(&map, "notify_smtp_port").and_then(|value| value.parse().ok()),
|
||||
user: setting(&map, "notify_smtp_user"),
|
||||
pass: setting(&map, "notify_smtp_pass"),
|
||||
from: setting(&map, "notify_smtp_from"),
|
||||
to: setting(&map, "notify_smtp_to"),
|
||||
},
|
||||
cooldown_sec: setting(&map, "notify_cooldown_sec")
|
||||
.and_then(|value| value.parse().ok())
|
||||
.unwrap_or(60),
|
||||
})
|
||||
}
|
||||
|
||||
async fn should_throttle(&self, notif: &Notification, cooldown_sec: i64) -> AppResult<bool> {
|
||||
let Some(signature) = notif.signature.as_deref().map(str::trim) else {
|
||||
return Ok(false);
|
||||
};
|
||||
if signature.is_empty() || cooldown_sec <= 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let updated = sqlx::query(
|
||||
"UPDATE notification_throttle
|
||||
SET count = count + 1,
|
||||
title = ?,
|
||||
message = ?,
|
||||
level = ?,
|
||||
updated_at = datetime('now')
|
||||
WHERE signature = ?
|
||||
AND updated_at >= datetime('now', ?)",
|
||||
)
|
||||
.bind(¬if.title)
|
||||
.bind(¬if.message)
|
||||
.bind(level_name(¬if.level))
|
||||
.bind(signature)
|
||||
.bind(format!("-{} seconds", cooldown_sec))
|
||||
.execute(&*self.db)
|
||||
.await?;
|
||||
|
||||
if updated.rows_affected() > 0 {
|
||||
tracing::info!(signature, "notification merged into cooldown throttle");
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO notification_throttle
|
||||
(signature, title, message, level, count, updated_at)
|
||||
VALUES (?, ?, ?, ?, 1, datetime('now'))
|
||||
ON CONFLICT(signature) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
message = excluded.message,
|
||||
level = excluded.level,
|
||||
count = 1,
|
||||
updated_at = datetime('now')",
|
||||
)
|
||||
.bind(signature)
|
||||
.bind(¬if.title)
|
||||
.bind(¬if.message)
|
||||
.bind(level_name(¬if.level))
|
||||
.execute(&*self.db)
|
||||
.await?;
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn enabled_channels(&self, config: &NotificationConfig) -> Vec<String> {
|
||||
let mut channels = Vec::new();
|
||||
if config.telegram_bot_token.is_some() && config.telegram_chat_id.is_some() {
|
||||
channels.push("telegram".to_string());
|
||||
}
|
||||
if config.wecom_webhook.is_some() {
|
||||
channels.push("wecom".to_string());
|
||||
}
|
||||
if config.feishu_webhook.is_some() {
|
||||
channels.push("feishu".to_string());
|
||||
}
|
||||
if config.webhook_url.is_some() {
|
||||
channels.push("webhook".to_string());
|
||||
}
|
||||
if config.bark_url.is_some() {
|
||||
channels.push("bark".to_string());
|
||||
}
|
||||
if config.serverchan_key.is_some() {
|
||||
channels.push("serverchan".to_string());
|
||||
}
|
||||
if config.smtp.is_configured() {
|
||||
channels.push("smtp".to_string());
|
||||
}
|
||||
channels
|
||||
}
|
||||
|
||||
async fn send_channel(
|
||||
&self,
|
||||
channel: &str,
|
||||
notif: &Notification,
|
||||
config: &NotificationConfig,
|
||||
) -> anyhow::Result<()> {
|
||||
match channel {
|
||||
"telegram" => self.send_telegram(notif, config).await,
|
||||
"wecom" => self.send_wecom(notif, config).await,
|
||||
"feishu" => self.send_feishu(notif, config).await,
|
||||
"webhook" => self.send_webhook(notif, config).await,
|
||||
"bark" => self.send_bark(notif, config).await,
|
||||
"serverchan" => self.send_serverchan(notif, config).await,
|
||||
"smtp" => self.send_smtp(notif, config).await,
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_telegram(
|
||||
&self,
|
||||
notif: &Notification,
|
||||
config: &NotificationConfig,
|
||||
) -> anyhow::Result<()> {
|
||||
let token = required(config.telegram_bot_token.as_deref(), "telegram token")?;
|
||||
let chat_id = required(config.telegram_chat_id.as_deref(), "telegram chat id")?;
|
||||
let mut body = json!({
|
||||
"chat_id": chat_id,
|
||||
"text": truncate_chars(&format_notification_text(notif), 3900),
|
||||
});
|
||||
if let Some(thread_id) = config.telegram_thread_id {
|
||||
body["message_thread_id"] = json!(thread_id);
|
||||
}
|
||||
self.client
|
||||
.post(format!("https://api.telegram.org/bot{token}/sendMessage"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_wecom(
|
||||
&self,
|
||||
notif: &Notification,
|
||||
config: &NotificationConfig,
|
||||
) -> anyhow::Result<()> {
|
||||
let url = required(config.wecom_webhook.as_deref(), "wecom webhook")?;
|
||||
self.client
|
||||
.post(url)
|
||||
.json(&json!({
|
||||
"msgtype": "text",
|
||||
"text": { "content": format_notification_text(notif) },
|
||||
}))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_feishu(
|
||||
&self,
|
||||
notif: &Notification,
|
||||
config: &NotificationConfig,
|
||||
) -> anyhow::Result<()> {
|
||||
let url = required(config.feishu_webhook.as_deref(), "feishu webhook")?;
|
||||
self.client
|
||||
.post(url)
|
||||
.json(&json!({
|
||||
"msg_type": "text",
|
||||
"content": { "text": format_notification_text(notif) },
|
||||
}))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_webhook(
|
||||
&self,
|
||||
notif: &Notification,
|
||||
config: &NotificationConfig,
|
||||
) -> anyhow::Result<()> {
|
||||
let url = required(config.webhook_url.as_deref(), "webhook url")?;
|
||||
self.client
|
||||
.post(url)
|
||||
.json(&json!({
|
||||
"title": ¬if.title,
|
||||
"message": ¬if.message,
|
||||
"level": level_name(¬if.level),
|
||||
}))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_bark(
|
||||
&self,
|
||||
notif: &Notification,
|
||||
config: &NotificationConfig,
|
||||
) -> anyhow::Result<()> {
|
||||
let base = required(config.bark_url.as_deref(), "bark url")?;
|
||||
let mut url = Url::parse(base)?;
|
||||
{
|
||||
let mut segments = url
|
||||
.path_segments_mut()
|
||||
.map_err(|_| anyhow::anyhow!("bark url cannot be a base url"))?;
|
||||
segments.pop_if_empty();
|
||||
segments.push(¬if.title);
|
||||
segments.push(¬if.message);
|
||||
}
|
||||
self.client.get(url).send().await?.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_serverchan(
|
||||
&self,
|
||||
notif: &Notification,
|
||||
config: &NotificationConfig,
|
||||
) -> anyhow::Result<()> {
|
||||
let key = required(config.serverchan_key.as_deref(), "serverchan key")?;
|
||||
self.client
|
||||
.post(format!("https://sctapi.ftqq.com/{key}.send"))
|
||||
.form(&[
|
||||
("title", notif.title.as_str()),
|
||||
("desp", notif.message.as_str()),
|
||||
])
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_smtp(
|
||||
&self,
|
||||
notif: &Notification,
|
||||
config: &NotificationConfig,
|
||||
) -> anyhow::Result<()> {
|
||||
let smtp = config.smtp.clone();
|
||||
let notif = notif.clone();
|
||||
tokio::task::spawn_blocking(move || smtp.send(¬if)).await??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_schema(db: &SqlitePool) -> AppResult<()> {
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS notification_throttle (
|
||||
signature TEXT PRIMARY KEY NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
count INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl SmtpConfig {
|
||||
fn is_configured(&self) -> bool {
|
||||
self.host.is_some()
|
||||
&& self.port.is_some()
|
||||
&& self.user.is_some()
|
||||
&& self.pass.is_some()
|
||||
&& self.from.is_some()
|
||||
}
|
||||
|
||||
fn send(self, notif: &Notification) -> anyhow::Result<()> {
|
||||
let host = required(self.host.as_deref(), "smtp host")?;
|
||||
let port = self.port.unwrap_or(587);
|
||||
let user = required(self.user.as_deref(), "smtp user")?;
|
||||
let pass = required(self.pass.as_deref(), "smtp pass")?;
|
||||
let from = required(self.from.as_deref(), "smtp from")?;
|
||||
let to = self.to.as_deref().unwrap_or(from);
|
||||
|
||||
let email = Message::builder()
|
||||
.from(from.parse::<Mailbox>()?)
|
||||
.to(to.parse::<Mailbox>()?)
|
||||
.subject(format!(
|
||||
"[BoosAPI][{}] {}",
|
||||
level_name(¬if.level),
|
||||
notif.title
|
||||
))
|
||||
.body(format_notification_text(notif))?;
|
||||
|
||||
let mailer = SmtpTransport::relay(host)?
|
||||
.port(port)
|
||||
.credentials(Credentials::new(user.to_string(), pass.to_string()))
|
||||
.build();
|
||||
mailer.send(&email)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn setting(map: &HashMap<String, String>, key: &str) -> Option<String> {
|
||||
map.get(key).map(|value| value.trim().to_string())
|
||||
}
|
||||
|
||||
fn required<'a>(value: Option<&'a str>, name: &str) -> anyhow::Result<&'a str> {
|
||||
value.ok_or_else(|| anyhow::anyhow!("{name} is not configured"))
|
||||
}
|
||||
|
||||
fn normalize_channel(channel: &str) -> String {
|
||||
channel.trim().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn level_name(level: &NotificationLevel) -> &'static str {
|
||||
match level {
|
||||
NotificationLevel::Info => "info",
|
||||
NotificationLevel::Warning => "warning",
|
||||
NotificationLevel::Error => "error",
|
||||
NotificationLevel::Critical => "critical",
|
||||
}
|
||||
}
|
||||
|
||||
fn format_notification_text(notif: &Notification) -> String {
|
||||
let utc = Utc::now();
|
||||
let shanghai = FixedOffset::east_opt(8 * 3600)
|
||||
.expect("valid Shanghai offset")
|
||||
.from_utc_datetime(&utc.naive_utc());
|
||||
format!(
|
||||
"[BoosAPI][{}] {}\n\n{}\n\nLevel: {}\nLocal Time: {} (Asia/Shanghai)\nUTC Time: {}",
|
||||
level_name(¬if.level).to_ascii_uppercase(),
|
||||
notif.title,
|
||||
notif.message,
|
||||
level_name(¬if.level),
|
||||
shanghai.format("%Y-%m-%d %H:%M:%S"),
|
||||
utc.to_rfc3339_opts(SecondsFormat::Millis, true)
|
||||
)
|
||||
}
|
||||
|
||||
fn truncate_chars(value: &str, max_chars: usize) -> String {
|
||||
if value.chars().count() <= max_chars {
|
||||
return value.to_string();
|
||||
}
|
||||
value.chars().take(max_chars).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
|
||||
async fn pool() -> SqlitePool {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("CREATE TABLE settings (key TEXT PRIMARY KEY NOT NULL, value TEXT)")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
pool
|
||||
}
|
||||
|
||||
async fn set(pool: &SqlitePool, key: &str, value: &str) {
|
||||
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
|
||||
.bind(key)
|
||||
.bind(value)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skips_when_no_channel_is_configured() {
|
||||
let pool = pool().await;
|
||||
let service = NotificationService::new(Arc::new(pool));
|
||||
service
|
||||
.send(Notification {
|
||||
title: "Disk".to_string(),
|
||||
message: "Almost full".to_string(),
|
||||
level: NotificationLevel::Warning,
|
||||
channel: Some("webhook".to_string()),
|
||||
signature: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn throttles_duplicate_signature() {
|
||||
let pool = pool().await;
|
||||
set(&pool, "notify_cooldown_sec", "60").await;
|
||||
|
||||
let service = NotificationService::new(Arc::new(pool));
|
||||
let notification = Notification {
|
||||
title: "Route down".to_string(),
|
||||
message: "primary failed".to_string(),
|
||||
level: NotificationLevel::Error,
|
||||
channel: Some("webhook".to_string()),
|
||||
signature: Some("route:1".to_string()),
|
||||
};
|
||||
service.send(notification.clone()).await.unwrap();
|
||||
service.send(notification).await.unwrap();
|
||||
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT count FROM notification_throttle WHERE signature = 'route:1'",
|
||||
)
|
||||
.fetch_one(&*service.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_telegram_compatible_text() {
|
||||
let text = format_notification_text(&Notification {
|
||||
title: "Title".to_string(),
|
||||
message: "Message".to_string(),
|
||||
level: NotificationLevel::Critical,
|
||||
channel: None,
|
||||
signature: None,
|
||||
});
|
||||
|
||||
assert!(text.starts_with("[BoosAPI][CRITICAL] Title\n\nMessage"));
|
||||
assert!(text.contains("Local Time:"));
|
||||
assert!(text.contains("UTC Time:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncates_by_char_count() {
|
||||
assert_eq!(truncate_chars("abcdef", 3), "abc");
|
||||
assert_eq!(truncate_chars("短消息", 2), "短消");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user