feat: complete user login admin keys and deployment docs

This commit is contained in:
boos
2026-05-19 10:06:52 +02:00
parent 812a7c6c07
commit 4fe84707e5
16 changed files with 1384 additions and 187 deletions
+190
View File
@@ -0,0 +1,190 @@
# 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)
### 推荐: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`) |
| `web_dir` | `BOOSAPI_WEB_DIR` | 前端静态资源目录 (默认 `dist/web`) |
---
## 5. 运维操作 (Ops)
### 检查配置
在启动前验证配置文件是否正确:
```bash
./boosapi --check-config
```
### 数据库备份
BoosAPI 使用 SQLite,只需备份数据库文件即可:
```bash
cp data/boosapi.db data/boosapi.db.bak
```
### 查看日志
如果使用 Systemd:
```bash
journalctl -u boosapi -f
```
如果使用 Docker:
```bash
docker logs -f boosapi
```
+2 -1
View File
@@ -24,7 +24,8 @@
<p align="center"> <p align="center">
<a href="README.md"><strong>中文</strong></a> | <a href="README.md"><strong>中文</strong></a> |
<a href="README_EN.md">English</a> <a href="README_EN.md">English</a> |
<a href="DEPLOY.md">部署指南</a>
</p> </p>
</div> </div>
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# 编译前后端并启动服务
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT_DIR"
# ── 参数 ──────────────────────────────────────────────────────────────────────
RELEASE=0
FRONTEND_ONLY=0
BACKEND_ONLY=0
NO_RUN=0
usage() {
cat <<'EOF'
用法: ./build-and-run.sh [选项]
选项:
--release 使用 release 模式编译后端(更慢但性能好)
--frontend-only 只构建前端
--backend-only 只构建后端
--no-run 构建后不启动服务
-h, --help 显示帮助
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
--release) RELEASE=1 ;;
--frontend-only) FRONTEND_ONLY=1 ;;
--backend-only) BACKEND_ONLY=1 ;;
--no-run) NO_RUN=1 ;;
-h|--help) usage; exit 0 ;;
*) echo "未知参数: $1" >&2; usage >&2; exit 1 ;;
esac
shift
done
# ── 工具函数 ──────────────────────────────────────────────────────────────────
log() { echo "[$(date '+%H:%M:%S')] $*"; }
ok() { echo "[$(date '+%H:%M:%S')] ✓ $*"; }
fail() { echo "[$(date '+%H:%M:%S')] ✗ $*" >&2; exit 1; }
# ── 构建前端 ──────────────────────────────────────────────────────────────────
if [ "$BACKEND_ONLY" -eq 0 ]; then
log "构建前端 (Vite)..."
if ! command -v npx >/dev/null 2>&1; then
fail "未找到 npx,请先安装 Node.js"
fi
# 直接调用 vite build,跳过 desktop:icons(脚本文件不存在)
npx vite build
ok "前端构建完成 → dist/web/"
fi
# ── 构建后端 ──────────────────────────────────────────────────────────────────
if [ "$FRONTEND_ONLY" -eq 0 ]; then
if ! command -v cargo >/dev/null 2>&1; then
fail "未找到 cargo,请先安装 Rust 工具链"
fi
if [ "$RELEASE" -eq 1 ]; then
log "构建后端 (cargo build --release)..."
cargo build --release
BINARY="$ROOT_DIR/target/release/boosapi"
ok "后端构建完成 → target/release/boosapi"
else
log "构建后端 (cargo build)..."
cargo build
BINARY="$ROOT_DIR/target/debug/boosapi"
ok "后端构建完成 → target/debug/boosapi"
fi
fi
# ── 启动服务 ──────────────────────────────────────────────────────────────────
if [ "$NO_RUN" -eq 1 ] || [ "$FRONTEND_ONLY" -eq 1 ]; then
log "跳过启动(--no-run 或 --frontend-only)"
exit 0
fi
# 停止旧进程
OLD_PID=$(pgrep -f "target/(debug|release)/boosapi" || true)
if [ -n "$OLD_PID" ]; then
log "停止旧进程 (PID $OLD_PID)..."
kill "$OLD_PID" 2>/dev/null || true
sleep 1
fi
log "启动服务..."
exec "$BINARY"
+5 -1
View File
@@ -93,9 +93,13 @@ impl ComfyUIConfig {
impl AppConfig { impl AppConfig {
pub fn load() -> anyhow::Result<Self> { 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(); dotenvy::dotenv().ok();
Ok(Figment::new() Ok(Figment::new()
.merge(Toml::file("config.toml")) .merge(Toml::file(path))
.merge(Env::prefixed("BOOSAPI_").split("__")) .merge(Env::prefixed("BOOSAPI_").split("__"))
.extract()?) .extract()?)
} }
+82 -6
View File
@@ -1,19 +1,76 @@
use axum::serve; use axum::serve;
use boosapi::{config::AppConfig, db, AppState}; use boosapi::{config::AppConfig, db, AppState};
use clap::Parser;
use std::ffi::OsString;
use std::net::SocketAddr; use std::net::SocketAddr;
use tokio::signal; use tokio::signal;
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
#[derive(Parser, Debug)]
#[command(
name = "boosapi",
version,
author,
about = "AI API Gateway — Rust rewrite of BoosAPI"
)]
struct Cli {
/// 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,
}
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
// Initialize tracing let args = Cli::parse_from(normalize_legacy_args());
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.init();
// Load config // Load config
let config = AppConfig::load()?; let mut config = AppConfig::load_from_file(&args.config)?;
tracing::info!("Loaded configuration");
// 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::fmt().with_env_filter(filter).init();
tracing::info!("Loaded configuration from {}", args.config);
// Create database pool // Create database pool
let pool = db::create_pool(&config.db.url, config.db.max_connections).await?; let pool = db::create_pool(&config.db.url, config.db.max_connections).await?;
@@ -23,6 +80,11 @@ async fn main() -> anyhow::Result<()> {
sqlx::migrate!("./migrations").run(&pool).await?; sqlx::migrate!("./migrations").run(&pool).await?;
tracing::info!("Database migrations applied"); tracing::info!("Database migrations applied");
if args.migrate_only {
tracing::info!("Migration only mode requested. Exiting.");
return Ok(());
}
// Build app state // Build app state
let state = AppState::new(config.clone(), pool); let state = AppState::new(config.clone(), pool);
if let Err(err) = state.comfyui.start().await { if let Err(err) = state.comfyui.start().await {
@@ -51,6 +113,20 @@ async fn main() -> anyhow::Result<()> {
Ok(()) 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() { async fn shutdown_signal() {
let ctrl_c = async { let ctrl_c = async {
signal::ctrl_c() signal::ctrl_c()
+130 -1
View File
@@ -8,7 +8,7 @@ use axum::{
http::StatusCode, http::StatusCode,
Json, Json,
}; };
use serde::Deserialize; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::time::Duration; use std::time::Duration;
use tracing::info; use tracing::info;
@@ -501,3 +501,132 @@ fn build_account_extra_config(
.map_err(|err| AppError::Internal(err.into())) .map_err(|err| AppError::Internal(err.into()))
} }
} }
// ── Account model availability ────────────────────────────────────────────────
#[derive(Debug, Serialize)]
struct ModelItem {
name: String,
#[serde(rename = "latencyMs")]
latency_ms: Option<i64>,
disabled: bool,
#[serde(rename = "isManual")]
is_manual: bool,
}
#[derive(Debug, Deserialize)]
pub struct AddManualModelsRequest {
pub models: Vec<String>,
}
/// GET /api/accounts/{id}/models
pub async fn get_account_models(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Value>> {
info!("Getting models for account: {}", id);
// Account + site info
let row = sqlx::query(
"SELECT a.id, a.site_id, s.name AS site_name
FROM accounts a
JOIN sites s ON s.id = a.site_id
WHERE a.id = ?",
)
.bind(id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound(format!("Account {} not found", id)))?;
use sqlx::Row;
let site_id: i64 = row.get("site_id");
let site_name: String = row.get("site_name");
// Available models for this account
let availability: Vec<(String, Option<i64>, bool)> = sqlx::query_as(
"SELECT model_name, latency_ms, is_manual
FROM model_availability
WHERE account_id = ? AND available = 1
ORDER BY model_name ASC",
)
.bind(id)
.fetch_all(&*state.db)
.await?;
// Disabled models for this site
let disabled_names: std::collections::HashSet<String> =
sqlx::query_scalar("SELECT model_name FROM site_disabled_models WHERE site_id = ?")
.bind(site_id)
.fetch_all(&*state.db)
.await?
.into_iter()
.collect();
let models: Vec<ModelItem> = availability
.into_iter()
.map(|(name, latency_ms, is_manual)| {
let disabled = disabled_names.contains(&name);
ModelItem {
name,
latency_ms,
disabled,
is_manual,
}
})
.collect();
let total_count = models.len();
let disabled_count = models.iter().filter(|m| m.disabled).count();
Ok(Json(json!({
"siteId": site_id,
"siteName": site_name,
"models": models,
"totalCount": total_count,
"disabledCount": disabled_count,
})))
}
/// POST /api/accounts/{id}/models/manual
pub async fn add_account_manual_models(
State(state): State<AppState>,
Path(id): Path<i64>,
Json(payload): Json<AddManualModelsRequest>,
) -> AppResult<Json<Value>> {
info!(
"Adding {} manual models for account: {}",
payload.models.len(),
id
);
// Verify account exists
sqlx::query_scalar::<_, i64>("SELECT 1 FROM accounts WHERE id = ?")
.bind(id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound(format!("Account {} not found", id)))?;
let mut inserted = 0usize;
for model_name in payload
.models
.iter()
.map(|m| m.trim())
.filter(|m| !m.is_empty())
{
sqlx::query(
"INSERT INTO model_availability (account_id, model_name, available, is_manual, checked_at)
VALUES (?, ?, 1, 1, datetime('now'))
ON CONFLICT (account_id, model_name) DO UPDATE SET
available = 1,
is_manual = 1,
checked_at = datetime('now')",
)
.bind(id)
.bind(model_name)
.execute(&*state.db)
.await?;
inserted += 1;
}
Ok(Json(json!({ "inserted": inserted })))
}
+78 -8
View File
@@ -4,7 +4,7 @@ use crate::{
AppState, AppState,
}; };
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, Query, State},
http::StatusCode, http::StatusCode,
Json, Json,
}; };
@@ -13,6 +13,12 @@ use serde_json::{json, Value};
use tracing::info; use tracing::info;
use uuid::Uuid; use uuid::Uuid;
#[derive(Debug, Deserialize)]
pub struct ListKeysQuery {
pub q: Option<String>,
pub user_id: Option<i64>,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct CreateDownstreamKeyRequest { pub struct CreateDownstreamKeyRequest {
pub name: String, pub name: String,
@@ -25,6 +31,7 @@ pub struct CreateDownstreamKeyRequest {
pub max_requests: Option<i64>, pub max_requests: Option<i64>,
pub supported_models: Option<Vec<String>>, pub supported_models: Option<Vec<String>>,
pub allowed_route_ids: Option<String>, pub allowed_route_ids: Option<String>,
pub user_id: Option<i64>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -39,6 +46,7 @@ pub struct UpdateDownstreamKeyRequest {
pub max_requests: Option<i64>, pub max_requests: Option<i64>,
pub supported_models: Option<Vec<String>>, pub supported_models: Option<Vec<String>>,
pub allowed_route_ids: Option<String>, pub allowed_route_ids: Option<String>,
pub user_id: Option<i64>,
} }
/// Helper to validate models from a JSON string against a Vec<String> /// Helper to validate models from a JSON string against a Vec<String>
@@ -54,11 +62,30 @@ fn validate_supported_models(models: &[String]) -> AppResult<()> {
} }
/// GET /api/downstream-api-keys /// GET /api/downstream-api-keys
pub async fn list_downstream_keys(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn list_downstream_keys(
info!("Listing downstream API keys"); State(state): State<AppState>,
Query(query): Query<ListKeysQuery>,
) -> AppResult<Json<Value>> {
info!("Listing downstream API keys with filter: {:?}", query);
let keys: Vec<DownstreamApiKey> = let q = query.q.unwrap_or_default().trim().to_string();
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys ORDER BY name") let q_like = format!("%{}%", q);
let user_id = query.user_id;
let keys: Vec<DownstreamApiKey> = sqlx::query_as::<_, DownstreamApiKey>(
r#"
SELECT * FROM downstream_api_keys
WHERE (? = '' OR name LIKE ? OR key LIKE ? OR tags LIKE ?)
AND (? IS NULL OR user_id = ?)
ORDER BY id DESC
"#,
)
.bind(&q)
.bind(&q_like)
.bind(&q_like)
.bind(&q_like)
.bind(user_id)
.bind(user_id)
.fetch_all(&*state.db) .fetch_all(&*state.db)
.await?; .await?;
@@ -79,7 +106,11 @@ pub async fn create_downstream_key(
} }
// Auto-generate key // Auto-generate key
let key = format!("sk-{}", Uuid::new_v4().to_string().replace('-', "")); let key = if payload.user_id.is_some() {
format!("sk-user-{}", Uuid::new_v4().to_string().replace('-', ""))
} else {
format!("sk-{}", Uuid::new_v4().to_string().replace('-', ""))
};
// Serialize supported_models to JSON string if provided // Serialize supported_models to JSON string if provided
let supported_models: Option<String> = if let Some(ref models) = payload.supported_models { let supported_models: Option<String> = if let Some(ref models) = payload.supported_models {
@@ -95,7 +126,7 @@ pub async fn create_downstream_key(
let enabled = payload.enabled.unwrap_or(true); let enabled = payload.enabled.unwrap_or(true);
let result = sqlx::query( let result = sqlx::query(
"INSERT INTO downstream_api_keys (name, key, description, group_name, tags, enabled, expires_at, max_cost, max_requests, supported_models, allowed_route_ids) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" "INSERT INTO downstream_api_keys (name, key, description, group_name, tags, enabled, expires_at, max_cost, max_requests, supported_models, allowed_route_ids, user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
) )
.bind(&payload.name) .bind(&payload.name)
.bind(&key) .bind(&key)
@@ -108,6 +139,7 @@ pub async fn create_downstream_key(
.bind(payload.max_requests) .bind(payload.max_requests)
.bind(&supported_models) .bind(&supported_models)
.bind(&payload.allowed_route_ids) .bind(&payload.allowed_route_ids)
.bind(payload.user_id)
.execute(&*state.db) .execute(&*state.db)
.await?; .await?;
@@ -149,7 +181,7 @@ pub async fn update_downstream_key(
}; };
sqlx::query( sqlx::query(
"UPDATE downstream_api_keys SET name = ?, description = ?, group_name = ?, tags = ?, enabled = ?, expires_at = ?, max_cost = ?, max_requests = ?, supported_models = ?, allowed_route_ids = ?, updated_at = datetime('now') WHERE id = ?" "UPDATE downstream_api_keys SET name = ?, description = ?, group_name = ?, tags = ?, enabled = ?, expires_at = ?, max_cost = ?, max_requests = ?, supported_models = ?, allowed_route_ids = ?, user_id = ?, updated_at = datetime('now') WHERE id = ?"
) )
.bind(payload.name.unwrap_or(existing.name)) .bind(payload.name.unwrap_or(existing.name))
.bind(payload.description.or(existing.description)) .bind(payload.description.or(existing.description))
@@ -161,6 +193,7 @@ pub async fn update_downstream_key(
.bind(payload.max_requests.or(existing.max_requests)) .bind(payload.max_requests.or(existing.max_requests))
.bind(&supported_models) .bind(&supported_models)
.bind(payload.allowed_route_ids.or(existing.allowed_route_ids)) .bind(payload.allowed_route_ids.or(existing.allowed_route_ids))
.bind(payload.user_id.or(existing.user_id))
.bind(id) .bind(id)
.execute(&*state.db) .execute(&*state.db)
.await?; .await?;
@@ -196,3 +229,40 @@ pub async fn delete_downstream_key(
Ok(Json(json!({ "data": { "deleted": true, "id": id } }))) Ok(Json(json!({ "data": { "deleted": true, "id": id } })))
} }
/// POST /api/downstream-api-keys/{id}/reset
pub async fn reset_downstream_key(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Value>> {
info!("Resetting downstream API key: {}", id);
let existing: DownstreamApiKey =
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys WHERE id = ?")
.bind(id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound(format!("Downstream key {} not found", id)))?;
let new_key = if existing.user_id.is_some() {
format!("sk-user-{}", Uuid::new_v4().to_string().replace('-', ""))
} else {
format!("sk-{}", Uuid::new_v4().to_string().replace('-', ""))
};
sqlx::query(
"UPDATE downstream_api_keys SET key = ?, updated_at = datetime('now') WHERE id = ?",
)
.bind(&new_key)
.bind(id)
.execute(&*state.db)
.await?;
let api_key: DownstreamApiKey =
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys WHERE id = ?")
.bind(id)
.fetch_one(&*state.db)
.await?;
Ok(Json(json!({ "data": api_key })))
}
+30 -2
View File
@@ -8,7 +8,7 @@ use axum::{
http::HeaderMap, http::HeaderMap,
middleware, middleware,
response::Response, response::Response,
routing::{get, post, put}, routing::{get, patch, post, put},
Json, Router, Json, Router,
}; };
use serde_json::{json, Value}; use serde_json::{json, Value};
@@ -79,6 +79,14 @@ pub fn routes(state: AppState) -> Router<AppState> {
get(accounts::get_account) get(accounts::get_account)
.put(accounts::update_account) .put(accounts::update_account)
.delete(accounts::delete_account), .delete(accounts::delete_account),
)
.route(
"/api/accounts/{id}/models",
get(accounts::get_account_models),
)
.route(
"/api/accounts/{id}/models/manual",
post(accounts::add_account_manual_models),
); );
let tokens_routes = Router::new() let tokens_routes = Router::new()
@@ -112,10 +120,18 @@ pub fn routes(state: AppState) -> Router<AppState> {
put(downstream_keys::update_downstream_key) put(downstream_keys::update_downstream_key)
.delete(downstream_keys::delete_downstream_key), .delete(downstream_keys::delete_downstream_key),
) )
.route(
"/api/downstream-api-keys/{id}/reset",
post(downstream_keys::reset_downstream_key),
)
.route( .route(
"/api/downstream-keys/{id}", "/api/downstream-keys/{id}",
put(downstream_keys::update_downstream_key) put(downstream_keys::update_downstream_key)
.delete(downstream_keys::delete_downstream_key), .delete(downstream_keys::delete_downstream_key),
)
.route(
"/api/downstream-keys/{id}/reset",
post(downstream_keys::reset_downstream_key),
); );
let settings_routes = let settings_routes =
@@ -133,7 +149,19 @@ pub fn routes(state: AppState) -> Router<AppState> {
.patch(users::update_user) .patch(users::update_user)
.delete(users::delete_user), .delete(users::delete_user),
) )
.route("/api/admin/users/{id}/usage", get(users::get_user_usage)); .route("/api/admin/users/{id}/usage", get(users::get_user_usage))
.route(
"/api/admin/users/{id}/keys",
get(users::list_user_keys).post(users::create_user_key),
)
.route(
"/api/admin/users/{id}/keys/{key_id}",
patch(users::update_user_key).delete(users::delete_user_key),
)
.route(
"/api/admin/users/{id}/keys/{key_id}/reset",
post(users::reset_user_key),
);
Router::new() Router::new()
.merge(sites_routes) .merge(sites_routes)
+199 -1
View File
@@ -2,17 +2,24 @@
use crate::{ use crate::{
auth::password::hash_password, auth::password::hash_password,
db::models::user::User, db::models::{downstream_api_key::DownstreamApiKey, user::User},
error::{AppError, AppResult}, error::{AppError, AppResult},
AppState, AppState,
}; };
use axum::{ use axum::{
extract::{Path, Query, State}, extract::{Path, Query, State},
http::StatusCode,
Json, Json,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
use tracing::info; use tracing::info;
use uuid::Uuid;
#[derive(Debug, Deserialize)]
pub struct CreateUserApiKeyRequest {
pub name: String,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct CreateUserRequest { pub struct CreateUserRequest {
@@ -501,3 +508,194 @@ async fn attach_usage_summary(state: &AppState, item: &mut UserResponse) -> AppR
item.last_7_days_cost = Some(last_7_days_cost); item.last_7_days_cost = Some(last_7_days_cost);
Ok(()) Ok(())
} }
// ── User API Key Management for Admin ──────────────────────────────────
/// GET /api/admin/users/{id}/keys
pub async fn list_user_keys(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Value>> {
info!("Admin listing API keys for user: {}", id);
// Verify user exists
let _exists = sqlx::query_scalar::<_, i64>("SELECT 1 FROM users WHERE id = ?")
.bind(id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound(format!("User {} not found", id)))?;
let keys: Vec<DownstreamApiKey> = sqlx::query_as::<_, DownstreamApiKey>(
"SELECT * FROM downstream_api_keys WHERE user_id = ? ORDER BY created_at DESC",
)
.bind(id)
.fetch_all(&*state.db)
.await?;
Ok(Json(json!({ "data": keys, "keys": keys })))
}
/// POST /api/admin/users/{id}/keys
pub async fn create_user_key(
State(state): State<AppState>,
Path(id): Path<i64>,
Json(payload): Json<CreateUserApiKeyRequest>,
) -> AppResult<(StatusCode, Json<Value>)> {
info!("Admin creating API key for user: {}", id);
let key = format!("sk-user-{}", Uuid::new_v4().to_string().replace('-', ""));
let result = sqlx::query(
"INSERT INTO downstream_api_keys (name, key, user_id, enabled) VALUES (?, ?, ?, ?)",
)
.bind(&payload.name)
.bind(&key)
.bind(id)
.bind(true)
.execute(&*state.db)
.await?;
let key_id = result.last_insert_rowid();
let api_key: DownstreamApiKey =
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys WHERE id = ?")
.bind(key_id)
.fetch_one(&*state.db)
.await?;
Ok((StatusCode::CREATED, Json(json!({ "data": api_key }))))
}
/// DELETE /api/admin/users/{id}/keys/{key_id}
pub async fn delete_user_key(
State(state): State<AppState>,
Path((user_id, key_id)): Path<(i64, i64)>,
) -> AppResult<Json<Value>> {
info!("Admin deleting API key {} for user {}", key_id, user_id);
let affected = sqlx::query("DELETE FROM downstream_api_keys WHERE id = ? AND user_id = ?")
.bind(key_id)
.bind(user_id)
.execute(&*state.db)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound(format!(
"Key {} for user {} not found",
key_id, user_id
)));
}
Ok(Json(json!({ "data": { "deleted": true, "id": key_id } })))
}
/// POST /api/admin/users/{id}/keys/{key_id}/reset
pub async fn reset_user_key(
State(state): State<AppState>,
Path((user_id, key_id)): Path<(i64, i64)>,
) -> AppResult<Json<Value>> {
info!("Admin resetting API key {} for user {}", key_id, user_id);
let new_key = format!("sk-user-{}", Uuid::new_v4().to_string().replace('-', ""));
let affected = sqlx::query("UPDATE downstream_api_keys SET key = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?")
.bind(&new_key)
.bind(key_id)
.bind(user_id)
.execute(&*state.db)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound(format!(
"Key {} for user {} not found",
key_id, user_id
)));
}
let api_key: DownstreamApiKey =
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys WHERE id = ?")
.bind(key_id)
.fetch_one(&*state.db)
.await?;
Ok(Json(json!({ "data": api_key })))
}
/// PATCH /api/admin/users/{id}/keys/{key_id}
pub async fn update_user_key(
State(state): State<AppState>,
Path((user_id, key_id)): Path<(i64, i64)>,
Json(payload): Json<serde_json::Value>,
) -> AppResult<Json<Value>> {
info!("Admin updating API key {} for user {}", key_id, user_id);
let _existing: DownstreamApiKey = sqlx::query_as::<_, DownstreamApiKey>(
"SELECT * FROM downstream_api_keys WHERE id = ? AND user_id = ?",
)
.bind(key_id)
.bind(user_id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound(format!("Key {} for user {} not found", key_id, user_id)))?;
// Build dynamic UPDATE
let mut sets: Vec<String> = vec![];
let mut params: Vec<serde_json::Value> = vec![];
if let Some(v) = payload.get("name").and_then(|v| v.as_str()) {
sets.push("name = ?".to_string());
params.push(json!(v));
}
if let Some(v) = payload.get("description") {
sets.push("description = ?".to_string());
params.push(v.clone());
}
if let Some(v) = payload.get("enabled").and_then(|v| v.as_bool()) {
sets.push("enabled = ?".to_string());
params.push(json!(v));
}
if let Some(v) = payload.get("expiresAt") {
sets.push("expires_at = ?".to_string());
params.push(v.clone());
}
if let Some(v) = payload.get("maxCost").and_then(|v| v.as_f64()) {
sets.push("max_cost = ?".to_string());
params.push(json!(v));
}
if let Some(v) = payload.get("maxRequests").and_then(|v| v.as_i64()) {
sets.push("max_requests = ?".to_string());
params.push(json!(v));
}
if !sets.is_empty() {
sets.push("updated_at = datetime('now')".to_string());
let sql = format!(
"UPDATE downstream_api_keys SET {} WHERE id = ? AND user_id = ?",
sets.join(", ")
);
let mut q = sqlx::query(&sql);
for p in &params {
if let Some(s) = p.as_str() {
q = q.bind(s.to_owned());
} else if let Some(b) = p.as_bool() {
q = q.bind(b);
} else if let Some(f) = p.as_f64() {
q = q.bind(f);
} else if let Some(i) = p.as_i64() {
q = q.bind(i);
} else if p.is_null() {
q = q.bind(None::<String>);
}
}
q.bind(key_id).bind(user_id).execute(&*state.db).await?;
}
let updated: DownstreamApiKey =
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys WHERE id = ?")
.bind(key_id)
.fetch_one(&*state.db)
.await?;
Ok(Json(json!({ "data": updated })))
}
+77 -18
View File
@@ -161,33 +161,66 @@ async fn delete_api_key(
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
#[derive(Debug, Deserialize)]
struct UserLogsQuery {
q: Option<String>,
status: Option<String>,
date_from: Option<String>,
date_to: Option<String>,
page: Option<i64>,
page_size: Option<i64>,
// legacy compat
limit: Option<i64>,
offset: Option<i64>,
}
async fn get_user_logs( async fn get_user_logs(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
Query(params): Query<std::collections::HashMap<String, String>>, Query(params): Query<UserLogsQuery>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let user_id = current_user_id(&headers, &state)?; let user_id = current_user_id(&headers, &state)?;
let limit = params let page_size = params
.get("limit") .page_size
.and_then(|v| v.parse::<i64>().ok()) .or(params.limit)
.unwrap_or(20) .unwrap_or(20)
.clamp(1, 100); .clamp(1, 200);
let offset = params let page = params.page.unwrap_or(1).max(1);
.get("offset") let offset = params.offset.unwrap_or((page - 1) * page_size).max(0);
.and_then(|v| v.parse::<i64>().ok())
.unwrap_or(0) let q = params.q.as_deref().map(str::trim).filter(|s| !s.is_empty());
.max(0); let q_like = q.map(|s| format!("%{s}%"));
let status = params
.status
.as_deref()
.filter(|s| !s.is_empty() && *s != "all");
let date_from = params.date_from.as_deref().filter(|s| !s.is_empty());
let date_to = params.date_to.as_deref().filter(|s| !s.is_empty());
let items: Vec<Value> = sqlx::query_as::<_, crate::routes::stats::ProxyLogRow>( let items: Vec<Value> = sqlx::query_as::<_, crate::routes::stats::ProxyLogRow>(
"SELECT l.* FROM proxy_logs l r#"
SELECT l.* FROM proxy_logs l
JOIN downstream_api_keys k ON l.downstream_api_key_id = k.id JOIN downstream_api_keys k ON l.downstream_api_key_id = k.id
WHERE k.user_id = ? WHERE k.user_id = ?
AND (? IS NULL OR l.model_requested LIKE ?)
AND (? IS NULL OR l.status = ?)
AND (? IS NULL OR l.created_at >= ?)
AND (? IS NULL OR l.created_at <= ?)
ORDER BY l.created_at DESC ORDER BY l.created_at DESC
LIMIT ? OFFSET ?", LIMIT ? OFFSET ?
"#,
) )
.bind(user_id) .bind(user_id)
.bind(limit) .bind(q_like.as_deref())
.bind(q_like.as_deref())
.bind(status)
.bind(status)
.bind(date_from)
.bind(date_from)
.bind(date_to)
.bind(date_to)
.bind(page_size)
.bind(offset) .bind(offset)
.fetch_all(&*state.db) .fetch_all(&*state.db)
.await? .await?
@@ -195,20 +228,46 @@ async fn get_user_logs(
.map(|row| serde_json::to_value(row).unwrap_or(json!({}))) .map(|row| serde_json::to_value(row).unwrap_or(json!({})))
.collect(); .collect();
let total: i64 = sqlx::query_scalar( let (total, success_count, total_cost, total_tokens): (i64, i64, f64, i64) = sqlx::query_as(
"SELECT COUNT(*) FROM proxy_logs l r#"
SELECT
COUNT(*),
SUM(CASE WHEN l.status = 'success' THEN 1 ELSE 0 END),
COALESCE(SUM(l.estimated_cost), 0.0),
COALESCE(SUM(l.total_tokens), 0)
FROM proxy_logs l
JOIN downstream_api_keys k ON l.downstream_api_key_id = k.id JOIN downstream_api_keys k ON l.downstream_api_key_id = k.id
WHERE k.user_id = ?", WHERE k.user_id = ?
AND (? IS NULL OR l.model_requested LIKE ?)
AND (? IS NULL OR l.status = ?)
AND (? IS NULL OR l.created_at >= ?)
AND (? IS NULL OR l.created_at <= ?)
"#,
) )
.bind(user_id) .bind(user_id)
.bind(q_like.as_deref())
.bind(q_like.as_deref())
.bind(status)
.bind(status)
.bind(date_from)
.bind(date_from)
.bind(date_to)
.bind(date_to)
.fetch_one(&*state.db) .fetch_one(&*state.db)
.await?; .await?;
Ok(Json(json!({ Ok(Json(json!({
"items": items, "items": items,
"total": total, "total": total,
"limit": limit, "page": page,
"offset": offset "page_size": page_size,
"summary": {
"total": total,
"success": success_count,
"failed": total - success_count,
"total_cost": total_cost,
"total_tokens": total_tokens,
}
}))) })))
} }
+13 -4
View File
@@ -5,7 +5,7 @@ import SearchModal from './components/SearchModal.js';
import NotificationPanel from './components/NotificationPanel.js'; import NotificationPanel from './components/NotificationPanel.js';
import TooltipLayer from './components/TooltipLayer.js'; import TooltipLayer from './components/TooltipLayer.js';
import { api } from './api.js'; import { api } from './api.js';
import { clearAuthSession, hasValidAuthSession, hasValidUserSession, persistAuthSession, persistUserSession } from './authSession.js'; import { clearAuthSession, clearUserSession, hasValidAuthSession, hasValidUserSession, persistAuthSession, persistUserSession } from './authSession.js';
import { import {
FIRST_USE_DOC_REMINDER_KEY, FIRST_USE_DOC_REMINDER_KEY,
LEGACY_THEME_STORAGE_KEY, LEGACY_THEME_STORAGE_KEY,
@@ -207,7 +207,10 @@ export function Login({
}); });
const data = await res.json().catch(() => null); const data = await res.json().catch(() => null);
if (!res.ok) { if (!res.ok) {
const message = data?.message || data?.error || t('登录失败,请检查邮箱和密码'); const rawErr = data?.error;
const message = data?.message
|| (typeof rawErr === 'string' ? rawErr : rawErr?.message)
|| t('登录失败,请检查邮箱和密码');
setError(t(String(message))); setError(t(String(message)));
setLoading(false); setLoading(false);
return; return;
@@ -798,11 +801,15 @@ function AppShell() {
const isUserPage = location.pathname.startsWith('/user/'); const isUserPage = location.pathname.startsWith('/user/');
const isPublicUserPage = ['/user/login', '/user/register'].includes(location.pathname); const isPublicUserPage = ['/user/login', '/user/register'].includes(location.pathname);
if (isUserPage && isPublicUserPage && userAuthed) { if (isUserPage && isPublicUserPage) {
return <Navigate to="/user/dashboard" />; if (userAuthed) return <Navigate to="/user/dashboard" />;
if (authed) return <Navigate to="/" />;
} }
if (!authed && !isUserPage) { if (!authed && !isUserPage) {
if (userAuthed) {
return <Navigate to="/user/dashboard" />;
}
return ( return (
<Login <Login
t={t} t={t}
@@ -978,7 +985,9 @@ function AppShell() {
</button> </button>
<button onClick={() => { <button onClick={() => {
clearAuthSession(localStorage); clearAuthSession(localStorage);
clearUserSession(localStorage);
setAuthed(false); setAuthed(false);
setUserAuthed(false);
}} className="user-dropdown-item danger"> }} className="user-dropdown-item danger">
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" /></svg> <svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" /></svg>
{t('退出登录')} {t('退出登录')}
+10 -2
View File
@@ -1822,8 +1822,16 @@ export const api = {
request(`/api/admin/users/${id}`, { method: "PATCH", body: JSON.stringify(data) }), request(`/api/admin/users/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
deleteUser: (id: number) => deleteUser: (id: number) =>
request(`/api/admin/users/${id}`, { method: "DELETE" }), request(`/api/admin/users/${id}`, { method: "DELETE" }),
getUserKeys: (id: number) => request(`/api/admin/users/${id}/keys`),
createUserKey: (id: number, data: { name: string }) =>
request(`/api/admin/users/${id}/keys`, { method: "POST", body: JSON.stringify(data) }),
deleteUserKey: (userId: number, keyId: number) =>
request(`/api/admin/users/${userId}/keys/${keyId}`, { method: "DELETE" }),
resetUserKey: (userId: number, keyId: number) =>
request(`/api/admin/users/${userId}/keys/${keyId}/reset`, { method: "POST" }),
updateUserKey: (userId: number, keyId: number, data: any) =>
request(`/api/admin/users/${userId}/keys/${keyId}`, { method: "PATCH", body: JSON.stringify(data) }),
}; };
export const userApi = { export const userApi = {
register: (data: { username: string; email: string; password: string }) => register: (data: { username: string; email: string; password: string }) =>
userRequest("/api/users/register", { method: "POST", body: JSON.stringify(data) }), userRequest("/api/users/register", { method: "POST", body: JSON.stringify(data) }),
@@ -1841,7 +1849,7 @@ export const userApi = {
userRequest(`/api/user-api-keys/${id}`, { method: "PATCH", body: JSON.stringify(data) }), userRequest(`/api/user-api-keys/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
deleteApiKey: (id: number) => deleteApiKey: (id: number) =>
userRequest(`/api/user-api-keys/${id}`, { method: "DELETE" }), userRequest(`/api/user-api-keys/${id}`, { method: "DELETE" }),
getLogs: (params?: { limit?: number; offset?: number }) => getLogs: (params?: { limit?: number; offset?: number; page?: number; page_size?: number; q?: string; status?: string; date_from?: string; date_to?: string }) =>
userRequest(`/api/user/logs${buildQueryString(params)}`), userRequest(`/api/user/logs${buildQueryString(params)}`),
getSummary: () => userRequest("/api/user/summary"), getSummary: () => userRequest("/api/user/summary"),
}; };
+221 -49
View File
@@ -1,142 +1,314 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { userApi } from '../api.js'; import { userApi } from '../api.js';
import { useToast } from '../components/Toast.js'; import { useToast } from '../components/Toast.js';
interface LogItem { interface LogItem {
id: number; id: number;
model_requested: string; model_requested: string | null;
model_actual: string; model_actual: string | null;
status: string; status: string | null;
http_status: number; http_status: number | null;
latency_ms: number; latency_ms: number | null;
prompt_tokens: number | null;
completion_tokens: number | null;
total_tokens: number | null;
estimated_cost: number | null;
error_message: string | null;
is_stream: boolean | null;
created_at: string | null;
}
interface Summary {
total: number;
success: number;
failed: number;
total_cost: number;
total_tokens: number; total_tokens: number;
estimated_cost: number; }
error_message?: string;
created_at: string; const PAGE_SIZE = 50;
function formatDate(value: string | null): string {
if (!value) return '-';
const d = new Date(value.includes('T') ? value : value.replace(' ', 'T'));
if (Number.isNaN(d.getTime())) return value.slice(0, 16);
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function latencyColor(ms: number | null): string {
if (!ms) return 'var(--color-text-muted)';
if (ms < 1000) return 'var(--color-success)';
if (ms < 3000) return 'var(--color-warning)';
return 'var(--color-danger)';
} }
export default function UsageRecords() { export default function UsageRecords() {
const [logs, setLogs] = useState<LogItem[]>([]); const [logs, setLogs] = useState<LogItem[]>([]);
const [summary, setSummary] = useState<Summary | null>(null);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const pageSize = 20; const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const [expandedId, setExpandedId] = useState<number | null>(null);
const toast = useToast(); const toast = useToast();
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const loadLogs = useCallback(async () => { const load = useCallback(async (p: number, q: string, status: string, from: string, to: string) => {
setLoading(true); setLoading(true);
try { try {
const res = await userApi.getLogs({ const res = await userApi.getLogs({
limit: pageSize, page: p,
offset: (page - 1) * pageSize, page_size: PAGE_SIZE,
q: q || undefined,
status: status || undefined,
date_from: from || undefined,
date_to: to || undefined,
}); });
setLogs(res.items || []); setLogs(res.items || []);
setTotal(res.total || 0); setTotal(res.total || 0);
setSummary(res.summary || null);
} catch (err: any) { } catch (err: any) {
toast.error(err?.message || '加载记录失败'); toast.error(err?.message || '加载记录失败');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [page, toast]); }, [toast]);
useEffect(() => { useEffect(() => {
loadLogs(); load(page, search, statusFilter, dateFrom, dateTo);
}, [loadLogs]); }, [load, page, statusFilter, dateFrom, dateTo]);
const totalPages = Math.ceil(total / pageSize); const handleSearchChange = (value: string) => {
setSearch(value);
if (searchTimer.current) clearTimeout(searchTimer.current);
searchTimer.current = setTimeout(() => {
setPage(1);
load(1, value, statusFilter, dateFrom, dateTo);
}, 300);
};
const handleFilterChange = (status: string, from: string, to: string) => {
setStatusFilter(status);
setDateFrom(from);
setDateTo(to);
setPage(1);
};
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const successRate = summary && summary.total > 0
? ((summary.success / summary.total) * 100).toFixed(1)
: '0.0';
return ( return (
<div className="page-container" style={{ padding: '24px' }}> <div className="page-container animate-fade-in">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}> <div className="page-header">
<h1 style={{ fontSize: '20px', fontWeight: 600, margin: 0 }}>使用记录</h1> <div>
<button className="btn btn-ghost" onClick={loadLogs} disabled={loading}> <h2 className="page-title">使用记录</h2>
<div className="page-subtitle">当前账号下所有 API 调用日志</div>
</div>
<button
type="button"
className="btn btn-ghost"
onClick={() => load(page, search, statusFilter, dateFrom, dateTo)}
disabled={loading}
>
刷新 刷新
</button> </button>
</div> </div>
{/* Summary cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12, marginBottom: 20 }}>
{[
{ label: '总请求', value: summary?.total.toLocaleString() ?? '-', color: 'var(--color-text-primary)' },
{ label: '成功率', value: `${successRate}%`, color: 'var(--color-success)' },
{ label: '失败', value: summary?.failed.toLocaleString() ?? '-', color: summary?.failed ? 'var(--color-danger)' : 'var(--color-text-muted)' },
{ label: 'Tokens', value: summary ? (summary.total_tokens >= 1000 ? `${(summary.total_tokens / 1000).toFixed(1)}K` : summary.total_tokens.toString()) : '-', color: 'var(--color-text-primary)' },
{ label: '预估费用', value: summary ? `$${summary.total_cost.toFixed(4)}` : '-', color: 'var(--color-primary)' },
].map((card) => (
<div key={card.label} className="card" style={{ padding: '14px 16px' }}>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 6 }}>{card.label}</div>
<div style={{ fontSize: 20, fontWeight: 700, color: card.color }}>{card.value}</div>
</div>
))}
</div>
{/* Filters */}
<div className="card" style={{ padding: '12px 16px', marginBottom: 16 }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'center' }}>
<div style={{ position: 'relative', flex: '1 1 200px', minWidth: 160 }}>
<svg style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', color: 'var(--color-text-muted)' }} width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="m21 21-4.35-4.35M10.5 18a7.5 7.5 0 1 1 0-15 7.5 7.5 0 0 1 0 15Z" />
</svg>
<input
style={{ width: '100%', paddingLeft: 30, paddingRight: 10, height: 34, border: '1px solid var(--color-border)', borderRadius: 6, background: 'var(--color-bg)', color: 'var(--color-text-primary)', fontSize: 13, boxSizing: 'border-box' }}
placeholder="搜索模型名称..."
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
/>
</div>
<select
style={{ height: 34, border: '1px solid var(--color-border)', borderRadius: 6, background: 'var(--color-bg)', color: 'var(--color-text-primary)', fontSize: 13, padding: '0 10px' }}
value={statusFilter}
onChange={(e) => handleFilterChange(e.target.value, dateFrom, dateTo)}
>
<option value="">全部状态</option>
<option value="success">成功</option>
<option value="failed">失败</option>
</select>
<input
type="date"
style={{ height: 34, border: '1px solid var(--color-border)', borderRadius: 6, background: 'var(--color-bg)', color: 'var(--color-text-primary)', fontSize: 13, padding: '0 10px' }}
value={dateFrom}
onChange={(e) => handleFilterChange(statusFilter, e.target.value, dateTo)}
/>
<span style={{ color: 'var(--color-text-muted)', fontSize: 13 }}>至</span>
<input
type="date"
style={{ height: 34, border: '1px solid var(--color-border)', borderRadius: 6, background: 'var(--color-bg)', color: 'var(--color-text-primary)', fontSize: 13, padding: '0 10px' }}
value={dateTo}
onChange={(e) => handleFilterChange(statusFilter, dateFrom, e.target.value)}
/>
{(search || statusFilter || dateFrom || dateTo) && (
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => { setSearch(''); handleFilterChange('', '', ''); }}
>
清除
</button>
)}
</div>
</div>
{/* Table */}
<div className="card" style={{ overflow: 'hidden' }}> <div className="card" style={{ overflow: 'hidden' }}>
<div style={{ overflowX: 'auto' }}> <div style={{ overflowX: 'auto' }}>
<table className="table"> <table className="data-table">
<thead> <thead>
<tr> <tr>
<th>时间</th> <th>时间</th>
<th>请求模型</th> <th>模型</th>
<th>状态</th> <th>状态</th>
<th>耗时</th> <th>耗时</th>
<th>Tokens</th> <th>Tokens</th>
<th>预估成本</th> <th>费用</th>
<th style={{ width: 40 }} />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{loading ? ( {loading ? (
<tr> <tr>
<td colSpan={6} style={{ textAlign: 'center', padding: '40px' }}> <td colSpan={7} style={{ textAlign: 'center', padding: '40px' }}>
<div className="spinner" style={{ margin: '0 auto' }} /> <div className="spinner" style={{ margin: '0 auto' }} />
</td> </td>
</tr> </tr>
) : logs.length === 0 ? ( ) : logs.length === 0 ? (
<tr> <tr>
<td colSpan={6} style={{ textAlign: 'center', padding: '40px', color: 'var(--color-text-muted)' }}> <td colSpan={7} style={{ textAlign: 'center', padding: '40px', color: 'var(--color-text-muted)' }}>
暂无使用记录 暂无记录
</td> </td>
</tr> </tr>
) : ( ) : (
logs.map((log) => ( logs.map((log) => (
<tr key={log.id}> <React.Fragment key={log.id}>
<td style={{ fontSize: '12px' }}>{new Date(log.created_at).toLocaleString()}</td> <tr
style={{ cursor: 'pointer' }}
onClick={() => setExpandedId(expandedId === log.id ? null : log.id)}
>
<td style={{ fontSize: 12, color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>
{formatDate(log.created_at)}
</td>
<td> <td>
<div style={{ fontWeight: 500 }}>{log.model_requested}</div> <div style={{ fontWeight: 500, fontSize: 13 }}>{log.model_requested || '-'}</div>
{log.model_actual && log.model_actual !== log.model_requested && ( {log.model_actual && log.model_actual !== log.model_requested && (
<div style={{ fontSize: '11px', color: 'var(--color-text-muted)' }}>→ {log.model_actual}</div> <div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>→ {log.model_actual}</div>
)} )}
</td> </td>
<td> <td>
<span className={`badge badge-${log.status === 'success' ? 'success' : 'danger'}`}> <span className={`badge badge-${log.status === 'success' ? 'success' : 'error'}`} style={{ fontSize: 11 }}>
{log.status === 'success' ? '成功' : '失败'} {log.status === 'success' ? '成功' : '失败'}
{log.http_status && ` (${log.http_status})`} {log.http_status ? ` ${log.http_status}` : ''}
</span> </span>
{log.is_stream && (
<span style={{ marginLeft: 4, fontSize: 10, color: 'var(--color-text-muted)' }}>流式</span>
)}
</td>
<td style={{ fontSize: 13, color: latencyColor(log.latency_ms), fontFamily: 'var(--font-mono)' }}>
{log.latency_ms != null ? `${log.latency_ms}ms` : '-'}
</td>
<td style={{ fontSize: 12, fontFamily: 'var(--font-mono)' }}>
{log.total_tokens != null ? log.total_tokens.toLocaleString() : '-'}
</td>
<td style={{ fontSize: 12, fontFamily: 'var(--font-mono)', color: 'var(--color-primary)' }}>
{log.estimated_cost != null ? `$${log.estimated_cost.toFixed(5)}` : '-'}
</td>
<td>
<svg
style={{ display: 'block', color: 'var(--color-text-muted)', transition: 'transform 0.15s', transform: expandedId === log.id ? 'rotate(180deg)' : 'none' }}
width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</td>
</tr>
{expandedId === log.id && (
<tr>
<td colSpan={7} style={{ padding: 0, background: 'var(--color-bg-alt)' }}>
<div style={{ padding: '14px 20px', display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12, borderBottom: '1px solid var(--color-border)' }}>
{[
{ label: 'Prompt Tokens', value: log.prompt_tokens?.toLocaleString() ?? '-' },
{ label: 'Completion Tokens', value: log.completion_tokens?.toLocaleString() ?? '-' },
{ label: 'Total Tokens', value: log.total_tokens?.toLocaleString() ?? '-' },
{ label: 'HTTP Status', value: log.http_status?.toString() ?? '-' },
].map((item) => (
<div key={item.label}>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 2 }}>{item.label}</div>
<div style={{ fontSize: 13, fontFamily: 'var(--font-mono)' }}>{item.value}</div>
</div>
))}
{log.error_message && ( {log.error_message && (
<div style={{ fontSize: '11px', color: 'var(--color-danger)', marginTop: '2px', maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={log.error_message}> <div style={{ gridColumn: '1 / -1' }}>
{log.error_message} <div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 2 }}>错误信息</div>
<div style={{ fontSize: 12, color: 'var(--color-danger)', wordBreak: 'break-all' }}>{log.error_message}</div>
</div> </div>
)} )}
</div>
</td> </td>
<td>{log.latency_ms}ms</td>
<td>{log.total_tokens || 0}</td>
<td>${log.estimated_cost ? log.estimated_cost.toFixed(6) : '0.000000'}</td>
</tr> </tr>
)}
</React.Fragment>
)) ))
)} )}
</tbody> </tbody>
</table> </table>
</div> </div>
{totalPages > 1 && (
<div style={{ padding: '12px 20px', borderTop: '1px solid var(--color-border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ padding: '12px 20px', borderTop: '1px solid var(--color-border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ fontSize: '13px', color: 'var(--color-text-muted)' }}> <div style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>
共 {total} 条记录 共 {total} 条记录,第 {page} / {totalPages} 页
</div> </div>
<div style={{ display: 'flex', gap: '8px' }}> <div style={{ display: 'flex', gap: 8 }}>
<button <button
type="button"
className="btn btn-ghost btn-sm" className="btn btn-ghost btn-sm"
disabled={page === 1 || loading} disabled={page === 1 || loading}
onClick={() => setPage(page - 1)} onClick={() => setPage((p) => p - 1)}
> >
上一页 上一页
</button> </button>
<span style={{ fontSize: '13px', display: 'flex', alignItems: 'center' }}>
{page} / {totalPages}
</span>
<button <button
type="button"
className="btn btn-ghost btn-sm" className="btn btn-ghost btn-sm"
disabled={page === totalPages || loading} disabled={page >= totalPages || loading}
onClick={() => setPage(page + 1)} onClick={() => setPage((p) => p + 1)}
> >
下一页 下一页
</button> </button>
</div> </div>
</div> </div>
)}
</div> </div>
</div> </div>
); );
+1 -1
View File
@@ -210,7 +210,7 @@ export default function UserDashboard() {
</div> </div>
<div style={{ marginTop: '20px', display: 'flex', gap: '10px' }}> <div style={{ marginTop: '20px', display: 'flex', gap: '10px' }}>
<button className="btn btn-ghost btn-sm" style={{ flex: 1, border: '1px solid var(--color-border)' }} onClick={() => navigate('/user/dashboard/profile')}>修改资料</button> <button className="btn btn-ghost btn-sm" style={{ flex: 1, border: '1px solid var(--color-border)' }} onClick={() => navigate('/user/dashboard/profile')}>修改资料</button>
<button className="btn btn-ghost btn-sm danger" style={{ flex: 1, border: '1px solid var(--color-border)' }} onClick={() => { clearUserSession(localStorage); navigate('/user/login'); }}>退出登录</button> <button className="btn btn-ghost btn-sm danger" style={{ flex: 1, border: '1px solid var(--color-border)' }} onClick={() => { clearUserSession(localStorage); window.location.href = '/user/login'; }}>退出登录</button>
</div> </div>
</section> </section>
</div> </div>
+2 -1
View File
@@ -22,7 +22,8 @@ export default function UserLogin({ onLogin }: { onLogin?: () => void }) {
}); });
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
throw new Error(data?.error || `登录失败 (${res.status})`); const errMsg = typeof data?.error === 'string' ? data.error : (data?.error?.message || data?.message || `登录失败 (${res.status})`);
throw new Error(errMsg);
} }
const result = await res.json(); const result = await res.json();
persistUserSession(localStorage, result.token); persistUserSession(localStorage, result.token);
+159 -2
View File
@@ -147,6 +147,9 @@ export default function UserManagement() {
const [expandedUserId, setExpandedUserId] = useState<number | null>(null); const [expandedUserId, setExpandedUserId] = useState<number | null>(null);
const [usageData, setUsageData] = useState<any>(null); const [usageData, setUsageData] = useState<any>(null);
const [usageLoading, setUsageLoading] = useState(false); const [usageLoading, setUsageLoading] = useState(false);
const [keysData, setKeysData] = useState<any[]>([]);
const [keysLoading, setKeysLoading] = useState(false);
const [expandedTab, setExpandedTab] = useState<'usage' | 'keys'>('usage');
const [activeTab, setActiveTab] = useState<'list' | 'usage'>('list'); const [activeTab, setActiveTab] = useState<'list' | 'usage'>('list');
const loadUsage = async (userId: number) => { const loadUsage = async (userId: number) => {
@@ -161,14 +164,92 @@ export default function UserManagement() {
} }
}; };
const loadKeys = async (userId: number) => {
setKeysLoading(true);
try {
const result = await api.getUserKeys(userId);
setKeysData(result.data || result.keys || []);
} catch (err: any) {
console.error('Failed to load keys:', err);
} finally {
setKeysLoading(false);
}
};
const handleAddKey = async (userId: number) => {
const name = window.prompt('请输入密钥名称', '默认密钥');
if (!name) return;
try {
await api.createUserKey(userId, { name });
void loadKeys(userId);
} catch (err: any) {
alert(err.message || '添加失败');
}
};
const handleResetKey = async (userId: number, keyId: number) => {
if (!window.confirm('重置将生成新密钥并使旧密钥失效,确定吗?')) return;
try {
await api.resetUserKey(userId, keyId);
void loadKeys(userId);
} catch (err: any) {
alert(err.message || '重置失败');
}
};
const handleDeleteKey = async (userId: number, keyId: number) => {
if (!window.confirm('确定删除该密钥吗?')) return;
try {
await api.deleteUserKey(userId, keyId);
void loadKeys(userId);
} catch (err: any) {
alert(err.message || '删除失败');
}
};
const handleEditKeyName = async (userId: number, keyId: number, currentName: string) => {
const name = window.prompt('修改密钥名称', currentName);
if (!name || name === currentName) return;
try {
await api.updateUserKey(userId, keyId, { name });
void loadKeys(userId);
} catch (err: any) {
alert(err.message || '修改失败');
}
};
const handleToggleKey = async (userId: number, keyId: number, enabled: boolean) => {
try {
await api.updateUserKey(userId, keyId, { enabled });
void loadKeys(userId);
} catch (err: any) {
alert(err.message || '更新失败');
}
};
const toggleExpand = (userId: number) => { const toggleExpand = (userId: number) => {
if (expandedUserId === userId) { if (expandedUserId === userId) {
setExpandedUserId(null); setExpandedUserId(null);
setUsageData(null); setUsageData(null);
setKeysData([]);
} else { } else {
setExpandedUserId(userId); setExpandedUserId(userId);
setUsageData(null); setUsageData(null);
setKeysData([]);
if (expandedTab === 'usage') {
void loadUsage(userId); void loadUsage(userId);
} else {
void loadKeys(userId);
}
}
};
const handleExpandedTabChange = (tab: 'usage' | 'keys', userId: number) => {
setExpandedTab(tab);
if (tab === 'usage' && !usageData) {
void loadUsage(userId);
} else if (tab === 'keys' && keysData.length === 0) {
void loadKeys(userId);
} }
}; };
@@ -519,8 +600,30 @@ export default function UserManagement() {
<tr className="usage-detail-row"> <tr className="usage-detail-row">
<td colSpan={9}> <td colSpan={9}>
<div className="usage-detail-container animate-fade-in" style={{ padding: '16px 20px', background: 'var(--color-bg-alt)', borderBottom: '1px solid var(--color-border)' }}> <div className="usage-detail-container animate-fade-in" style={{ padding: '16px 20px', background: 'var(--color-bg-alt)', borderBottom: '1px solid var(--color-border)' }}>
<h4 style={{ margin: '0 0 12px 0', fontSize: 14 }}>用量详情 - {user.username}</h4> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
{usageLoading ? ( <div style={{ display: 'flex', gap: 20 }}>
<button
type="button"
className={`btn-link ${expandedTab === 'usage' ? 'btn-link-primary' : ''}`}
style={{ fontWeight: expandedTab === 'usage' ? 600 : 400, padding: 0, fontSize: 14 }}
onClick={(e) => { e.stopPropagation(); handleExpandedTabChange('usage', user.id); }}
>
用量详情
</button>
<button
type="button"
className={`btn-link ${expandedTab === 'keys' ? 'btn-link-primary' : ''}`}
style={{ fontWeight: expandedTab === 'keys' ? 600 : 400, padding: 0, fontSize: 14 }}
onClick={(e) => { e.stopPropagation(); handleExpandedTabChange('keys', user.id); }}
>
API 密钥管理
</button>
</div>
<div style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>用户: {user.username} (ID: {user.id})</div>
</div>
{expandedTab === 'usage' ? (
usageLoading ? (
<div style={{ padding: '20px 0', textAlign: 'center', color: 'var(--color-text-muted)' }}>加载中...</div> <div style={{ padding: '20px 0', textAlign: 'center', color: 'var(--color-text-muted)' }}>加载中...</div>
) : usageData ? ( ) : usageData ? (
<div className="usage-detail-content"> <div className="usage-detail-content">
@@ -593,6 +696,60 @@ export default function UserManagement() {
</div> </div>
) : ( ) : (
<div style={{ padding: '20px 0', textAlign: 'center', color: 'var(--color-text-muted)' }}>获取数据失败</div> <div style={{ padding: '20px 0', textAlign: 'center', color: 'var(--color-text-muted)' }}>获取数据失败</div>
)
) : (
<div className="keys-management-content">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>API 密钥列表</div>
<button type="button" className="btn btn-primary btn-sm" onClick={(e) => { e.stopPropagation(); handleAddKey(user.id); }}>新建密钥</button>
</div>
{keysLoading ? (
<div style={{ padding: '20px 0', textAlign: 'center', color: 'var(--color-text-muted)' }}>加载中...</div>
) : (
<div className="table-responsive" style={{ border: '1px solid var(--color-border)', borderRadius: 8 }}>
<table className="data-table" style={{ fontSize: 12 }}>
<thead>
<tr>
<th>名称</th>
<th>密钥内容</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{keysData.map((k: any) => (
<tr key={k.id}>
<td style={{ cursor: 'pointer' }} onClick={(e) => { e.stopPropagation(); handleEditKeyName(user.id, k.id, k.name); }}>
{k.name} <span style={{ fontSize: 10, color: 'var(--color-primary)', marginLeft: 4 }}>✎</span>
</td>
<td><code style={{ fontSize: 11, background: 'var(--color-bg)', padding: '2px 4px' }}>{k.key}</code></td>
<td>
<span
className={`badge ${k.enabled ? 'badge-success' : 'badge-muted'}`}
style={{ fontSize: 10, cursor: 'pointer' }}
onClick={(e) => { e.stopPropagation(); handleToggleKey(user.id, k.id, !k.enabled); }}
>
{k.enabled ? '已启用' : '已禁用'}
</span>
</td>
<td>{formatDate(k.createdAt || k.created_at)}</td>
<td>
<div style={{ display: 'flex', gap: 10 }}>
<button type="button" className="btn btn-link btn-link-primary" style={{ fontSize: 12 }} onClick={(e) => { e.stopPropagation(); handleResetKey(user.id, k.id); }}>重置</button>
<button type="button" className="btn btn-link btn-link-danger" style={{ fontSize: 12 }} onClick={(e) => { e.stopPropagation(); handleDeleteKey(user.id, k.id); }}>删除</button>
</div>
</td>
</tr>
))}
{keysData.length === 0 && (
<tr><td colSpan={5} style={{ textAlign: 'center', color: 'var(--color-text-muted)', padding: '20px 0' }}>暂无密钥</td></tr>
)}
</tbody>
</table>
</div>
)}
</div>
)} )}
</div> </div>
</td> </td>