init
This commit is contained in:
commit
37a608fb45
44 files changed
+5222
No files matched your search
@@ -0,0 +1,8 @@
|
||||
codemate
|
||||
*.pyc
|
||||
.claude
|
||||
data
|
||||
logs
|
||||
.opencode
|
||||
.history
|
||||
config.yaml
|
||||
@@ -0,0 +1,147 @@
|
||||
# 项目记忆(ai_proxy)
|
||||
适用范围:`d:\001Code\008llm\003ai_proxy`
|
||||
|
||||
## 基础约束
|
||||
- 所有文本文件使用 `UTF-8`(无 BOM)。
|
||||
- 运行日志统一英文。
|
||||
- 代码注释和 docstring 目标规范为中文。
|
||||
|
||||
## 项目定位
|
||||
- 这是一个 Flask + gevent 的 OpenAI 兼容代理服务。
|
||||
- 核心能力是将 `/v1/chat/completions` 请求路由到配置中的上游 Provider,并支持 Hook 扩展、白名单控制、后台管理和日志统计。
|
||||
|
||||
## 启动与装配链路
|
||||
- 入口文件:`main.py`
|
||||
- 启动关键点:
|
||||
- 使用 `gevent.monkey.patch_all()`。
|
||||
- 支持 `--config` 指定配置文件,默认使用项目根目录 `config.yaml`。
|
||||
- 启动 `Application.run()`,由 gevent `WSGIServer` 提供服务。
|
||||
- 应用装配:`src/application/application.py`
|
||||
- 初始化顺序:
|
||||
- 读取配置 `ConfigManager`
|
||||
- 初始化日志(`app.log`、`access.log`)
|
||||
- 注册 `before_request` 访问日志钩子(记录 `ip + url`)
|
||||
- 创建 `AppContext`
|
||||
- 初始化 SQLite 连接工厂与仓储
|
||||
- 加载 Provider 与 Hook
|
||||
- 组装 Service 与 Controller,并注册路由
|
||||
|
||||
## 分层职责
|
||||
- `src/presentation`:HTTP 路由与输入输出校验。
|
||||
- `src/services`:业务逻辑编排(认证、用户、代理、日志)。
|
||||
- `src/repositories`:SQLite 数据访问。
|
||||
- `src/config`:配置读取与 provider/hook 装载。
|
||||
- `src/external`:上游 LLM provider 抽象与输出处理。
|
||||
- `src/common`:上下文对象与类型协议。
|
||||
- `src/utils`:数据库连接、IP 处理等通用工具。
|
||||
|
||||
## 核心请求流程(chat/completions)
|
||||
1. `ProxyController.chat_completions` 接收 `POST /v1/chat/completions`。
|
||||
2. 标准化客户端 IP(`normalize_ip`)。
|
||||
3. 若 `chat.whitelist_enabled=true`,按 IP 查用户且要求 `whitelist_access_enabled=1`。
|
||||
4. 校验请求体含 `model`。
|
||||
5. 强制补齐 `stream_options.include_usage=true`。
|
||||
6. 用 `ProviderManager.find_provider_by_model(model)` 找 provider。
|
||||
7. 调用 `ProxyService.proxy_request(...)` 转发到上游。
|
||||
8. 响应完成后通过回调写入 `request_logs` 并更新 `daily_request_stats`。
|
||||
|
||||
## 模型路由规则
|
||||
- Provider 在配置中声明 `name` 与 `model_list`。
|
||||
- 系统内部模型 key 格式是:`{provider_name}/{model_name}`。
|
||||
- 代理接口请求体中的 `model` 必须匹配上述 key(例如 `volc/glm-4-7-251222`)。
|
||||
- 转发给上游前,`ProxyService` 会将模型名改为去前缀后的真实模型名(仅保留 `/` 后最后一段)。
|
||||
|
||||
## Hook 机制
|
||||
- 在 `config.yaml` 的 provider 项可配置:
|
||||
- `header_hook`
|
||||
- `input_body_hook`
|
||||
- `output_body_hook`
|
||||
- Hook 路径规则:
|
||||
- 绝对路径直接加载。
|
||||
- 相对路径默认相对项目根的 `hooks/` 目录加载。
|
||||
- `ProviderManager` 对 hook 模块做缓存,避免重复导入。
|
||||
- Hook 函数约定:
|
||||
- `header_hook(ctx, headers) -> headers`
|
||||
- `input_body_hook(ctx, body) -> body`
|
||||
- `output_body_hook(ctx, data) -> Optional[data]`
|
||||
- `ctx` 常用字段:
|
||||
- `retry`(当前重试序号,从 0 开始)
|
||||
- `ip_address`
|
||||
- `user_id`
|
||||
|
||||
## 流式与非流式输出处理
|
||||
- 输出处理入口:`LLMProvider.apply_output_body_hook(...)`。
|
||||
- 非流式:
|
||||
- 读取完整 JSON,提取 `model` 与 `usage`(`total/prompt/completion_tokens`)。
|
||||
- 若配置了输出 hook,则以 hook 返回内容作为响应体。
|
||||
- 流式(SSE):
|
||||
- 按事件块解析 `data:` 行,逐块执行输出 hook(若存在)。
|
||||
- 识别 `[DONE]`。
|
||||
- 在流结束时触发 `on_complete`,用于日志落库与统计。
|
||||
|
||||
## 重试与网络行为
|
||||
- `ProxyService` 使用 `requests.Session`。
|
||||
- 每个 provider 可配置:
|
||||
- `timeout_seconds`(默认 300)
|
||||
- `max_retries`(默认 3)
|
||||
- `verify_ssl`(默认 false)
|
||||
- 仅对 `requests.exceptions.RequestException` 进行重试。
|
||||
|
||||
## 认证与权限
|
||||
- 认证是否启用取决于 `admin.username` 和 `admin.password` 是否都存在。
|
||||
- 启用后:
|
||||
- 页面接口未登录重定向 `/login`。
|
||||
- API 未登录返回 `401 {"error":"Unauthorized"}`。
|
||||
- 会话存储在内存(`AuthenticationService._sessions`),服务重启后会话失效。
|
||||
- cookie 默认:
|
||||
- `httponly=true`
|
||||
- `samesite=Lax`
|
||||
- `secure=false`
|
||||
- `max_age=86400`
|
||||
|
||||
## 主要路由
|
||||
- 代理接口:
|
||||
- `POST /v1/chat/completions`
|
||||
- `GET /v1/models`
|
||||
- 认证接口:
|
||||
- `GET /login`
|
||||
- `POST /api/login`
|
||||
- `GET /logout`
|
||||
- `POST /api/logout`
|
||||
- 用户管理:
|
||||
- `GET /api/users`
|
||||
- `POST /api/users`
|
||||
- `GET /api/users/<id>`
|
||||
- `PUT /api/users/<id>`
|
||||
- `DELETE /api/users/<id>`
|
||||
- `POST /api/users/<id>/toggle`
|
||||
- 统计与日志:
|
||||
- `GET /api/statistics`
|
||||
- `GET /api/request-logs`
|
||||
- `GET /api/usernames`
|
||||
- 页面:
|
||||
- `GET /`
|
||||
- `GET /users`
|
||||
|
||||
## 数据库结构(SQLite)
|
||||
- 默认路径:`data/requests.db`(可由 `database.path` 覆盖)。
|
||||
- 表:
|
||||
- `users`
|
||||
- 字段:`id`, `username`, `ip_address(unique)`, `whitelist_access_enabled`, `created_at`, `updated_at`
|
||||
- `request_logs`
|
||||
- 字段:`ip_address`, `request_model`, `response_model`, `total_tokens`, `prompt_tokens`, `completion_tokens`, `start_time`, `end_time`
|
||||
- `daily_request_stats`
|
||||
- 唯一键:`(stat_date, ip_address, request_model, response_model)`
|
||||
- 用于按天聚合请求次数与 token 数据
|
||||
|
||||
## 关键实现细节
|
||||
- `ProxyController` 会过滤 hop-by-hop 请求头后再转发。
|
||||
- `ProxyService` 会过滤 hop-by-hop 响应头返回给客户端。
|
||||
- `UserService` 对按 IP 查用户做了内存缓存,并在用户变更时失效缓存。
|
||||
- `UserRepository.get()` 会关联 `daily_request_stats` 返回用户累计请求/Token统计。
|
||||
- `LogRepository.insert()` 在单事务内同时写明细日志与日聚合统计。
|
||||
|
||||
## 当前开发注意事项
|
||||
- 代码中存在较多中文注释/docstring 乱码现象,修改相关文件时优先一并修复,避免继续扩散。
|
||||
- `output_body_hook` 在非流式分支若返回 `None`,当前会把响应体变成 `null`;新增 hook 时应明确返回原对象或改造此行为。
|
||||
- 会话是进程内存态,不适合多实例部署;如果未来需要横向扩展,应改为共享会话存储。
|
||||
@@ -0,0 +1,98 @@
|
||||
# LLM_Proxy
|
||||
|
||||
基于 Flask 的 OpenAI 兼容代理服务,聚焦模型转发、访问控制与请求统计。
|
||||
|
||||
## 功能特性
|
||||
- OpenAI 兼容接口:支持 `POST /v1/chat/completions` 和 `GET /v1/models`
|
||||
- 多 Provider / 多模型路由:按 `model` 自动选择上游 provider
|
||||
- Hook 扩展:单一 `hook` 配置,模块中通过 `Hook(BaseHook)` 按需实现阶段方法
|
||||
- 访问白名单控制:可按客户端 IP 控制是否允许调用代理接口
|
||||
- 管理后台与认证:支持登录、用户管理、统计查询页面与 API
|
||||
- 流式与非流式统一处理:支持 SSE 转发并提取 usage 信息
|
||||
|
||||
## 使用方式
|
||||
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
pip install flask gevent requests pyyaml urllib3
|
||||
```
|
||||
|
||||
### 2. 配置服务
|
||||
|
||||
编辑 `config.yaml`,至少确认以下字段:
|
||||
|
||||
- `server.host` / `server.port`
|
||||
- `chat.whitelist_enabled`
|
||||
- `providers[].name`
|
||||
- `providers[].api`
|
||||
- `providers[].api_key`
|
||||
- `providers[].model_list`
|
||||
|
||||
可选字段:
|
||||
|
||||
- `admin.username` / `admin.password`
|
||||
- `providers[].hook`
|
||||
- `logging.path` / `logging.level`
|
||||
- `database.path`
|
||||
|
||||
### 3. 启动服务
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
或指定配置文件:
|
||||
|
||||
```bash
|
||||
python main.py --config path/to/config.yaml
|
||||
```
|
||||
|
||||
### 4. 调用代理接口
|
||||
|
||||
获取模型列表:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:22026/v1/models
|
||||
```
|
||||
|
||||
发起聊天请求:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:22026/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "your-model-name",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"stream": false
|
||||
}'
|
||||
```
|
||||
|
||||
## Hook API(类接口)
|
||||
|
||||
Provider 配置使用单一 `hook` 字段。
|
||||
|
||||
```yaml
|
||||
providers:
|
||||
- name: your-provider
|
||||
api: https://example.com/v1/chat/completions
|
||||
api_key: your-key
|
||||
model_list:
|
||||
- your-model
|
||||
hook: example_hook.py
|
||||
```
|
||||
|
||||
Hook 模块必须导出一个名为 `Hook` 的类,通常继承 `BaseHook`:
|
||||
|
||||
```python
|
||||
from src.hooks import BaseHook, HookContext
|
||||
|
||||
class Hook(BaseHook):
|
||||
def header_hook(self, ctx: HookContext, headers: dict[str, str]) -> dict[str, str]:
|
||||
return headers
|
||||
```
|
||||
|
||||
可选实现的方法:
|
||||
- `header_hook(ctx, headers) -> headers`
|
||||
- `input_body_hook(ctx, body) -> body`
|
||||
- `output_body_hook(ctx, body) -> body`
|
||||
@@ -0,0 +1,37 @@
|
||||
server:
|
||||
port: 8080
|
||||
host: 0.0.0.0
|
||||
|
||||
chat:
|
||||
whitelist_enabled: true
|
||||
|
||||
admin:
|
||||
username: admin
|
||||
password: admin
|
||||
|
||||
providers:
|
||||
- name: volcengine
|
||||
api: https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions
|
||||
api_key:
|
||||
timeout_seconds: 300
|
||||
max_retries: 3
|
||||
verify_ssl: false
|
||||
model_list:
|
||||
- kimi-k2.5
|
||||
hook: example_hook.py
|
||||
|
||||
- name: volc
|
||||
api: https://ark.cn-beijing.volces.com/api/v3/chat/completions
|
||||
api_key:
|
||||
model_list:
|
||||
- glm-4-7-251222
|
||||
- deepseek-v3-2-251201
|
||||
- MiniMax-M2.5
|
||||
- Kimi-K2.5
|
||||
|
||||
# logging: # 可选
|
||||
# path:
|
||||
# level:
|
||||
|
||||
# database: # 可选
|
||||
# path:
|
||||
@@ -0,0 +1,40 @@
|
||||
from typing import Any
|
||||
|
||||
from src.hooks import BaseHook, HookContext
|
||||
|
||||
|
||||
class Hook(BaseHook):
|
||||
"""Example hook that demonstrates all available extension points."""
|
||||
|
||||
def header_hook(self, ctx: HookContext, headers: dict[str, str]) -> dict[str, str]:
|
||||
headers["X-Custom-Header"] = "custom-value"
|
||||
headers["X-User-Id"] = str(ctx.get("user_id", "default"))
|
||||
return headers
|
||||
|
||||
def input_body_hook(self, ctx: HookContext, body: dict[str, Any]) -> dict[str, Any]:
|
||||
messages = body.get("messages")
|
||||
if isinstance(messages, list):
|
||||
for msg in messages:
|
||||
if isinstance(msg, dict) and msg.get("role") == "user":
|
||||
original = str(msg.get("content", ""))
|
||||
msg["content"] = f"[PREFIX] {original}"
|
||||
return body
|
||||
|
||||
def output_body_hook(self, ctx: HookContext, body: Any) -> Any:
|
||||
if not isinstance(body, dict):
|
||||
return body
|
||||
|
||||
if body.get("type") == "done":
|
||||
return body
|
||||
|
||||
choices = body.get("choices")
|
||||
if isinstance(choices, list):
|
||||
for choice in choices:
|
||||
if not isinstance(choice, dict):
|
||||
continue
|
||||
delta = choice.get("delta")
|
||||
if isinstance(delta, dict) and "content" in delta:
|
||||
original = str(delta.get("content", ""))
|
||||
delta["content"] = f"[MODIFIED] {original}"
|
||||
|
||||
return body
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from gevent import monkey
|
||||
|
||||
monkey.patch_all()
|
||||
|
||||
import urllib3
|
||||
|
||||
from src.application import Application
|
||||
|
||||
# 禁用 HTTPS 证书告警
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="LLM Proxy Service")
|
||||
parser.add_argument(
|
||||
"--config", type=str, default=None, help="Configuration file path"
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def app() -> Application:
|
||||
args = parse_args()
|
||||
config_path = args.config
|
||||
project_root = Path(__file__).resolve().parent
|
||||
if config_path is None:
|
||||
resolved_config_path = project_root / "config.yaml"
|
||||
else:
|
||||
config_candidate = Path(config_path)
|
||||
resolved_config_path = (
|
||||
config_candidate
|
||||
if config_candidate.is_absolute()
|
||||
else (Path.cwd() / config_candidate).resolve()
|
||||
)
|
||||
|
||||
if not resolved_config_path.exists():
|
||||
raise FileNotFoundError(f"Configuration file not found: {resolved_config_path}")
|
||||
|
||||
return Application(resolved_config_path)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
app().run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""应用层导出。"""
|
||||
|
||||
from .application import Application
|
||||
from .app_context import AppContext, Logger
|
||||
|
||||
__all__ = ['Application', 'AppContext', 'Logger']
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""应用装配过程中共享的运行上下文。"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Protocol
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..config import ConfigManager
|
||||
from flask import Flask
|
||||
|
||||
|
||||
class Logger(Protocol):
|
||||
def info(self, msg: str) -> None: ...
|
||||
|
||||
def error(self, msg: str) -> None: ...
|
||||
|
||||
def warning(self, msg: str) -> None: ...
|
||||
|
||||
def debug(self, msg: str) -> None: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AppContext:
|
||||
"""跨层传递的最小依赖集合。"""
|
||||
|
||||
logger: Logger
|
||||
config_manager: "ConfigManager"
|
||||
root_path: Path
|
||||
flask_app: "Flask"
|
||||
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
from flask import request
|
||||
|
||||
from .app_context import AppContext
|
||||
from ..config import ConfigManager, ProviderManager
|
||||
from ..presentation import (
|
||||
AuthenticationController,
|
||||
ProxyController,
|
||||
UserController,
|
||||
WebController,
|
||||
create_flask_app,
|
||||
)
|
||||
from ..repositories import LogRepository, UserRepository
|
||||
from ..services import AuthenticationService, LogService, ProxyService, UserService
|
||||
from ..utils.database import create_connection_factory
|
||||
|
||||
|
||||
class Application:
|
||||
"""应用装配入口,负责初始化配置、日志、仓储与控制器。"""
|
||||
|
||||
def __init__(self, config_path: Path):
|
||||
self._config_path = config_path
|
||||
self._flask_app = create_flask_app()
|
||||
self._root_path = Path(__file__).resolve().parents[2]
|
||||
|
||||
self._setup_config()
|
||||
self._setup_logging()
|
||||
self._setup_request_access_logging()
|
||||
self._setup_context()
|
||||
self._setup_repositories()
|
||||
self._setup_provider_manager()
|
||||
self._setup_controllers()
|
||||
|
||||
self._logger.info("Application initialized successfully")
|
||||
|
||||
def _setup_config(self) -> None:
|
||||
"""初始化配置管理器。"""
|
||||
self._config_manager = ConfigManager(self._config_path, self._root_path)
|
||||
|
||||
def _setup_logging(self) -> None:
|
||||
"""初始化应用日志与访问日志。"""
|
||||
log_path = Path(self._config_manager.get_log_path())
|
||||
log_level = self._config_manager.get_log_level()
|
||||
|
||||
log_path.mkdir(parents=True, exist_ok=True)
|
||||
level = getattr(logging, log_level.upper(), logging.INFO)
|
||||
|
||||
formatter = logging.Formatter(
|
||||
"%(asctime)s|%(name)s|%(filename)s:%(lineno)d|%(levelname)s|%(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
|
||||
logger = logging.getLogger("app")
|
||||
logger.handlers.clear()
|
||||
|
||||
app_log_handler = RotatingFileHandler(
|
||||
log_path / "app.log",
|
||||
maxBytes=10 * 1024 * 1024,
|
||||
backupCount=3,
|
||||
encoding="utf-8",
|
||||
)
|
||||
app_log_handler.setFormatter(formatter)
|
||||
app_log_handler.setLevel(level)
|
||||
logger.addHandler(app_log_handler)
|
||||
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setFormatter(formatter)
|
||||
console_handler.setLevel(level)
|
||||
logger.addHandler(console_handler)
|
||||
logger.setLevel(level)
|
||||
|
||||
access_logger = logging.getLogger("access")
|
||||
access_logger.handlers.clear()
|
||||
access_handler = RotatingFileHandler(
|
||||
log_path / "access.log",
|
||||
maxBytes=10 * 1024 * 1024,
|
||||
backupCount=3,
|
||||
encoding="utf-8",
|
||||
)
|
||||
access_handler.setFormatter(
|
||||
logging.Formatter(
|
||||
"%(asctime)s|%(name)s|%(filename)s:%(lineno)d|%(levelname)s|%(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
)
|
||||
access_handler.setLevel(level)
|
||||
access_logger.addHandler(access_handler)
|
||||
access_logger.setLevel(level)
|
||||
|
||||
self._logger = logger
|
||||
self._flask_app.logger = logger
|
||||
self._flask_app.access_logger = access_logger
|
||||
|
||||
def _setup_request_access_logging(self) -> None:
|
||||
"""注册请求访问日志钩子,仅记录 IP 与 URL。"""
|
||||
|
||||
@self._flask_app.before_request
|
||||
def log_request_access() -> None:
|
||||
client_ip = request.remote_addr or "-"
|
||||
requested_url = request.url
|
||||
model = None
|
||||
if (request.content_length or 0) > 0 and request.is_json:
|
||||
payload = request.get_json(silent=True)
|
||||
if isinstance(payload, dict):
|
||||
model = payload.get("model")
|
||||
|
||||
log_message = f"ip={client_ip} url={requested_url}"
|
||||
if model is not None:
|
||||
log_message = f"{log_message} model={model}"
|
||||
self._flask_app.access_logger.info(log_message)
|
||||
|
||||
def _setup_context(self) -> None:
|
||||
"""创建全局运行上下文。"""
|
||||
self._ctx = AppContext(
|
||||
logger=self._logger,
|
||||
config_manager=self._config_manager,
|
||||
root_path=self._root_path,
|
||||
flask_app=self._flask_app,
|
||||
)
|
||||
|
||||
def _setup_repositories(self) -> None:
|
||||
"""初始化数据库连接工厂与仓储层。"""
|
||||
db_path = Path(self._config_manager.get_database_path())
|
||||
self._db_connection_factory = create_connection_factory(db_path)
|
||||
self._user_repository = UserRepository(self._db_connection_factory)
|
||||
self._log_repository = LogRepository(self._db_connection_factory)
|
||||
|
||||
def _setup_provider_manager(self) -> None:
|
||||
"""加载 provider 配置并注册可用模型。"""
|
||||
self._provider_manager = ProviderManager(self._ctx)
|
||||
config_dict = self._config_manager.get_raw_config()
|
||||
providers_config = config_dict.get("providers", [])
|
||||
self._provider_manager.load_providers(providers_config)
|
||||
|
||||
def _setup_controllers(self) -> None:
|
||||
"""初始化服务层并完成路由注册。"""
|
||||
auth_service = AuthenticationService(self._ctx)
|
||||
user_service = UserService(self._ctx, self._user_repository)
|
||||
proxy_service = ProxyService(self._ctx)
|
||||
log_service = LogService(self._ctx, self._log_repository)
|
||||
|
||||
self._auth_controller = AuthenticationController(self._ctx, auth_service)
|
||||
self._user_controller = UserController(self._ctx, user_service, auth_service)
|
||||
self._proxy_controller = ProxyController(
|
||||
self._ctx, proxy_service, user_service, log_service, self._provider_manager
|
||||
)
|
||||
self._web_controller = WebController(self._ctx, log_service, auth_service)
|
||||
|
||||
self._logger.info("All controllers initialized successfully")
|
||||
|
||||
def run(self) -> None:
|
||||
"""启动 WSGI 服务。"""
|
||||
host = self._config_manager.get_server_host()
|
||||
port = self._config_manager.get_server_port()
|
||||
|
||||
from gevent.pywsgi import WSGIServer
|
||||
|
||||
server = WSGIServer((host, port), self._flask_app)
|
||||
self._logger.info(f"Starting LLM Proxy on {host}:{port}...")
|
||||
server.serve_forever()
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""配置层导出。"""
|
||||
|
||||
from .config_manager import ConfigManager
|
||||
from .provider_manager import ProviderManager
|
||||
|
||||
__all__ = [
|
||||
"ConfigManager",
|
||||
"ProviderManager",
|
||||
]
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""配置访问层。"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Union
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
class ConfigManager:
|
||||
"""提供配置项的类型化读取方法。"""
|
||||
|
||||
def __init__(self, config_path: Path, root_path: Path):
|
||||
self._config = self._load_config(config_path)
|
||||
self._root_path = Path(root_path).resolve()
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
value: Any = self._config
|
||||
for part in key.split('.'):
|
||||
if isinstance(value, dict) and part in value:
|
||||
value = value[part]
|
||||
else:
|
||||
return default
|
||||
return value
|
||||
|
||||
def get_server_port(self) -> int:
|
||||
return int(self.get('server.port', 22026))
|
||||
|
||||
def get_server_host(self) -> str:
|
||||
return str(self.get('server.host', '0.0.0.0'))
|
||||
|
||||
def get_admin_config(self) -> Optional[Dict[str, str]]:
|
||||
admin = self.get('admin')
|
||||
return admin if isinstance(admin, dict) else None
|
||||
|
||||
def is_auth_enabled(self) -> bool:
|
||||
admin = self.get_admin_config()
|
||||
return bool(admin and admin.get('username') and admin.get('password'))
|
||||
|
||||
def is_chat_whitelist_enabled(self) -> bool:
|
||||
value = self.get('chat.whitelist_enabled', False)
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in {'1', 'true', 'yes', 'on'}
|
||||
if isinstance(value, int):
|
||||
return value != 0
|
||||
return False
|
||||
|
||||
def get_database_path(self) -> str:
|
||||
return self.get('database.path', self._root_path / 'data/requests.db')
|
||||
|
||||
def get_log_path(self) -> str:
|
||||
return self.get('logging.path', self._root_path / 'logs')
|
||||
|
||||
def get_log_level(self) -> str:
|
||||
return self.get('logging.level', 'INFO').upper()
|
||||
|
||||
def get_raw_config(self) -> Dict[str, Any]:
|
||||
return self._config.copy()
|
||||
|
||||
@staticmethod
|
||||
def _load_config(config_path: Union[str, Path]) -> Dict[str, Any]:
|
||||
path = Path(config_path).resolve()
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f'Configuration file not found: {path}')
|
||||
|
||||
with open(path, 'r', encoding='utf-8') as file:
|
||||
return yaml.safe_load(file) or {}
|
||||
@@ -0,0 +1,162 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Provider 管理器:负责加载配置与可选钩子模块。"""
|
||||
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import inspect
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ..application.app_context import AppContext, Logger
|
||||
from ..external import LLMProvider
|
||||
from ..hooks import HookModule
|
||||
|
||||
|
||||
class ProviderManager:
|
||||
"""管理模型与 provider 的映射关系。"""
|
||||
|
||||
def __init__(self, ctx: AppContext):
|
||||
self._ctx = ctx
|
||||
self._base_dir = ctx.root_path.resolve()
|
||||
self._logger: Logger = ctx.logger
|
||||
self._provider_by_model: Dict[str, LLMProvider] = {}
|
||||
self._provider_names: set[str] = set()
|
||||
self._hook_cache: Dict[str, Optional[HookModule]] = {}
|
||||
|
||||
def load_providers(self, providers_config: List[Dict[str, Any]]) -> None:
|
||||
"""重载 provider 配置。"""
|
||||
self._provider_by_model.clear()
|
||||
self._provider_names.clear()
|
||||
self._hook_cache.clear()
|
||||
|
||||
for provider_cfg in providers_config:
|
||||
self._load_provider(provider_cfg)
|
||||
|
||||
def _load_provider(self, config: Dict[str, Any]) -> None:
|
||||
"""加载单个 provider,并注册其模型映射。"""
|
||||
name = config.get('name')
|
||||
api = config.get('api', '')
|
||||
api_key = config.get('api_key', '')
|
||||
model_list = config.get('model_list', [])
|
||||
timeout_seconds = self._to_positive_int(config.get('timeout_seconds', 300), default=300)
|
||||
max_retries = self._to_positive_int(config.get('max_retries', 3), default=3)
|
||||
verify_ssl = self._to_bool(config.get('verify_ssl', False), default=False)
|
||||
|
||||
if name in self._provider_names:
|
||||
raise ValueError(f"Duplicate provider name detected: {name}")
|
||||
|
||||
if not api:
|
||||
self._logger.warning(f"Provider '{name}' skipped: api is empty")
|
||||
return
|
||||
|
||||
if not model_list:
|
||||
self._logger.warning(f"Provider '{name}' skipped: model_list is empty")
|
||||
return
|
||||
|
||||
hook = self._load_hook(config.get('hook'))
|
||||
|
||||
provider = LLMProvider(
|
||||
name=name,
|
||||
api=api,
|
||||
api_key=api_key,
|
||||
model_list=model_list,
|
||||
timeout_seconds=timeout_seconds,
|
||||
max_retries=max_retries,
|
||||
verify_ssl=verify_ssl,
|
||||
hook=hook,
|
||||
)
|
||||
|
||||
for model in model_list:
|
||||
key = f'{name}/{model}'
|
||||
if key in self._provider_by_model:
|
||||
raise ValueError(f'Duplicate provider model mapping detected: {key}')
|
||||
self._provider_by_model[key] = provider
|
||||
|
||||
self._provider_names.add(name)
|
||||
self._logger.info(f'Loaded provider: {name}, models: {model_list}')
|
||||
|
||||
def _load_hook(self, hook_path: Optional[str]) -> Optional[HookModule]:
|
||||
"""按路径加载钩子模块,并做缓存复用。"""
|
||||
if not hook_path:
|
||||
return None
|
||||
|
||||
if Path(hook_path).is_absolute():
|
||||
hook_file = Path(hook_path).resolve()
|
||||
else:
|
||||
hooks_dir = self._base_dir / 'hooks'
|
||||
hook_file = (hooks_dir / hook_path).resolve()
|
||||
|
||||
cache_key = str(hook_file)
|
||||
if cache_key in self._hook_cache:
|
||||
return self._hook_cache[cache_key]
|
||||
|
||||
if not hook_file.exists():
|
||||
self._logger.warning(f'Hook file not found: {hook_file}')
|
||||
self._hook_cache[cache_key] = None
|
||||
return None
|
||||
|
||||
try:
|
||||
hook_dir = str(hook_file.parent)
|
||||
path_inserted = False
|
||||
if hook_dir not in sys.path:
|
||||
sys.path.insert(0, hook_dir)
|
||||
path_inserted = True
|
||||
|
||||
hook_name = hook_file.stem
|
||||
normalized_path = hook_file.resolve().as_posix().lower()
|
||||
path_hash = hashlib.sha1(normalized_path.encode('utf-8')).hexdigest()[:12]
|
||||
module_name = f'hook_{hook_name}_{path_hash}'
|
||||
spec = importlib.util.spec_from_file_location(module_name, hook_file)
|
||||
if spec and spec.loader:
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
hook_class = getattr(module, 'Hook', None)
|
||||
if hook_class is None or not inspect.isclass(hook_class):
|
||||
self._logger.error(f'Hook file must export a class named Hook: {hook_file}')
|
||||
self._hook_cache[cache_key] = None
|
||||
return None
|
||||
hook_instance = hook_class()
|
||||
self._hook_cache[cache_key] = hook_instance
|
||||
self._logger.info(f'Hook loaded successfully: {hook_file}')
|
||||
return hook_instance
|
||||
except Exception as exc:
|
||||
self._logger.error(f'Failed to load hook {hook_file}: {exc}')
|
||||
finally:
|
||||
if path_inserted:
|
||||
try:
|
||||
sys.path.remove(hook_dir)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
self._hook_cache[cache_key] = None
|
||||
return None
|
||||
|
||||
def find_provider_by_model(self, model_name: str) -> Optional[LLMProvider]:
|
||||
return self._provider_by_model.get(model_name)
|
||||
|
||||
def get_all_models(self) -> List[str]:
|
||||
return sorted(self._provider_by_model.keys())
|
||||
|
||||
@staticmethod
|
||||
def _to_positive_int(value: Any, default: int) -> int:
|
||||
try:
|
||||
parsed = int(value)
|
||||
return parsed if parsed > 0 else default
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _to_bool(value: Any, default: bool) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
lowered = value.strip().lower()
|
||||
if lowered in {'true', '1', 'yes', 'on'}:
|
||||
return True
|
||||
if lowered in {'false', '0', 'no', 'off'}:
|
||||
return False
|
||||
if isinstance(value, (int, float)):
|
||||
return bool(value)
|
||||
return default
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""外部服务层导出。"""
|
||||
|
||||
from .llm_provider import LLMProvider
|
||||
|
||||
__all__ = ['LLMProvider']
|
||||
Vendored
+202
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""外部 LLM Provider 抽象。"""
|
||||
|
||||
import json
|
||||
from typing import Any, Callable, Dict, Iterator, Optional
|
||||
|
||||
from flask import Response
|
||||
|
||||
from ..application.app_context import Logger
|
||||
from ..hooks import HookContext, HookModule
|
||||
|
||||
|
||||
class LLMProvider:
|
||||
"""封装 provider 配置与钩子处理流程。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
api: str,
|
||||
api_key: Optional[str] = None,
|
||||
model_list: Optional[list] = None,
|
||||
timeout_seconds: int = 300,
|
||||
max_retries: int = 3,
|
||||
verify_ssl: bool = False,
|
||||
hook: Optional[HookModule] = None,
|
||||
):
|
||||
self.name = name
|
||||
self.api = api
|
||||
self.api_key = api_key
|
||||
self.model_list = model_list or []
|
||||
self.timeout_seconds = timeout_seconds
|
||||
self.max_retries = max_retries
|
||||
self.verify_ssl = verify_ssl
|
||||
self.hook = hook
|
||||
|
||||
def apply_header_hook(self, ctx: HookContext, headers: Dict[str, str]) -> Dict[str, str]:
|
||||
if self.hook and hasattr(self.hook, 'header_hook'):
|
||||
return self.hook.header_hook(ctx, headers)
|
||||
return headers
|
||||
|
||||
def apply_input_body_hook(self, ctx: HookContext, body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if self.hook and hasattr(self.hook, 'input_body_hook'):
|
||||
return self.hook.input_body_hook(ctx, body)
|
||||
return body
|
||||
|
||||
def apply_output_body_hook(
|
||||
self,
|
||||
ctx: HookContext,
|
||||
response: Any,
|
||||
is_stream: bool,
|
||||
filter_headers_func: Any,
|
||||
stream_context_func: Any,
|
||||
logger: Logger,
|
||||
on_complete: Optional[Callable[[Dict[str, Any]], None]] = None,
|
||||
) -> Response:
|
||||
hook_func = getattr(self.hook, 'output_body_hook', None) if self.hook else None
|
||||
meta: Dict[str, Any] = {
|
||||
'response_model': None,
|
||||
'total_tokens': 0,
|
||||
'prompt_tokens': 0,
|
||||
'completion_tokens': 0,
|
||||
}
|
||||
completed = False
|
||||
|
||||
def update_meta_from_payload(payload: Dict[str, Any]) -> None:
|
||||
model = payload.get('model')
|
||||
if model:
|
||||
meta['response_model'] = model
|
||||
usage = payload.get('usage')
|
||||
if isinstance(usage, dict):
|
||||
if 'total_tokens' in usage and usage.get('total_tokens') is not None:
|
||||
meta['total_tokens'] = int(usage.get('total_tokens') or 0)
|
||||
if 'prompt_tokens' in usage and usage.get('prompt_tokens') is not None:
|
||||
meta['prompt_tokens'] = int(usage.get('prompt_tokens') or 0)
|
||||
if 'completion_tokens' in usage and usage.get('completion_tokens') is not None:
|
||||
meta['completion_tokens'] = int(usage.get('completion_tokens') or 0)
|
||||
|
||||
def safe_on_complete() -> None:
|
||||
nonlocal completed
|
||||
if completed:
|
||||
return
|
||||
completed = True
|
||||
if on_complete:
|
||||
try:
|
||||
on_complete(meta)
|
||||
except Exception as exc:
|
||||
logger.error(f'Error in on_complete callback: {exc}')
|
||||
|
||||
def parse_sse_line(line: str) -> Optional[str]:
|
||||
"""解析单行 SSE data 内容。"""
|
||||
stripped = line.strip()
|
||||
if not stripped or not stripped.startswith('data:'):
|
||||
return None
|
||||
return stripped[5:].strip()
|
||||
|
||||
def process_sse_event(event_text: str) -> Iterator[bytes]:
|
||||
"""处理单个 SSE 事件块并执行输出钩子。"""
|
||||
data_lines = []
|
||||
for raw_line in event_text.splitlines():
|
||||
if raw_line.startswith('data:'):
|
||||
data_lines.append(raw_line[5:].strip())
|
||||
|
||||
if not data_lines:
|
||||
if event_text.strip():
|
||||
yield (event_text + '\n\n').encode('utf-8')
|
||||
return
|
||||
|
||||
data_str = '\n'.join(data_lines).strip()
|
||||
if not data_str:
|
||||
return
|
||||
|
||||
if data_str == '[DONE]':
|
||||
yield b'data: [DONE]\n\n'
|
||||
return
|
||||
|
||||
try:
|
||||
data = json.loads(data_str)
|
||||
except json.JSONDecodeError:
|
||||
yield f'data: {data_str}\n\n'.encode('utf-8')
|
||||
return
|
||||
|
||||
if isinstance(data, dict):
|
||||
update_meta_from_payload(data)
|
||||
|
||||
if hook_func:
|
||||
modified = hook_func(ctx, data)
|
||||
if modified is not None:
|
||||
yield f"data: {json.dumps(modified)}\n\n".encode('utf-8')
|
||||
else:
|
||||
yield f'data: {data_str}\n\n'.encode('utf-8')
|
||||
else:
|
||||
yield f'data: {data_str}\n\n'.encode('utf-8')
|
||||
|
||||
if not is_stream:
|
||||
body = response.content
|
||||
response_body = body
|
||||
try:
|
||||
payload = json.loads(body.decode('utf-8'))
|
||||
if isinstance(payload, dict):
|
||||
update_meta_from_payload(payload)
|
||||
if hook_func:
|
||||
modified = hook_func(ctx, payload)
|
||||
response_body = json.dumps(modified).encode('utf-8')
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.warning(
|
||||
'Non-stream response is not valid JSON, skip output_body_hook: status=%s content_type=%s error=%s',
|
||||
response.status_code,
|
||||
response.headers.get('Content-Type'),
|
||||
exc,
|
||||
)
|
||||
finally:
|
||||
safe_on_complete()
|
||||
|
||||
return Response(
|
||||
response_body,
|
||||
status=response.status_code,
|
||||
headers=filter_headers_func(response.headers),
|
||||
mimetype=response.headers.get('Content-Type', 'application/json'),
|
||||
)
|
||||
|
||||
def generate() -> Iterator[bytes]:
|
||||
buffer = ''
|
||||
try:
|
||||
for chunk in response.iter_content(chunk_size=None):
|
||||
if not chunk:
|
||||
continue
|
||||
|
||||
try:
|
||||
buffer += chunk.decode('utf-8')
|
||||
except UnicodeDecodeError:
|
||||
yield chunk
|
||||
continue
|
||||
|
||||
while '\n\n' in buffer:
|
||||
event_text, buffer = buffer.split('\n\n', 1)
|
||||
yield from process_sse_event(event_text)
|
||||
|
||||
if buffer:
|
||||
# 对不完整流做兜底:尽量解析已完整接收的 data 行。
|
||||
for line in buffer.splitlines():
|
||||
data_str = parse_sse_line(line)
|
||||
if data_str is None:
|
||||
continue
|
||||
if data_str == '[DONE]':
|
||||
yield b'data: [DONE]\n\n'
|
||||
continue
|
||||
try:
|
||||
data = json.loads(data_str)
|
||||
if isinstance(data, dict):
|
||||
update_meta_from_payload(data)
|
||||
except Exception:
|
||||
continue
|
||||
finally:
|
||||
safe_on_complete()
|
||||
|
||||
return Response(
|
||||
stream_context_func(generate()),
|
||||
status=response.status_code,
|
||||
headers=filter_headers_func(response.headers),
|
||||
mimetype=response.headers.get('Content-Type', 'text/event-stream'),
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Hook public exports."""
|
||||
|
||||
from .contracts import BaseHook, HookContext, HookModule
|
||||
|
||||
__all__ = ['BaseHook', 'HookContext', 'HookModule']
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Hook type contracts."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Protocol
|
||||
|
||||
from ..application.app_context import Logger
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HookContext:
|
||||
"""Context passed into hooks."""
|
||||
|
||||
retry: int
|
||||
root_path: Path
|
||||
logger: Logger
|
||||
|
||||
|
||||
class HookModule(Protocol):
|
||||
def header_hook(self, ctx: HookContext, headers: Dict[str, str]) -> Dict[str, str]:
|
||||
...
|
||||
|
||||
def input_body_hook(self, ctx: HookContext, body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
...
|
||||
|
||||
def output_body_hook(self, ctx: HookContext, body: Any) -> Any:
|
||||
...
|
||||
|
||||
|
||||
class BaseHook:
|
||||
"""Base hook implementation. Override only the methods you need."""
|
||||
|
||||
def header_hook(self, ctx: HookContext, headers: Dict[str, str]) -> Dict[str, str]:
|
||||
return headers
|
||||
|
||||
def input_body_hook(self, ctx: HookContext, body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return body
|
||||
|
||||
def output_body_hook(self, ctx: HookContext, body: Any) -> Any:
|
||||
return body
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""表现层导出。"""
|
||||
|
||||
from .app_factory import create_flask_app
|
||||
from .auth_controller import AuthenticationController
|
||||
from .proxy_controller import ProxyController
|
||||
from .user_controller import UserController
|
||||
from .web_controller import WebController
|
||||
|
||||
__all__ = [
|
||||
'create_flask_app',
|
||||
'AuthenticationController',
|
||||
'ProxyController',
|
||||
'UserController',
|
||||
'WebController',
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""创建 Flask 应用实例。"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask
|
||||
|
||||
|
||||
def create_flask_app() -> Flask:
|
||||
"""初始化模板目录与静态目录。"""
|
||||
templates_dir = Path(__file__).resolve().parent / 'templates'
|
||||
static_dir = Path(__file__).resolve().parent / 'static'
|
||||
|
||||
app = Flask(
|
||||
__name__,
|
||||
template_folder=str(templates_dir),
|
||||
static_folder=str(static_dir),
|
||||
static_url_path='/static',
|
||||
)
|
||||
app.config['JSON_AS_ASCII'] = False
|
||||
return app
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""认证控制器。"""
|
||||
|
||||
from flask import Response, jsonify, make_response, redirect, render_template, request
|
||||
from ..application.app_context import AppContext
|
||||
from ..services import AuthenticationService
|
||||
|
||||
|
||||
class AuthenticationController:
|
||||
"""处理登录/登出相关路由。"""
|
||||
|
||||
def __init__(self, ctx: AppContext, auth_service: AuthenticationService):
|
||||
self._ctx = ctx
|
||||
self._app = ctx.flask_app
|
||||
self._logger = ctx.logger
|
||||
self._auth_service = auth_service
|
||||
self._register_routes()
|
||||
|
||||
def _register_routes(self) -> None:
|
||||
"""注册认证相关路由。"""
|
||||
self._app.route('/login')(self.login_page)
|
||||
self._app.route('/api/login', methods=['POST'])(self.api_login)
|
||||
self._app.route('/logout')(self.logout)
|
||||
self._app.route('/api/logout', methods=['POST'])(self.api_logout)
|
||||
|
||||
def login_page(self) -> Response:
|
||||
"""返回登录页,已登录用户会被重定向到首页。"""
|
||||
if not self._auth_service.is_auth_enabled():
|
||||
return redirect('/')
|
||||
|
||||
session_token = request.cookies.get('session_token')
|
||||
if self._auth_service.validate_session(session_token):
|
||||
return redirect('/')
|
||||
|
||||
return render_template('login.html')
|
||||
|
||||
def api_login(self) -> Response:
|
||||
"""处理登录请求并写入 session cookie。"""
|
||||
if not self._auth_service.is_auth_enabled():
|
||||
return jsonify({'message': 'Authentication not enabled'}), 200
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
username = data.get('username')
|
||||
password = data.get('password')
|
||||
|
||||
if not username or not password:
|
||||
self._logger.warning("Login rejected: missing username or password")
|
||||
return jsonify({'error': 'Username and password are required'}), 400
|
||||
|
||||
if not self._auth_service.authenticate(username, password):
|
||||
self._logger.warning(f"Login failed: invalid credentials for username={username!r}")
|
||||
return jsonify({'error': 'Invalid username or password'}), 401
|
||||
|
||||
session_token = self._auth_service.create_session(username)
|
||||
self._logger.info(f"Login succeeded: username={username!r}")
|
||||
response = make_response(jsonify({'message': 'Login successful', 'username': username}))
|
||||
cookie_settings = self._auth_service.get_cookie_settings()
|
||||
response.set_cookie('session_token', session_token, **cookie_settings)
|
||||
return response
|
||||
|
||||
def logout(self) -> Response:
|
||||
"""处理页面登出并清理 cookie。"""
|
||||
session_token = request.cookies.get('session_token')
|
||||
if session_token:
|
||||
self._auth_service.destroy_session(session_token)
|
||||
self._logger.info("Logout succeeded via page route")
|
||||
|
||||
response = make_response(redirect('/login'))
|
||||
response.delete_cookie('session_token')
|
||||
return response
|
||||
|
||||
def api_logout(self) -> Response:
|
||||
"""处理 API 登出并清理 cookie。"""
|
||||
session_token = request.cookies.get('session_token')
|
||||
if session_token:
|
||||
self._auth_service.destroy_session(session_token)
|
||||
self._logger.info("Logout succeeded via API route")
|
||||
|
||||
response = make_response(jsonify({'message': 'Logout successful'}))
|
||||
response.delete_cookie('session_token')
|
||||
return response
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""认证装饰器。"""
|
||||
|
||||
from functools import wraps
|
||||
from typing import Callable
|
||||
|
||||
from flask import jsonify, redirect, request
|
||||
|
||||
from ..services import AuthenticationService
|
||||
|
||||
|
||||
def require_authentication(auth_service: AuthenticationService):
|
||||
"""生成认证校验装饰器。"""
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not auth_service.is_auth_enabled():
|
||||
return func(*args, **kwargs)
|
||||
|
||||
session_token = request.cookies.get("session_token")
|
||||
if not auth_service.validate_session(session_token):
|
||||
if request.is_json or request.path.startswith("/api/"):
|
||||
return jsonify({"error": "Unauthorized"}), 401
|
||||
return redirect("/login")
|
||||
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
return decorator
|
||||
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""代理控制器。"""
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from datetime import datetime
|
||||
from flask import Response, jsonify, request
|
||||
|
||||
from ..application.app_context import AppContext
|
||||
from ..config import ConfigManager, ProviderManager
|
||||
from ..services import LogService, ProxyService, UserService
|
||||
from ..utils import normalize_ip
|
||||
|
||||
|
||||
class ProxyController:
|
||||
"""处理模型代理相关路由。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ctx: AppContext,
|
||||
proxy_service: ProxyService,
|
||||
user_service: UserService,
|
||||
log_service: LogService,
|
||||
provider_manager: ProviderManager,
|
||||
):
|
||||
self._ctx = ctx
|
||||
self._app = ctx.flask_app
|
||||
self._logger = ctx.logger
|
||||
self._config_manager: ConfigManager = ctx.config_manager
|
||||
self._proxy_service = proxy_service
|
||||
self._user_service = user_service
|
||||
self._log_service = log_service
|
||||
self._provider_manager = provider_manager
|
||||
self._register_routes()
|
||||
|
||||
def _register_routes(self) -> None:
|
||||
"""注册代理路由。"""
|
||||
self._app.route("/v1/chat/completions", methods=["POST"])(self.chat_completions)
|
||||
self._app.route("/v1/models", methods=["GET"])(self.list_models)
|
||||
|
||||
def _get_user_by_ip(self, ip_address: str) -> Optional[Dict[str, Any]]:
|
||||
"""按 IP 查询白名单用户。"""
|
||||
return self._user_service.get_user_by_ip(ip_address, require_whitelist_access=True)
|
||||
|
||||
def _is_whitelist_required(self) -> bool:
|
||||
"""读取是否开启白名单访问控制。"""
|
||||
return self._config_manager.is_chat_whitelist_enabled()
|
||||
|
||||
def chat_completions(self) -> Response:
|
||||
"""代理上游 chat/completions 请求。"""
|
||||
try:
|
||||
client_ip = normalize_ip(request.remote_addr)
|
||||
self._logger.info(f"Proxy request received: ip={client_ip}")
|
||||
whitelist_required = self._is_whitelist_required()
|
||||
user = self._get_user_by_ip(client_ip) if whitelist_required else None
|
||||
if whitelist_required and not user:
|
||||
self._logger.warning(f"Proxy denied: ip={client_ip} is not in whitelist")
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"error": {
|
||||
"message": f"IP address {client_ip} is not in whitelist",
|
||||
"type": "access_denied",
|
||||
}
|
||||
}
|
||||
),
|
||||
403,
|
||||
)
|
||||
|
||||
request_data = request.get_json(silent=True)
|
||||
if not request_data or "model" not in request_data:
|
||||
self._logger.warning("Proxy rejected: missing model in request body")
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"error": {
|
||||
"message": "Missing 'model' in request body",
|
||||
"type": "invalid_request_error",
|
||||
}
|
||||
}
|
||||
),
|
||||
400,
|
||||
)
|
||||
|
||||
stream_options = request_data.get("stream_options")
|
||||
if not isinstance(stream_options, dict):
|
||||
stream_options = {}
|
||||
if stream_options.get("include_usage") is not True:
|
||||
stream_options["include_usage"] = True
|
||||
request_data["stream_options"] = stream_options
|
||||
|
||||
model_name = request_data["model"]
|
||||
provider = self._provider_manager.find_provider_by_model(model_name)
|
||||
if not provider:
|
||||
self._logger.warning(f"Proxy rejected: unknown model={model_name!r}")
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"error": {
|
||||
"message": f"Unknown model: {model_name}",
|
||||
"type": "invalid_request_error",
|
||||
}
|
||||
}
|
||||
),
|
||||
400,
|
||||
)
|
||||
|
||||
headers = self._filter_request_headers(request.headers)
|
||||
start_time = datetime.now()
|
||||
|
||||
def on_proxy_complete(response_meta: Dict[str, Any]) -> None:
|
||||
self._logger.info(
|
||||
"Proxy completed: model=%s response_model=%s total_tokens=%s ip=%s",
|
||||
model_name,
|
||||
response_meta.get("response_model"),
|
||||
response_meta.get("total_tokens", 0),
|
||||
client_ip,
|
||||
)
|
||||
self._log_service.log_request(
|
||||
request_model=model_name,
|
||||
response_model=response_meta.get("response_model"),
|
||||
total_tokens=response_meta.get("total_tokens", 0),
|
||||
prompt_tokens=response_meta.get("prompt_tokens", 0),
|
||||
completion_tokens=response_meta.get("completion_tokens", 0),
|
||||
start_time=start_time,
|
||||
end_time=datetime.now(),
|
||||
ip_address=client_ip,
|
||||
)
|
||||
|
||||
result, status_code = self._proxy_service.proxy_request(
|
||||
provider, request_data, headers, on_complete=on_proxy_complete
|
||||
)
|
||||
if result is None:
|
||||
self._logger.error(f"Proxy failed after retries: model={model_name!r}, ip={client_ip}")
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"error": {
|
||||
"message": "Request failed after retries",
|
||||
"type": "proxy_error",
|
||||
}
|
||||
}
|
||||
),
|
||||
status_code,
|
||||
)
|
||||
|
||||
return result
|
||||
except Exception as exc:
|
||||
self._logger.error(f"Error in chat_completions: {exc}")
|
||||
return (
|
||||
jsonify({"error": {"message": str(exc), "type": "internal_error"}}),
|
||||
500,
|
||||
)
|
||||
|
||||
def list_models(self) -> Response:
|
||||
"""返回当前可用模型列表。"""
|
||||
try:
|
||||
data = [{"id": model_key} for model_key in self._provider_manager.get_all_models()]
|
||||
return jsonify({"object": "list", "data": data})
|
||||
except Exception as exc:
|
||||
self._logger.error(f"Error listing models: {exc}")
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
|
||||
@staticmethod
|
||||
def _filter_request_headers(headers: Any) -> Dict[str, str]:
|
||||
"""过滤不应转发到上游的 hop-by-hop 请求头。"""
|
||||
excluded = {
|
||||
"host",
|
||||
"content-length",
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailers",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
}
|
||||
return {k: v for k, v in headers.items() if k.lower() not in excluded}
|
||||
@@ -0,0 +1,434 @@
|
||||
:root {
|
||||
--bg-page: #f4f8fc;
|
||||
--surface: #ffffff;
|
||||
--surface-soft: #f8fbff;
|
||||
--surface-hover: #f2f7fd;
|
||||
--text-primary: #0f172a;
|
||||
--text-secondary: #475569;
|
||||
--text-muted: #64748b;
|
||||
--line: #dbe6f2;
|
||||
--line-strong: #c6d7e8;
|
||||
--primary: #5f8fbe;
|
||||
--primary-hover: #4f82b4;
|
||||
--primary-active: #4575a4;
|
||||
--danger: #d66a76;
|
||||
--danger-hover: #c25a66;
|
||||
--success: #5b9a87;
|
||||
--focus: rgba(95, 143, 190, 0.24);
|
||||
--radius-sm: 10px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 14px;
|
||||
--shadow-soft: 0 8px 24px rgba(15, 23, 42, 0.06);
|
||||
--shadow-card: 0 2px 10px rgba(15, 23, 42, 0.05);
|
||||
--transition: 180ms ease;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body.app-page {
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
color: var(--text-primary);
|
||||
overflow-x: hidden;
|
||||
background:
|
||||
radial-gradient(1100px 620px at -10% -15%, #eaf2fb 0%, transparent 60%),
|
||||
radial-gradient(980px 640px at 100% 0%, #eef4fb 0%, transparent 62%),
|
||||
var(--bg-page);
|
||||
}
|
||||
|
||||
.app-page .container {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-soft);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.app-page .header {
|
||||
padding: 24px 30px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: linear-gradient(180deg, #fbfdff 0%, #f5f9ff 100%);
|
||||
}
|
||||
|
||||
.app-page .header-title h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
line-height: 1.2;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.01em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.app-page .header-title p {
|
||||
margin: 8px 0 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.app-page .header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.app-page .header-nav {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.app-page .header-nav-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: color var(--transition), background-color var(--transition), border-color var(--transition), box-shadow var(--transition);
|
||||
}
|
||||
|
||||
.app-page .header-nav-link:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-hover);
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
.app-page .header-nav-link.active {
|
||||
color: #fff;
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.app-page .btn,
|
||||
.app-page .header-nav-btn {
|
||||
min-height: 40px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0 16px;
|
||||
border: 1px solid transparent;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
transition: color var(--transition), background-color var(--transition), border-color var(--transition), box-shadow var(--transition);
|
||||
}
|
||||
|
||||
.app-page .btn-primary,
|
||||
.app-page .header-nav-btn,
|
||||
.app-page .btn-primary:focus {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.app-page .btn-primary:hover,
|
||||
.app-page .header-nav-btn:hover {
|
||||
background: var(--primary-hover);
|
||||
border-color: var(--primary-hover);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.app-page .btn-primary:active,
|
||||
.app-page .header-nav-btn:active {
|
||||
background: var(--primary-active);
|
||||
border-color: var(--primary-active);
|
||||
}
|
||||
|
||||
.app-page .btn-secondary {
|
||||
background: #fff;
|
||||
color: var(--text-secondary);
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
|
||||
.app-page .btn-secondary:hover {
|
||||
background: var(--surface-hover);
|
||||
border-color: #b5c9de;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.app-page .btn-primary:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.app-page .form-group input,
|
||||
.app-page .form-group textarea,
|
||||
.app-page .form-control,
|
||||
.app-page .quick-jump input,
|
||||
.app-page .page-size-controls select {
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: #fff;
|
||||
color: var(--text-primary);
|
||||
padding: 0 12px;
|
||||
font-size: 14px;
|
||||
transition: border-color var(--transition), box-shadow var(--transition), background-color var(--transition);
|
||||
}
|
||||
|
||||
.app-page .form-group textarea {
|
||||
min-height: 82px;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.app-page .form-group input:hover,
|
||||
.app-page .form-control:hover,
|
||||
.app-page .quick-jump input:hover,
|
||||
.app-page .page-size-controls select:hover {
|
||||
border-color: #b9cbde;
|
||||
background: #fcfeff;
|
||||
}
|
||||
|
||||
.app-page .form-group input:focus,
|
||||
.app-page .form-group textarea:focus,
|
||||
.app-page .form-control:focus,
|
||||
.app-page .quick-jump input:focus,
|
||||
.app-page .page-size-controls select:focus,
|
||||
.app-page .custom-select-trigger:focus,
|
||||
.app-page .header-nav-link:focus-visible,
|
||||
.app-page .header-nav-btn:focus-visible,
|
||||
.app-page .btn:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px var(--focus);
|
||||
}
|
||||
|
||||
.app-page .native-select-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-page .custom-select {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.app-page .custom-select-trigger {
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
padding: 0 34px 0 12px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 9px;
|
||||
background: #fff;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: border-color var(--transition), box-shadow var(--transition), background-color var(--transition);
|
||||
}
|
||||
|
||||
.app-page .custom-select-trigger::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-right: 1.5px solid var(--text-muted);
|
||||
border-bottom: 1.5px solid var(--text-muted);
|
||||
transform: translateY(-60%) rotate(45deg);
|
||||
transition: transform var(--transition), border-color var(--transition);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-page .custom-select:hover .custom-select-trigger {
|
||||
border-color: #b9cbde;
|
||||
background: #fcfeff;
|
||||
}
|
||||
|
||||
.app-page .custom-select.open .custom-select-trigger {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px var(--focus);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.app-page .custom-select.open .custom-select-trigger::after {
|
||||
transform: translateY(-35%) rotate(225deg);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.app-page .custom-select-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 50;
|
||||
display: none;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
background: #fff;
|
||||
box-shadow: var(--shadow-soft);
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.app-page .custom-select.open .custom-select-menu {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.app-page .custom-select.drop-up .custom-select-menu {
|
||||
top: auto;
|
||||
bottom: calc(100% + 6px);
|
||||
}
|
||||
|
||||
.app-page .custom-select-option {
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
padding: 9px 10px;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition), color var(--transition);
|
||||
}
|
||||
|
||||
.app-page .custom-select-option:hover {
|
||||
background: #f1f7fd;
|
||||
color: var(--primary-active);
|
||||
}
|
||||
|
||||
.app-page .custom-select-option.selected {
|
||||
background: #eaf2fb;
|
||||
color: var(--primary-active);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-page .table-footer {
|
||||
border-top: 1px solid var(--line);
|
||||
background: #f9fbfe;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.app-page .right-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.app-page .pagination-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.app-page .page-nav-text {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 9px;
|
||||
background: #fff;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background-color var(--transition), border-color var(--transition), color var(--transition);
|
||||
}
|
||||
|
||||
.app-page .page-nav-text:hover:not(.disabled) {
|
||||
background: var(--surface-hover);
|
||||
border-color: #b5c9de;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.app-page .page-nav-text.disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.app-page .page-info,
|
||||
.app-page .total-count {
|
||||
height: 34px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 9px;
|
||||
background: #fff;
|
||||
color: var(--text-secondary);
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-page .quick-jump,
|
||||
.app-page .page-size-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.app-page .quick-jump label,
|
||||
.app-page .page-size-controls label {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-page .quick-jump input {
|
||||
width: 76px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.app-page .page-size-controls .custom-select {
|
||||
min-width: 96px;
|
||||
}
|
||||
|
||||
.app-page .page-size-controls .custom-select-trigger {
|
||||
width: 92px;
|
||||
min-height: 34px;
|
||||
height: 34px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
body.app-page {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.app-page .container {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.app-page .right-controls {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
.dashboard-page .container {
|
||||
max-width: 1360px;
|
||||
}
|
||||
|
||||
.dashboard-page .header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dashboard-page .header-nav {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.dashboard-page .filters-section,
|
||||
.dashboard-page .stats-section,
|
||||
.dashboard-page .table-section {
|
||||
padding: 22px 26px;
|
||||
}
|
||||
|
||||
.dashboard-page .filters-section {
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.dashboard-page .filters {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.dashboard-page .filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
min-width: 170px;
|
||||
}
|
||||
|
||||
.dashboard-page .filter-group label {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dashboard-page .filter-group input,
|
||||
.dashboard-page .filter-group select {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: #fff;
|
||||
color: var(--text-primary);
|
||||
padding: 0 12px;
|
||||
font-size: 14px;
|
||||
transition: border-color var(--transition), box-shadow var(--transition), background-color var(--transition);
|
||||
}
|
||||
|
||||
.dashboard-page .filter-group input:hover,
|
||||
.dashboard-page .filter-group select:hover {
|
||||
border-color: #b9cbde;
|
||||
background: #fcfeff;
|
||||
}
|
||||
|
||||
.dashboard-page .stats-section {
|
||||
padding-top: 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.dashboard-page .stats-container,
|
||||
.dashboard-page .table-container {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
overflow-x: auto;
|
||||
background: #fff;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.dashboard-page .stats-table,
|
||||
.dashboard-page table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.dashboard-page .stats-table th,
|
||||
.dashboard-page th {
|
||||
padding: 13px 14px;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
background: #f4f8fd;
|
||||
border-bottom: 1px solid var(--line);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dashboard-page .stats-table td,
|
||||
.dashboard-page td {
|
||||
padding: 13px 14px;
|
||||
border-bottom: 1px solid #e8eef5;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.45;
|
||||
white-space: nowrap;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.dashboard-page .stats-table tbody tr:nth-child(even) td,
|
||||
.dashboard-page tbody tr:nth-child(even) td {
|
||||
background: #fbfdff;
|
||||
}
|
||||
|
||||
.dashboard-page .stats-table tbody tr:hover td,
|
||||
.dashboard-page tbody tr:hover td {
|
||||
background: #f2f7fd;
|
||||
}
|
||||
|
||||
.dashboard-page .stats-total-row td {
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
background: #eff5fc;
|
||||
}
|
||||
|
||||
.dashboard-page .loading,
|
||||
.dashboard-page .empty-state,
|
||||
.dashboard-page .error {
|
||||
text-align: center;
|
||||
padding: 32px 16px;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.dashboard-page .error {
|
||||
color: #9b2c2c;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.dashboard-page .table-footer {
|
||||
padding: 14px 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.dashboard-page .header,
|
||||
.dashboard-page .filters-section,
|
||||
.dashboard-page .stats-section,
|
||||
.dashboard-page .table-section {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.dashboard-page .header-content {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.dashboard-page .filter-group {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-page .btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dashboard-page th,
|
||||
.dashboard-page td,
|
||||
.dashboard-page .stats-table th,
|
||||
.dashboard-page .stats-table td {
|
||||
padding: 10px 11px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dashboard-page .table-footer {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
body.login-page {
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background:
|
||||
radial-gradient(1100px 620px at -10% -15%, #eaf2fb 0%, transparent 60%),
|
||||
radial-gradient(980px 640px at 100% 0%, #eef4fb 0%, transparent 62%),
|
||||
var(--bg-page);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.login-page .login-shell {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface);
|
||||
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-page .login-head {
|
||||
padding: 22px 24px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: linear-gradient(180deg, #fbfdff 0%, #f5f9ff 100%);
|
||||
}
|
||||
|
||||
.login-page .login-head h1 {
|
||||
margin: 0;
|
||||
font-size: 1.38rem;
|
||||
line-height: 1.2;
|
||||
font-weight: 650;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.login-page .login-head p {
|
||||
margin: 8px 0 0;
|
||||
font-size: 0.92rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.login-page .login-body {
|
||||
padding: 22px 24px 24px;
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.login-page .form-label {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.login-page .form-control {
|
||||
min-height: 40px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.58rem 0.72rem;
|
||||
color: var(--text-primary);
|
||||
background: #fff;
|
||||
transition: border-color var(--transition), box-shadow var(--transition), background-color var(--transition);
|
||||
}
|
||||
|
||||
.login-page .form-control:hover {
|
||||
border-color: #b9cbde;
|
||||
background: #fcfeff;
|
||||
}
|
||||
|
||||
.login-page .form-control:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px var(--focus);
|
||||
}
|
||||
|
||||
.login-page .btn-login {
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 600;
|
||||
padding: 0.58rem 0.72rem;
|
||||
color: #fff;
|
||||
border: 1px solid var(--primary);
|
||||
background: var(--primary);
|
||||
transition: background-color var(--transition), border-color var(--transition), box-shadow var(--transition), opacity var(--transition);
|
||||
}
|
||||
|
||||
.login-page .btn-login:hover {
|
||||
background: var(--primary-hover);
|
||||
border-color: var(--primary-hover);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.login-page .btn-login:active {
|
||||
background: var(--primary-active);
|
||||
border-color: var(--primary-active);
|
||||
}
|
||||
|
||||
.login-page .btn-login:disabled {
|
||||
opacity: 0.62;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
body.login-page {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.login-page .login-head,
|
||||
.login-page .login-body {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
.users-page .container {
|
||||
max-width: 1280px;
|
||||
}
|
||||
|
||||
.users-page .header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.users-page .content {
|
||||
padding: 24px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.users-page .add-user-section,
|
||||
.users-page .users-section {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-soft);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.users-page .add-user-section {
|
||||
padding: 18px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.users-page .users-section {
|
||||
padding: 18px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.users-page .users-section h2 {
|
||||
margin: 0 0 14px;
|
||||
font-size: 18px;
|
||||
font-weight: 650;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.users-page #usersTableContainer {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: visible;
|
||||
}
|
||||
|
||||
.users-page .form-row {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.users-page .form-group {
|
||||
flex: 1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.users-page .form-group.form-submit {
|
||||
flex: 0 0 110px;
|
||||
min-width: 110px;
|
||||
}
|
||||
|
||||
.users-page .form-group.form-submit .btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.users-page .form-group label,
|
||||
.users-page .form-label {
|
||||
display: block;
|
||||
margin-bottom: 7px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.users-page .users-table {
|
||||
width: 100%;
|
||||
min-width: 980px;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
font-size: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.users-page .users-table th {
|
||||
padding: 13px 14px;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
background: #f4f8fd;
|
||||
border-bottom: 1px solid var(--line);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.users-page .users-table td {
|
||||
padding: 13px 14px;
|
||||
border-bottom: 1px solid #e8eef5;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.45;
|
||||
white-space: nowrap;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.users-page .users-table tbody tr:nth-child(even) td {
|
||||
background: #fbfdff;
|
||||
}
|
||||
|
||||
.users-page .users-table tbody tr:hover td {
|
||||
background: #f2f7fd;
|
||||
}
|
||||
|
||||
.users-page .ip-address {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
background: #f8fbff;
|
||||
color: var(--text-secondary);
|
||||
padding: 3px 8px;
|
||||
font-size: 12px;
|
||||
font-family: "Cascadia Mono", "Consolas", monospace;
|
||||
}
|
||||
|
||||
.users-page .action-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.users-page .btn-action {
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition), border-color var(--transition), color var(--transition);
|
||||
}
|
||||
|
||||
.users-page .btn-edit {
|
||||
color: var(--primary);
|
||||
background: #edf4fc;
|
||||
border-color: #d6e4f1;
|
||||
}
|
||||
|
||||
.users-page .btn-edit:hover {
|
||||
color: #fff;
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.users-page .btn-delete {
|
||||
color: var(--danger);
|
||||
background: #fdf2f3;
|
||||
border-color: #f3d7dc;
|
||||
}
|
||||
|
||||
.users-page .btn-delete:hover {
|
||||
color: #fff;
|
||||
background: var(--danger-hover);
|
||||
border-color: var(--danger-hover);
|
||||
}
|
||||
|
||||
.users-page .switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.users-page .switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.users-page .slider {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #c8d7e6;
|
||||
background: #e7eef6;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition), border-color var(--transition);
|
||||
}
|
||||
|
||||
.users-page .slider::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
top: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.16);
|
||||
transition: transform var(--transition);
|
||||
}
|
||||
|
||||
.users-page .switch input:checked + .slider {
|
||||
background: #e7f5ef;
|
||||
border-color: #b7dccf;
|
||||
}
|
||||
|
||||
.users-page .switch input:checked + .slider::before {
|
||||
transform: translateX(20px);
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.users-page .empty-state,
|
||||
.users-page .loading,
|
||||
.users-page .error,
|
||||
.users-page .success {
|
||||
text-align: center;
|
||||
padding: 30px 16px;
|
||||
font-size: 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.users-page .loading,
|
||||
.users-page .empty-state {
|
||||
color: var(--text-secondary);
|
||||
border: 1px dashed var(--line);
|
||||
background: #fbfdff;
|
||||
}
|
||||
|
||||
.users-page .error {
|
||||
color: #9b2c2c;
|
||||
border: 1px solid #f1d4d8;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.users-page .success {
|
||||
color: #24604d;
|
||||
border: 1px solid #cce4dc;
|
||||
background: #edf9f4;
|
||||
}
|
||||
|
||||
.users-page .table-footer {
|
||||
padding: 14px 4px 0;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.users-page .modal-content {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-soft) !important;
|
||||
}
|
||||
|
||||
.users-page .modal-header,
|
||||
.users-page .modal-footer {
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.users-page .modal-title {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.users-page .btn-close:focus {
|
||||
box-shadow: 0 0 0 3px var(--focus);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.users-page .header,
|
||||
.users-page .content {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.users-page .header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.users-page .form-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.users-page .form-group.form-submit {
|
||||
flex: 1 1 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.users-page .content {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.users-page .form-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.users-page .users-table th,
|
||||
.users-page .users-table td {
|
||||
padding: 10px 11px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.users-page .btn-action {
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
(function () {
|
||||
const TOAST_CONTAINER_ID = "ui-toast-container";
|
||||
|
||||
function ensureToastContainer() {
|
||||
let container = document.getElementById(TOAST_CONTAINER_ID);
|
||||
if (container) return container;
|
||||
|
||||
container = document.createElement("div");
|
||||
container.id = TOAST_CONTAINER_ID;
|
||||
container.className = "toast-container position-fixed top-0 end-0 p-3";
|
||||
container.style.zIndex = "1080";
|
||||
document.body.appendChild(container);
|
||||
return container;
|
||||
}
|
||||
|
||||
function createToastElement(message, toastTypeClass) {
|
||||
const toast = document.createElement("div");
|
||||
toast.className = `toast align-items-center border-0 ${toastTypeClass}`;
|
||||
toast.setAttribute("role", "alert");
|
||||
toast.setAttribute("aria-live", "assertive");
|
||||
toast.setAttribute("aria-atomic", "true");
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "d-flex";
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "toast-body";
|
||||
body.textContent = String(message || "");
|
||||
|
||||
const closeBtn = document.createElement("button");
|
||||
closeBtn.type = "button";
|
||||
closeBtn.className = "btn-close btn-close-white me-2 m-auto";
|
||||
closeBtn.setAttribute("data-bs-dismiss", "toast");
|
||||
closeBtn.setAttribute("aria-label", "Close");
|
||||
|
||||
wrapper.appendChild(body);
|
||||
wrapper.appendChild(closeBtn);
|
||||
toast.appendChild(wrapper);
|
||||
return toast;
|
||||
}
|
||||
|
||||
function showMessage(message, type, options) {
|
||||
const opts = options || {};
|
||||
const level = type || "error";
|
||||
const dismissMs = Number.isFinite(opts.dismissMs) ? opts.dismissMs : 3000;
|
||||
const container = ensureToastContainer();
|
||||
|
||||
const typeMap = {
|
||||
success: "text-bg-success",
|
||||
error: "text-bg-danger",
|
||||
warning: "text-bg-warning",
|
||||
info: "text-bg-info",
|
||||
};
|
||||
const toastTypeClass = typeMap[level] || typeMap.error;
|
||||
|
||||
const toastEl = createToastElement(message, toastTypeClass);
|
||||
container.appendChild(toastEl);
|
||||
|
||||
const delay = dismissMs > 0 ? dismissMs : 600000;
|
||||
if (window.bootstrap && window.bootstrap.Toast) {
|
||||
const toast = window.bootstrap.Toast.getOrCreateInstance(toastEl, {
|
||||
autohide: dismissMs > 0,
|
||||
delay,
|
||||
});
|
||||
toast.show();
|
||||
if (!toastEl.hasAttribute("data-bs-dismiss-bound")) {
|
||||
toastEl.setAttribute("data-bs-dismiss-bound", "true");
|
||||
toastEl.addEventListener("hidden.bs.toast", function () {
|
||||
toastEl.remove();
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (dismissMs > 0) {
|
||||
window.setTimeout(function () {
|
||||
toastEl.remove();
|
||||
}, dismissMs);
|
||||
}
|
||||
}
|
||||
|
||||
window.showMessage = showMessage;
|
||||
})();
|
||||
@@ -0,0 +1,7 @@
|
||||
/*!
|
||||
* Bootstrap v5.3.8 (https://getbootstrap.com/)
|
||||
* Copyright 2011-2025 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors)
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
||||
*/
|
||||
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).bootstrap=e()}(this,function(){"use strict";const t=new Map,e={set(e,i,n){t.has(e)||t.set(e,new Map);const s=t.get(e);s.has(i)||0===s.size?s.set(i,n):console.error(`Bootstrap doesn't allow more than one instance per element. Bound instance: ${Array.from(s.keys())[0]}.`)},get:(e,i)=>t.has(e)&&t.get(e).get(i)||null,remove(e,i){if(!t.has(e))return;const n=t.get(e);n.delete(i),0===n.size&&t.delete(e)}},i="transitionend",n=t=>(t&&window.CSS&&window.CSS.escape&&(t=t.replace(/#([^\s"#']+)/g,(t,e)=>`#${CSS.escape(e)}`)),t),s=t=>null==t?`${t}`:Object.prototype.toString.call(t).match(/\s([a-z]+)/i)[1].toLowerCase(),o=t=>{t.dispatchEvent(new Event(i))},r=t=>!(!t||"object"!=typeof t)&&(void 0!==t.jquery&&(t=t[0]),void 0!==t.nodeType),a=t=>r(t)?t.jquery?t[0]:t:"string"==typeof t&&t.length>0?document.querySelector(n(t)):null,l=t=>{if(!r(t)||0===t.getClientRects().length)return!1;const e="visible"===getComputedStyle(t).getPropertyValue("visibility"),i=t.closest("details:not([open])");if(!i)return e;if(i!==t){const e=t.closest("summary");if(e&&e.parentNode!==i)return!1;if(null===e)return!1}return e},c=t=>!t||t.nodeType!==Node.ELEMENT_NODE||!!t.classList.contains("disabled")||(void 0!==t.disabled?t.disabled:t.hasAttribute("disabled")&&"false"!==t.getAttribute("disabled")),h=t=>{if(!document.documentElement.attachShadow)return null;if("function"==typeof t.getRootNode){const e=t.getRootNode();return e instanceof ShadowRoot?e:null}return t instanceof ShadowRoot?t:t.parentNode?h(t.parentNode):null},d=()=>{},u=t=>{t.offsetHeight},f=()=>window.jQuery&&!document.body.hasAttribute("data-bs-no-jquery")?window.jQuery:null,p=[],m=()=>"rtl"===document.documentElement.dir,g=t=>{var e;e=()=>{const e=f();if(e){const i=t.NAME,n=e.fn[i];e.fn[i]=t.jQueryInterface,e.fn[i].Constructor=t,e.fn[i].noConflict=()=>(e.fn[i]=n,t.jQueryInterface)}},"loading"===document.readyState?(p.length||document.addEventListener("DOMContentLoaded",()=>{for(const t of p)t()}),p.push(e)):e()},_=(t,e=[],i=t)=>"function"==typeof t?t.call(...e):i,b=(t,e,n=!0)=>{if(!n)return void _(t);const s=(t=>{if(!t)return 0;let{transitionDuration:e,transitionDelay:i}=window.getComputedStyle(t);const n=Number.parseFloat(e),s=Number.parseFloat(i);return n||s?(e=e.split(",")[0],i=i.split(",")[0],1e3*(Number.parseFloat(e)+Number.parseFloat(i))):0})(e)+5;let r=!1;const a=({target:n})=>{n===e&&(r=!0,e.removeEventListener(i,a),_(t))};e.addEventListener(i,a),setTimeout(()=>{r||o(e)},s)},v=(t,e,i,n)=>{const s=t.length;let o=t.indexOf(e);return-1===o?!i&&n?t[s-1]:t[0]:(o+=i?1:-1,n&&(o=(o+s)%s),t[Math.max(0,Math.min(o,s-1))])},y=/[^.]*(?=\..*)\.|.*/,w=/\..*/,A=/::\d+$/,E={};let T=1;const C={mouseenter:"mouseover",mouseleave:"mouseout"},O=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function x(t,e){return e&&`${e}::${T++}`||t.uidEvent||T++}function k(t){const e=x(t);return t.uidEvent=e,E[e]=E[e]||{},E[e]}function L(t,e,i=null){return Object.values(t).find(t=>t.callable===e&&t.delegationSelector===i)}function S(t,e,i){const n="string"==typeof e,s=n?i:e||i;let o=N(t);return O.has(o)||(o=t),[n,s,o]}function D(t,e,i,n,s){if("string"!=typeof e||!t)return;let[o,r,a]=S(e,i,n);if(e in C){const t=t=>function(e){if(!e.relatedTarget||e.relatedTarget!==e.delegateTarget&&!e.delegateTarget.contains(e.relatedTarget))return t.call(this,e)};r=t(r)}const l=k(t),c=l[a]||(l[a]={}),h=L(c,r,o?i:null);if(h)return void(h.oneOff=h.oneOff&&s);const d=x(r,e.replace(y,"")),u=o?function(t,e,i){return function n(s){const o=t.querySelectorAll(e);for(let{target:r}=s;r&&r!==this;r=r.parentNode)for(const a of o)if(a===r)return j(s,{delegateTarget:r}),n.oneOff&&P.off(t,s.type,e,i),i.apply(r,[s])}}(t,i,r):function(t,e){return function i(n){return j(n,{delegateTarget:t}),i.oneOff&&P.off(t,n.type,e),e.apply(t,[n])}}(t,r);u.delegationSelector=o?i:null,u.callable=r,u.oneOff=s,u.uidEvent=d,c[d]=u,t.addEventListener(a,u,o)}function $(t,e,i,n,s){const o=L(e[i],n,s);o&&(t.removeEventListener(i,o,Boolean(s)),delete e[i][o.uidEvent])}function I(t,e,i,n){const s=e[i]||{};for(const[o,r]of Object.entries(s))o.includes(n)&&$(t,e,i,r.callable,r.delegationSelector)}function N(t){return t=t.replace(w,""),C[t]||t}const P={on(t,e,i,n){D(t,e,i,n,!1)},one(t,e,i,n){D(t,e,i,n,!0)},off(t,e,i,n){if("string"!=typeof e||!t)return;const[s,o,r]=S(e,i,n),a=r!==e,l=k(t),c=l[rLine truncated
|
||||
//# sourceMappingURL=bootstrap.bundle.min.js.map
|
||||
@@ -0,0 +1,6 @@
|
||||
@charset "UTF-8";/*!
|
||||
* Bootstrap v5.3.8 (https://getbootstrap.com/)
|
||||
* Copyright 2011-2025 The Bootstrap Authors
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
||||
*/:root,[data-bs-theme=light]{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-primary-text-emphasis:#052c65;--bs-secondary-text-emphasis:#2b2f32;--bs-success-text-emphasis:#0a3622;--bs-info-text-emphasis:#055160;--bs-warning-text-emphasis:#664d03;--bs-danger-text-emphasis:#58151c;--bs-light-text-emphasis:#495057;--bs-dark-text-emphasis:#495057;--bs-primary-bg-subtle:#cfe2ff;--bs-secondary-bg-subtle:#e2e3e5;--bs-success-bg-subtle:#d1e7dd;--bs-info-bg-subtle:#cff4fc;--bs-warning-bg-subtle:#fff3cd;--bs-danger-bg-subtle:#f8d7da;--bs-light-bg-subtle:#fcfcfd;--bs-dark-bg-subtle:#ced4da;--bs-primary-border-subtle:#9ec5fe;--bs-secondary-border-subtle:#c4c8cb;--bs-success-border-subtle:#a3cfbb;--bs-info-border-subtle:#9eeaf9;--bs-warning-border-subtle:#ffe69c;--bs-danger-border-subtle:#f1aeb5;--bs-light-border-subtle:#e9ecef;--bs-dark-border-subtle:#adb5bd;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-color-rgb:33,37,41;--bs-body-bg:#fff;--bs-body-bg-rgb:255,255,255;--bs-emphasis-color:#000;--bs-emphasis-color-rgb:0,0,0;--bs-secondary-color:rgba(33, 37, 41, 0.75);--bs-secondary-color-rgb:33,37,41;--bs-secondary-bg:#e9ecef;--bs-secondary-bg-rgb:233,236,239;--bs-tertiary-color:rgba(33, 37, 41, 0.5);--bs-tertiary-color-rgb:33,37,41;--bs-tertiary-bg:#f8f9fa;--bs-tertiary-bg-rgb:248,249,250;--bs-heading-color:inherit;--bs-link-color:#0d6efd;--bs-link-color-rgb:13,110,253;--bs-link-decoration:underline;--bs-link-hover-color:#0a58ca;--bs-link-hover-color-rgb:10,88,202;--bs-code-color:#d63384;--bs-highlight-color:#212529;--bs-highlight-bg:#fff3cd;--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, 0.175);--bs-border-radius:0.375rem;--bs-border-radius-sm:0.25rem;--bs-border-radius-lg:0.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-xxl:2rem;--bs-border-radius-2xl:var(--bs-border-radius-xxl);--bs-border-radius-pill:50rem;--bs-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-box-shadow-sm:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-box-shadow-lg:0 1rem 3rem rgba(0, 0, 0, 0.175);--bs-box-shadow-inset:inset 0 1px 2px rgba(0, 0, 0, 0.075);--bs-focus-ring-width:0.25rem;--bs-focus-ring-opacity:0.25;--bs-focus-ring-color:rgba(13, 110, 253, 0.25);--bs-form-valid-color:#198754;--bs-form-valid-border-color:#198754;--bs-form-invalid-color:#dc3545;--bs-form-invalid-border-color:#dc3545}[data-bs-theme=dark]{color-scheme:dark;--bs-body-color:#dee2e6;--bs-body-color-rgb:222,226,230;--bs-body-bg:#212529;--bs-body-bg-rgb:33,37,41;--bs-emphasis-color:#fff;--bs-emphasis-color-rgb:255,255,255;--bs-secondary-color:rgba(222, 226, 230, 0.75);--bs-secondary-color-rgb:222,226,230;--bs-secondary-bg:#343a40;--bs-secondary-bg-rgb:52,58,64;--bs-tertiary-color:rgba(222, 226, 230, 0.5);--bs-tertiary-color-rgb:222,226,230;--bs-tertiary-bg:#2b3035;--bs-tertiary-bg-rgb:43,48,53;--bs-primary-text-emphasis:#6ea8fe;--bs-secondary-text-emphasis:#a7acb1;--bs-success-text-emphasis:#75b798;--bs-info-text-emphasis:#6edff6;--bs-warning-text-emphasis:#ffda6a;--bs-danger-text-emphasis:#ea868f;--bs-light-text-emphasis:#f8f9fa;--bs-dark-text-emphasis:#dee2e6;--bs-primary-bg-subtle:#031633;--bs-secondary-bg-subtle:#161719;--bs-success-bg-subtle:#051b11;--bs-info-bg-subtle:#032830;--bs-warning-bg-subtle:#332701;--bs-danger-bg-subtle:#2c0b0e;--bs-light-bg-subtle:#343a40;--bs-dark-bg-subtle:#1a1d20;--bs-primary-border-subtle:#084298;--bs-secondary-border-subtle:#41464b;--bs-success-border-subtle:#0f5132;--bs-info-border-subtle:#087990;--bs-warning-border-subtle:#997404;--bs-danger-border-subtle:#842029;--bs-light-border-subtle:#495057;--bs-dark-border-subtle:#343a40;--bs-heading-color:inherLine truncated
|
||||
/*# sourceMappingURL=bootstrap.min.css.map */
|
||||
@@ -0,0 +1,568 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>LLM使用统计</title>
|
||||
<link rel="stylesheet" href="/static/vendor/bootstrap/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/static/css/admin-base.css">
|
||||
<link rel="stylesheet" href="/static/css/index.css">
|
||||
</head>
|
||||
<body class="app-page dashboard-page">
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="header-content">
|
||||
<div class="header-title">
|
||||
<h1>LLM使用统计</h1>
|
||||
<p>实时监控 API 请求和 Token 消耗</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<nav class="header-nav" aria-label="Main navigation">
|
||||
<a class="header-nav-link active" href="/">统计概览</a>
|
||||
<a class="header-nav-link" href="/users">用户管理</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filters-section">
|
||||
<div class="filters">
|
||||
<div class="filter-group">
|
||||
<label>开始日期:</label>
|
||||
<input type="date" id="startDate">
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>结束日期:</label>
|
||||
<input type="date" id="endDate">
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>用户名</label>
|
||||
<select id="username">
|
||||
<option value="">全部</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>请求模型:</label>
|
||||
<select id="requestModel">
|
||||
<option value="">全部</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="applyFilters()">查询</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-section">
|
||||
<div class="stats-container">
|
||||
<table class="stats-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IP</th>
|
||||
<th>用户名</th>
|
||||
<th>请求模型</th>
|
||||
<th>响应模型</th>
|
||||
<th>请求数</th>
|
||||
<th>输入token</th>
|
||||
<th>输出token</th>
|
||||
<th>总 Token</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="statsTable">
|
||||
<tr>
|
||||
<td colspan="8" class="loading">加载统计数据...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-section">
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IP</th>
|
||||
<th>用户名</th>
|
||||
<th>请求模型</th>
|
||||
<th>响应模型</th>
|
||||
<th>输入token</th>
|
||||
<th>输出token</th>
|
||||
<th>总 Token</th>
|
||||
<th>开始时间</th>
|
||||
<th>结束时间</th>
|
||||
<th>耗时(秒)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="logsTable">
|
||||
<tr>
|
||||
<td colspan="10" class="loading">加载请求数据...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-footer" id="tableFooter" style="display: none;">
|
||||
<div class="right-controls">
|
||||
<div class="pagination-controls">
|
||||
<span id="prevBtn" class="page-nav-text disabled" onclick="prevPage()"><</span>
|
||||
<span class="page-info" id="pageInfo">第 1 页 / 共 1 页</span>
|
||||
<span id="nextBtn" class="page-nav-text disabled" onclick="nextPage()">></span>
|
||||
</div>
|
||||
<div class="quick-jump">
|
||||
<label for="pageJumpInput">跳至</label>
|
||||
<input id="pageJumpInput" type="number" min="1" value="1">
|
||||
</div>
|
||||
<div class="page-size-controls">
|
||||
<label for="pageSizeSelect">每页显示:</label>
|
||||
<select id="pageSizeSelect" onchange="changePageSize()">
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
<option value="200">200</option>
|
||||
</select>
|
||||
</div>
|
||||
<span class="total-count" id="totalCount">共 0 条</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/ui-message.js"></script>
|
||||
<script>
|
||||
let currentPage = 1;
|
||||
let pageSize = 50;
|
||||
let currentFilters = {};
|
||||
let jumpDebounceTimer = null;
|
||||
let currentTotalPages = 1;
|
||||
let activeCustomSelect = null;
|
||||
|
||||
function closeAllCustomSelects() {
|
||||
document.querySelectorAll('.custom-select.open').forEach(select => {
|
||||
select.classList.remove('open');
|
||||
const trigger = select.querySelector('.custom-select-trigger');
|
||||
if (trigger) trigger.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
activeCustomSelect = null;
|
||||
}
|
||||
|
||||
function syncCustomSelectValue(selectId) {
|
||||
const nativeSelect = document.getElementById(selectId);
|
||||
const customSelect = document.querySelector(`.custom-select[data-for="${selectId}"]`);
|
||||
if (!nativeSelect || !customSelect) return;
|
||||
|
||||
const trigger = customSelect.querySelector('.custom-select-trigger');
|
||||
const selectedOption = nativeSelect.options[nativeSelect.selectedIndex] || nativeSelect.options[0];
|
||||
trigger.textContent = selectedOption ? selectedOption.textContent : '请选择';
|
||||
|
||||
customSelect.querySelectorAll('.custom-select-option').forEach(option => {
|
||||
option.classList.toggle('selected', option.dataset.value === nativeSelect.value);
|
||||
});
|
||||
}
|
||||
|
||||
function renderCustomSelectOptions(selectId) {
|
||||
const nativeSelect = document.getElementById(selectId);
|
||||
const customSelect = document.querySelector(`.custom-select[data-for="${selectId}"]`);
|
||||
if (!nativeSelect || !customSelect) return;
|
||||
|
||||
const menu = customSelect.querySelector('.custom-select-menu');
|
||||
menu.innerHTML = '';
|
||||
|
||||
Array.from(nativeSelect.options).forEach(option => {
|
||||
const optionButton = document.createElement('button');
|
||||
optionButton.type = 'button';
|
||||
optionButton.className = 'custom-select-option';
|
||||
optionButton.textContent = option.textContent;
|
||||
optionButton.dataset.value = option.value;
|
||||
optionButton.addEventListener('click', function() {
|
||||
if (nativeSelect.value !== option.value) {
|
||||
nativeSelect.value = option.value;
|
||||
nativeSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
syncCustomSelectValue(selectId);
|
||||
closeAllCustomSelects();
|
||||
});
|
||||
menu.appendChild(optionButton);
|
||||
});
|
||||
|
||||
syncCustomSelectValue(selectId);
|
||||
}
|
||||
|
||||
function setupCustomSelect(selectId) {
|
||||
const nativeSelect = document.getElementById(selectId);
|
||||
if (!nativeSelect) return;
|
||||
|
||||
nativeSelect.classList.add('native-select-hidden');
|
||||
|
||||
let customSelect = document.querySelector(`.custom-select[data-for="${selectId}"]`);
|
||||
if (!customSelect) {
|
||||
customSelect = document.createElement('div');
|
||||
customSelect.className = 'custom-select';
|
||||
customSelect.dataset.for = selectId;
|
||||
customSelect.innerHTML = `
|
||||
<button type="button" class="custom-select-trigger" aria-haspopup="listbox" aria-expanded="false"></button>
|
||||
<div class="custom-select-menu" role="listbox"></div>
|
||||
`;
|
||||
nativeSelect.insertAdjacentElement('afterend', customSelect);
|
||||
|
||||
const trigger = customSelect.querySelector('.custom-select-trigger');
|
||||
trigger.addEventListener('click', function(event) {
|
||||
event.stopPropagation();
|
||||
const isOpen = customSelect.classList.contains('open');
|
||||
closeAllCustomSelects();
|
||||
if (!isOpen) {
|
||||
const rect = customSelect.getBoundingClientRect();
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
customSelect.classList.toggle('drop-up', spaceBelow < 236);
|
||||
customSelect.classList.add('open');
|
||||
trigger.setAttribute('aria-expanded', 'true');
|
||||
activeCustomSelect = customSelect;
|
||||
}
|
||||
});
|
||||
|
||||
trigger.addEventListener('keydown', function(event) {
|
||||
if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
trigger.click();
|
||||
} else if (event.key === 'Escape') {
|
||||
closeAllCustomSelects();
|
||||
}
|
||||
});
|
||||
|
||||
nativeSelect.addEventListener('change', function() {
|
||||
syncCustomSelectValue(selectId);
|
||||
});
|
||||
}
|
||||
|
||||
renderCustomSelectOptions(selectId);
|
||||
}
|
||||
|
||||
// 页面加载时初始化
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
setupCustomSelect('username');
|
||||
setupCustomSelect('requestModel');
|
||||
setupCustomSelect('pageSizeSelect');
|
||||
setDefaultDateRange();
|
||||
loadStatistics();
|
||||
loadUsernames();
|
||||
loadRequestModels();
|
||||
loadLogs();
|
||||
|
||||
// 设置默认每页大小
|
||||
document.getElementById('pageSizeSelect').value = pageSize;
|
||||
syncCustomSelectValue('pageSizeSelect');
|
||||
const pageJumpInput = document.getElementById('pageJumpInput');
|
||||
if (pageJumpInput) {
|
||||
pageJumpInput.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
jumpToPage(true);
|
||||
}
|
||||
});
|
||||
pageJumpInput.addEventListener('blur', function() {
|
||||
jumpToPage(true);
|
||||
});
|
||||
pageJumpInput.addEventListener('input', function() {
|
||||
clearTimeout(jumpDebounceTimer);
|
||||
jumpDebounceTimer = setTimeout(function() {
|
||||
jumpToPage(false);
|
||||
}, 450);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', function(event) {
|
||||
if (!event.target.closest('.custom-select')) {
|
||||
closeAllCustomSelects();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function(event) {
|
||||
if (event.key === 'Escape') {
|
||||
closeAllCustomSelects();
|
||||
}
|
||||
});
|
||||
|
||||
// 加载统计数据
|
||||
async function loadStatistics() {
|
||||
try {
|
||||
const params = new URLSearchParams(currentFilters);
|
||||
const response = await fetch(`/api/statistics?${params}`);
|
||||
const stats = await response.json();
|
||||
|
||||
const statsTable = document.getElementById('statsTable');
|
||||
if (stats.length === 0) {
|
||||
statsTable.innerHTML = '<tr><td colspan="8" class="empty-state">暂无统计数据</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
let totalRequests = 0;
|
||||
let totalTokens = 0;
|
||||
let totalPromptTokens = 0;
|
||||
let totalCompletionTokens = 0;
|
||||
|
||||
stats.forEach(stat => {
|
||||
totalRequests += stat.request_count;
|
||||
totalTokens += stat.total_tokens;
|
||||
totalPromptTokens += stat.prompt_tokens || 0;
|
||||
totalCompletionTokens += stat.completion_tokens || 0;
|
||||
|
||||
html += `
|
||||
<tr>
|
||||
<td>${stat.ip_address || '-'}</td>
|
||||
<td>${stat.username || '-'}</td>
|
||||
<td>${stat.request_model || '-'}</td>
|
||||
<td>${stat.response_model || '-'}</td>
|
||||
<td>${stat.request_count}</td>
|
||||
<td>${(stat.prompt_tokens || 0).toLocaleString()}</td>
|
||||
<td>${(stat.completion_tokens || 0).toLocaleString()}</td>
|
||||
<td>${stat.total_tokens.toLocaleString()}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
html += `
|
||||
<tr class="stats-total-row">
|
||||
<td colspan="4">总计</td>
|
||||
<td>${totalRequests.toLocaleString()}</td>
|
||||
<td>${totalPromptTokens.toLocaleString()}</td>
|
||||
<td>${totalCompletionTokens.toLocaleString()}</td>
|
||||
<td>${totalTokens.toLocaleString()}</td>
|
||||
</tr>
|
||||
`;
|
||||
statsTable.innerHTML = html;
|
||||
} catch (error) {
|
||||
console.error('加载统计数据失败:', error);
|
||||
document.getElementById('statsTable').innerHTML = '<tr><td colspan="8" class="error">加载统计数据失败</td></tr>';
|
||||
showMessage('加载统计数据失败,请稍后重试', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 加载用户名列表
|
||||
async function loadUsernames() {
|
||||
try {
|
||||
const response = await fetch('/api/usernames');
|
||||
const usernames = await response.json();
|
||||
|
||||
const select = document.getElementById('username');
|
||||
usernames.forEach(username => {
|
||||
const option = document.createElement('option');
|
||||
option.value = username;
|
||||
option.textContent = username;
|
||||
select.appendChild(option);
|
||||
});
|
||||
renderCustomSelectOptions('username');
|
||||
} catch (error) {
|
||||
console.error('加载用户名列表失败', error);
|
||||
showMessage('加载用户名列表失败', 'warning');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRequestModels() {
|
||||
try {
|
||||
const response = await fetch('/api/request-models');
|
||||
const models = await response.json();
|
||||
|
||||
const select = document.getElementById('requestModel');
|
||||
models.forEach(model => {
|
||||
const option = document.createElement('option');
|
||||
option.value = model;
|
||||
option.textContent = model;
|
||||
select.appendChild(option);
|
||||
});
|
||||
renderCustomSelectOptions('requestModel');
|
||||
} catch (error) {
|
||||
console.error('加载请求模型列表失败', error);
|
||||
showMessage('加载请求模型列表失败', 'warning');
|
||||
}
|
||||
}
|
||||
|
||||
// 加载日志数据
|
||||
async function loadLogs() {
|
||||
// 显示加载状态
|
||||
document.getElementById('logsTable').innerHTML = '<tr><td colspan="10" class="loading">加载请求数据...</td></tr>';
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: currentPage,
|
||||
page_size: pageSize,
|
||||
...currentFilters
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/request-logs?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
renderLogs(data.logs);
|
||||
updatePagination(data);
|
||||
} catch (error) {
|
||||
console.error('加载日志失败:', error);
|
||||
document.getElementById('logsTable').innerHTML = '<tr><td colspan="10" class="error">加载日志失败</td></tr>';
|
||||
showMessage('加载日志失败,请稍后重试', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染日志表格
|
||||
function renderLogs(logs) {
|
||||
const tbody = document.getElementById('logsTable');
|
||||
|
||||
if (logs.length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="10" class="empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>
|
||||
<p>暂无请求数据</p>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
// 隐藏表格底部
|
||||
document.getElementById('tableFooter').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
logs.forEach(log => {
|
||||
const startTime = new Date(log.start_time);
|
||||
const endTime = new Date(log.end_time);
|
||||
const duration = ((endTime - startTime) / 1000).toFixed(2);
|
||||
|
||||
html += `
|
||||
<tr>
|
||||
<td>${log.ip_address || '-'}</td>
|
||||
<td>${log.username || '-'}</td>
|
||||
<td>${log.request_model || '-'}</td>
|
||||
<td>${log.response_model || '-'}</td>
|
||||
<td>${log.prompt_tokens?.toLocaleString() || 0}</td>
|
||||
<td>${log.completion_tokens?.toLocaleString() || 0}</td>
|
||||
<td>${log.total_tokens?.toLocaleString() || 0}</td>
|
||||
<td>${formatDateTime(startTime)}</td>
|
||||
<td>${formatDateTime(endTime)}</td>
|
||||
<td>${duration}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
tbody.innerHTML = html;
|
||||
}
|
||||
|
||||
// 更新分页
|
||||
function updatePagination(data) {
|
||||
currentTotalPages = data.total_pages;
|
||||
document.getElementById('pageInfo').textContent = `第 ${data.page} 页 / 共 ${data.total_pages} 页`;
|
||||
document.getElementById('prevBtn').classList.toggle('disabled', data.page <= 1);
|
||||
document.getElementById('nextBtn').classList.toggle('disabled', data.page >= data.total_pages);
|
||||
document.getElementById('totalCount').textContent = `共 ${data.total} 条`;
|
||||
|
||||
const pageSizeSelect = document.getElementById('pageSizeSelect');
|
||||
pageSizeSelect.value = pageSize;
|
||||
syncCustomSelectValue('pageSizeSelect');
|
||||
|
||||
const pageJumpInput = document.getElementById('pageJumpInput');
|
||||
if (pageJumpInput) {
|
||||
pageJumpInput.max = data.total_pages;
|
||||
pageJumpInput.value = data.page;
|
||||
}
|
||||
|
||||
document.getElementById('tableFooter').style.display = 'flex';
|
||||
}
|
||||
|
||||
function jumpToPage(force = false) {
|
||||
const input = document.getElementById('pageJumpInput');
|
||||
if (!input) return;
|
||||
const page = parseInt(input.value, 10);
|
||||
if (Number.isNaN(page)) {
|
||||
if (force) input.value = currentPage;
|
||||
return;
|
||||
}
|
||||
|
||||
const totalPages = Math.max(1, parseInt(input.max || '1', 10));
|
||||
const target = Math.min(totalPages, Math.max(1, page));
|
||||
if (target === currentPage) {
|
||||
input.value = currentPage;
|
||||
return;
|
||||
}
|
||||
|
||||
currentPage = target;
|
||||
loadLogs();
|
||||
}
|
||||
|
||||
// 格式化日期时间
|
||||
function formatDateTime(date) {
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
// 应用过滤
|
||||
function applyFilters() {
|
||||
currentFilters = {};
|
||||
|
||||
const startDate = document.getElementById('startDate').value;
|
||||
const endDate = document.getElementById('endDate').value;
|
||||
const username = document.getElementById('username').value;
|
||||
const requestModel = document.getElementById('requestModel').value;
|
||||
|
||||
if (startDate) currentFilters.start_date = startDate;
|
||||
if (endDate) currentFilters.end_date = endDate;
|
||||
if (username) currentFilters.username = username;
|
||||
if (requestModel) currentFilters.request_model = requestModel;
|
||||
|
||||
currentPage = 1;
|
||||
loadStatistics();
|
||||
loadLogs();
|
||||
}
|
||||
|
||||
function setDefaultDateRange() {
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
start.setDate(end.getDate() - 2);
|
||||
|
||||
const toDateInput = (date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
document.getElementById('startDate').value = toDateInput(start);
|
||||
document.getElementById('endDate').value = toDateInput(end);
|
||||
|
||||
currentFilters = {
|
||||
start_date: toDateInput(start),
|
||||
end_date: toDateInput(end),
|
||||
};
|
||||
}
|
||||
|
||||
// 上一页
|
||||
function prevPage() {
|
||||
if (currentPage <= 1) return;
|
||||
currentPage--;
|
||||
loadLogs();
|
||||
}
|
||||
|
||||
// 下一页
|
||||
function nextPage() {
|
||||
if (currentPage >= currentTotalPages) return;
|
||||
currentPage++;
|
||||
loadLogs();
|
||||
}
|
||||
|
||||
// 更改每页大小
|
||||
function changePageSize() {
|
||||
const newPageSize = parseInt(document.getElementById('pageSizeSelect').value);
|
||||
if (newPageSize !== pageSize) {
|
||||
pageSize = newPageSize;
|
||||
currentPage = 1; // 重置到第一页
|
||||
loadLogs();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script src="/static/vendor/bootstrap/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>管理员登录 - LLM Proxy</title>
|
||||
<link rel="stylesheet" href="/static/vendor/bootstrap/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/static/css/admin-base.css">
|
||||
<link rel="stylesheet" href="/static/css/login.css">
|
||||
</head>
|
||||
<body class="login-page">
|
||||
<div class="login-shell">
|
||||
<div class="login-head">
|
||||
<h1>管理员登录</h1>
|
||||
<p>请输入账号和密码以继续访问管理后台</p>
|
||||
</div>
|
||||
<div class="login-body">
|
||||
<form id="loginForm">
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="username">用户名</label>
|
||||
<input type="text" class="form-control" id="username" name="username" placeholder="请输入用户名" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="password">密码</label>
|
||||
<input type="password" class="form-control" id="password" name="password" placeholder="请输入密码" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-login" id="loginBtn">登录</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/ui-message.js"></script>
|
||||
<script>
|
||||
document.getElementById('loginForm').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const username = document.getElementById('username').value.trim();
|
||||
const password = document.getElementById('password').value;
|
||||
const loginBtn = document.getElementById('loginBtn');
|
||||
|
||||
loginBtn.disabled = true;
|
||||
loginBtn.textContent = '登录中...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: username,
|
||||
password: password
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
showMessage('登录成功,正在跳转...', 'success');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
|
||||
showMessage(data.error || '登录失败,请检查用户名和密码', 'error');
|
||||
} catch (error) {
|
||||
console.error('登录请求失败:', error);
|
||||
showMessage('网络错误,请稍后重试', 'error');
|
||||
}
|
||||
|
||||
loginBtn.disabled = false;
|
||||
loginBtn.textContent = '登录';
|
||||
});
|
||||
</script>
|
||||
<script src="/static/vendor/bootstrap/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,561 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>用户管理</title>
|
||||
<link rel="stylesheet" href="/static/vendor/bootstrap/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/static/css/admin-base.css">
|
||||
<link rel="stylesheet" href="/static/css/users.css">
|
||||
</head>
|
||||
<body class="app-page users-page">
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="header-title">
|
||||
<h1>用户管理</h1>
|
||||
<p>统一管理白名单用户与 IP</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<nav class="header-nav" aria-label="Main navigation">
|
||||
<a class="header-nav-link" href="/">统计概览</a>
|
||||
<a class="header-nav-link active" href="/users">用户管理</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="add-user-section">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>用户名 *</label>
|
||||
<input type="text" id="newUsername" placeholder="请输入用户名">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>IP 地址 *</label>
|
||||
<input type="text" id="newIpAddress" placeholder="仅支持 IPv4,例如 192.168.1.1">
|
||||
</div>
|
||||
<div class="form-group form-submit">
|
||||
<button class="btn btn-primary" onclick="createUser()">添加</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="users-section">
|
||||
<h2>用户列表</h2>
|
||||
<div id="usersTableContainer">
|
||||
<div class="loading">加载用户数据...</div>
|
||||
</div>
|
||||
<div class="table-footer" id="tableFooter" style="display: none;">
|
||||
<div class="right-controls">
|
||||
<div class="pagination-controls">
|
||||
<span id="prevBtn" class="page-nav-text disabled" onclick="prevPage()"><</span>
|
||||
<span class="page-info" id="pageInfo">第 1 页 / 共 1 页</span>
|
||||
<span id="nextBtn" class="page-nav-text disabled" onclick="nextPage()">></span>
|
||||
</div>
|
||||
<div class="quick-jump">
|
||||
<label for="pageJumpInput">跳至</label>
|
||||
<input id="pageJumpInput" type="number" min="1" value="1">
|
||||
</div>
|
||||
<div class="page-size-controls">
|
||||
<label for="pageSizeSelect">每页显示:</label>
|
||||
<select id="pageSizeSelect" onchange="changePageSize()">
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
<option value="200">200</option>
|
||||
</select>
|
||||
</div>
|
||||
<span class="total-count" id="totalCount">共 0 条</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑用户模态框 -->
|
||||
<div class="modal fade" id="editModal" tabindex="-1" aria-labelledby="editModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content border-0 shadow">
|
||||
<div class="modal-header border-bottom-0">
|
||||
<h5 class="modal-title fw-semibold" id="editModalLabel">编辑用户</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body pt-0">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">用户名</label>
|
||||
<input type="text" class="form-control" id="editUsername">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">IP 地址</label>
|
||||
<input type="text" class="form-control" id="editIpAddress">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer border-top-0">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveUser()">保存修改</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/ui-message.js"></script>
|
||||
<script>
|
||||
const whitelistControlEnabled = {{ 'true' if chat_whitelist_enabled else 'false' }};
|
||||
let users = [];
|
||||
let editingUserId = null;
|
||||
let currentPage = 1;
|
||||
let pageSize = 50;
|
||||
let totalUsers = 0;
|
||||
let jumpDebounceTimer = null;
|
||||
let currentTotalPages = 1;
|
||||
let activeCustomSelect = null;
|
||||
let editModalInstance = null;
|
||||
|
||||
function closeAllCustomSelects() {
|
||||
document.querySelectorAll('.custom-select.open').forEach(select => {
|
||||
select.classList.remove('open');
|
||||
const trigger = select.querySelector('.custom-select-trigger');
|
||||
if (trigger) trigger.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
activeCustomSelect = null;
|
||||
}
|
||||
|
||||
function syncCustomSelectValue(selectId) {
|
||||
const nativeSelect = document.getElementById(selectId);
|
||||
const customSelect = document.querySelector(`.custom-select[data-for="${selectId}"]`);
|
||||
if (!nativeSelect || !customSelect) return;
|
||||
|
||||
const trigger = customSelect.querySelector('.custom-select-trigger');
|
||||
const selectedOption = nativeSelect.options[nativeSelect.selectedIndex] || nativeSelect.options[0];
|
||||
trigger.textContent = selectedOption ? selectedOption.textContent : '请选择';
|
||||
|
||||
customSelect.querySelectorAll('.custom-select-option').forEach(option => {
|
||||
option.classList.toggle('selected', option.dataset.value === nativeSelect.value);
|
||||
});
|
||||
}
|
||||
|
||||
function renderCustomSelectOptions(selectId) {
|
||||
const nativeSelect = document.getElementById(selectId);
|
||||
const customSelect = document.querySelector(`.custom-select[data-for="${selectId}"]`);
|
||||
if (!nativeSelect || !customSelect) return;
|
||||
|
||||
const menu = customSelect.querySelector('.custom-select-menu');
|
||||
menu.innerHTML = '';
|
||||
|
||||
Array.from(nativeSelect.options).forEach(option => {
|
||||
const optionButton = document.createElement('button');
|
||||
optionButton.type = 'button';
|
||||
optionButton.className = 'custom-select-option';
|
||||
optionButton.textContent = option.textContent;
|
||||
optionButton.dataset.value = option.value;
|
||||
optionButton.addEventListener('click', function() {
|
||||
if (nativeSelect.value !== option.value) {
|
||||
nativeSelect.value = option.value;
|
||||
nativeSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
syncCustomSelectValue(selectId);
|
||||
closeAllCustomSelects();
|
||||
});
|
||||
menu.appendChild(optionButton);
|
||||
});
|
||||
|
||||
syncCustomSelectValue(selectId);
|
||||
}
|
||||
|
||||
function setupCustomSelect(selectId) {
|
||||
const nativeSelect = document.getElementById(selectId);
|
||||
if (!nativeSelect) return;
|
||||
|
||||
nativeSelect.classList.add('native-select-hidden');
|
||||
|
||||
let customSelect = document.querySelector(`.custom-select[data-for="${selectId}"]`);
|
||||
if (!customSelect) {
|
||||
customSelect = document.createElement('div');
|
||||
customSelect.className = 'custom-select';
|
||||
customSelect.dataset.for = selectId;
|
||||
customSelect.innerHTML = `
|
||||
<button type="button" class="custom-select-trigger" aria-haspopup="listbox" aria-expanded="false"></button>
|
||||
<div class="custom-select-menu" role="listbox"></div>
|
||||
`;
|
||||
nativeSelect.insertAdjacentElement('afterend', customSelect);
|
||||
|
||||
const trigger = customSelect.querySelector('.custom-select-trigger');
|
||||
trigger.addEventListener('click', function(event) {
|
||||
event.stopPropagation();
|
||||
const isOpen = customSelect.classList.contains('open');
|
||||
closeAllCustomSelects();
|
||||
if (!isOpen) {
|
||||
const rect = customSelect.getBoundingClientRect();
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
customSelect.classList.toggle('drop-up', spaceBelow < 236);
|
||||
customSelect.classList.add('open');
|
||||
trigger.setAttribute('aria-expanded', 'true');
|
||||
activeCustomSelect = customSelect;
|
||||
}
|
||||
});
|
||||
|
||||
trigger.addEventListener('keydown', function(event) {
|
||||
if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
trigger.click();
|
||||
} else if (event.key === 'Escape') {
|
||||
closeAllCustomSelects();
|
||||
}
|
||||
});
|
||||
|
||||
nativeSelect.addEventListener('change', function() {
|
||||
syncCustomSelectValue(selectId);
|
||||
});
|
||||
}
|
||||
|
||||
renderCustomSelectOptions(selectId);
|
||||
}
|
||||
|
||||
// 页面加载时初始化
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
setupCustomSelect('pageSizeSelect');
|
||||
loadUsers();
|
||||
document.getElementById('pageSizeSelect').value = pageSize;
|
||||
syncCustomSelectValue('pageSizeSelect');
|
||||
const editModalElement = document.getElementById('editModal');
|
||||
if (editModalElement && window.bootstrap) {
|
||||
editModalInstance = new bootstrap.Modal(editModalElement);
|
||||
editModalElement.addEventListener('hidden.bs.modal', function() {
|
||||
editingUserId = null;
|
||||
});
|
||||
}
|
||||
const pageJumpInput = document.getElementById('pageJumpInput');
|
||||
pageJumpInput.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
jumpToPage(true);
|
||||
}
|
||||
});
|
||||
pageJumpInput.addEventListener('blur', function() {
|
||||
jumpToPage(true);
|
||||
});
|
||||
pageJumpInput.addEventListener('input', function() {
|
||||
clearTimeout(jumpDebounceTimer);
|
||||
jumpDebounceTimer = setTimeout(function() {
|
||||
jumpToPage(false);
|
||||
}, 450);
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener('click', function(event) {
|
||||
if (!event.target.closest('.custom-select')) {
|
||||
closeAllCustomSelects();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function(event) {
|
||||
if (event.key === 'Escape') {
|
||||
closeAllCustomSelects();
|
||||
}
|
||||
});
|
||||
|
||||
// 加载用户列表
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: currentPage,
|
||||
page_size: pageSize
|
||||
});
|
||||
const response = await fetch(`/api/users?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
users = data;
|
||||
totalUsers = data.length;
|
||||
} else {
|
||||
users = data.users || [];
|
||||
totalUsers = data.total || users.length;
|
||||
}
|
||||
renderUsers();
|
||||
updatePagination();
|
||||
} catch (error) {
|
||||
console.error('加载用户失败:', error);
|
||||
document.getElementById('usersTableContainer').innerHTML =
|
||||
'<div class="error">加载用户数据失败</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染用户列表
|
||||
function renderUsers() {
|
||||
const container = document.getElementById('usersTableContainer');
|
||||
|
||||
if (users.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state">暂无用户数据</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = `
|
||||
<table class="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>用户名</th>
|
||||
<th>IP 地址</th>
|
||||
<th>总请求数</th>
|
||||
<th>输入token</th>
|
||||
<th>输出token</th>
|
||||
<th>总 Token</th>
|
||||
${whitelistControlEnabled ? '<th>白名单访问</th>' : ''}
|
||||
<th>创建时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
`;
|
||||
|
||||
users.forEach(user => {
|
||||
const whitelistEnabled = !!user.whitelist_access_enabled;
|
||||
|
||||
html += `
|
||||
<tr>
|
||||
<td>${user.username}</td>
|
||||
<td><span class="ip-address">${user.ip_address}</span></td>
|
||||
<td>${(user.total_request_count || 0).toLocaleString()}</td>
|
||||
<td>${(user.prompt_tokens || 0).toLocaleString()}</td>
|
||||
<td>${(user.completion_tokens || 0).toLocaleString()}</td>
|
||||
<td>${(user.total_tokens || 0).toLocaleString()}</td>
|
||||
${whitelistControlEnabled ? `
|
||||
<td>
|
||||
<label class="switch">
|
||||
<input type="checkbox" ${whitelistEnabled ? 'checked' : ''} onchange="toggleUser(${user.id})">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</td>` : ''}
|
||||
<td>${formatDateTime(new Date(user.created_at))}</td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
<button class="btn-action btn-edit" onclick="openEditModal(${user.id})">编辑</button>
|
||||
<button class="btn-action btn-delete" onclick="deleteUser(${user.id})">删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
html += '</tbody></table>';
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// 更新分页
|
||||
function updatePagination() {
|
||||
const totalPages = Math.ceil(totalUsers / pageSize) || 1;
|
||||
currentTotalPages = totalPages;
|
||||
document.getElementById('pageInfo').textContent = `第 ${currentPage} 页 / 共 ${totalPages} 页`;
|
||||
document.getElementById('prevBtn').classList.toggle('disabled', currentPage <= 1);
|
||||
document.getElementById('nextBtn').classList.toggle('disabled', currentPage >= totalPages);
|
||||
document.getElementById('totalCount').textContent = `共 ${totalUsers} 条`;
|
||||
document.getElementById('pageSizeSelect').value = pageSize;
|
||||
syncCustomSelectValue('pageSizeSelect');
|
||||
const pageJumpInput = document.getElementById('pageJumpInput');
|
||||
pageJumpInput.max = totalPages;
|
||||
pageJumpInput.value = currentPage;
|
||||
document.getElementById('tableFooter').style.display = 'flex';
|
||||
}
|
||||
|
||||
function jumpToPage(force = false) {
|
||||
const totalPages = Math.ceil(totalUsers / pageSize) || 1;
|
||||
const input = document.getElementById('pageJumpInput');
|
||||
const page = parseInt(input.value, 10);
|
||||
if (Number.isNaN(page)) {
|
||||
if (force) input.value = currentPage;
|
||||
return;
|
||||
}
|
||||
const target = Math.min(totalPages, Math.max(1, page));
|
||||
if (target === currentPage) {
|
||||
input.value = currentPage;
|
||||
return;
|
||||
}
|
||||
currentPage = target;
|
||||
loadUsers();
|
||||
}
|
||||
|
||||
// 上一页
|
||||
function prevPage() {
|
||||
if (currentPage <= 1) return;
|
||||
currentPage--;
|
||||
loadUsers();
|
||||
}
|
||||
|
||||
// 下一页
|
||||
function nextPage() {
|
||||
if (currentPage >= currentTotalPages) return;
|
||||
currentPage++;
|
||||
loadUsers();
|
||||
}
|
||||
|
||||
// 更改每页大小
|
||||
function changePageSize() {
|
||||
const newPageSize = parseInt(document.getElementById('pageSizeSelect').value);
|
||||
if (newPageSize !== pageSize) {
|
||||
pageSize = newPageSize;
|
||||
currentPage = 1;
|
||||
loadUsers();
|
||||
}
|
||||
}
|
||||
|
||||
// 创建用户
|
||||
async function createUser() {
|
||||
const username = document.getElementById('newUsername').value.trim();
|
||||
const ipAddress = document.getElementById('newIpAddress').value.trim();
|
||||
|
||||
if (!username) {
|
||||
showMessage('请输入用户名', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ipAddress) {
|
||||
showMessage('请输入IP地址', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: username,
|
||||
ip_address: ipAddress
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
showMessage('用户创建成功', 'success');
|
||||
document.getElementById('newUsername').value = '';
|
||||
document.getElementById('newIpAddress').value = '';
|
||||
loadUsers();
|
||||
} else {
|
||||
showMessage(result.error || '创建失败', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建用户失败:', error);
|
||||
showMessage('创建用户失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 打开编辑模态框
|
||||
function openEditModal(userId) {
|
||||
const user = users.find(u => u.id === userId);
|
||||
if (!user) return;
|
||||
|
||||
editingUserId = userId;
|
||||
document.getElementById('editUsername').value = user.username;
|
||||
document.getElementById('editIpAddress').value = user.ip_address;
|
||||
|
||||
if (editModalInstance) {
|
||||
editModalInstance.show();
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭编辑模态框
|
||||
function closeEditModal() {
|
||||
if (editModalInstance) {
|
||||
editModalInstance.hide();
|
||||
}
|
||||
editingUserId = null;
|
||||
}
|
||||
|
||||
// 保存用户修改
|
||||
async function saveUser() {
|
||||
if (!editingUserId) return;
|
||||
|
||||
const username = document.getElementById('editUsername').value.trim();
|
||||
const ipAddress = document.getElementById('editIpAddress').value.trim();
|
||||
|
||||
if (!username) {
|
||||
showMessage('请输入用户名', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ipAddress) {
|
||||
showMessage('请输入IP地址', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/users/${editingUserId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: username,
|
||||
ip_address: ipAddress
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
showMessage('用户更新成功', 'success');
|
||||
closeEditModal();
|
||||
loadUsers();
|
||||
} else {
|
||||
showMessage(result.error || '更新失败', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新用户失败:', error);
|
||||
showMessage('更新用户失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 切换用户状态
|
||||
async function toggleUser(userId) {
|
||||
try {
|
||||
const response = await fetch(`/api/users/${userId}/toggle`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
loadUsers();
|
||||
} else {
|
||||
console.error('切换白名单访问状态失败:', result.error || '切换失败');
|
||||
loadUsers();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('切换白名单访问状态失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 删除用户
|
||||
async function deleteUser(userId) {
|
||||
if (!confirm('确定要删除该用户吗?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/users/${userId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
showMessage('用户删除成功', 'success');
|
||||
loadUsers();
|
||||
} else {
|
||||
showMessage(result.error || '删除失败', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除用户失败:', error);
|
||||
showMessage('删除用户失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(date) {
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
</script>
|
||||
<script src="/static/vendor/bootstrap/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""用户控制器。"""
|
||||
|
||||
from flask import Response, jsonify, request
|
||||
|
||||
from ..application.app_context import AppContext
|
||||
from ..services import AuthenticationService, UserService
|
||||
from ..utils import is_valid_ip, normalize_ip
|
||||
from .decorators import require_authentication
|
||||
|
||||
|
||||
class UserController:
|
||||
"""处理用户管理 API。"""
|
||||
|
||||
def __init__(self, ctx: AppContext, user_service: UserService, auth_service: AuthenticationService):
|
||||
self._ctx = ctx
|
||||
self._app = ctx.flask_app
|
||||
self._logger = ctx.logger
|
||||
self._config_manager = ctx.config_manager
|
||||
self._user_service = user_service
|
||||
self._auth_service = auth_service
|
||||
self._register_routes()
|
||||
|
||||
def _register_routes(self) -> None:
|
||||
"""注册用户管理路由。"""
|
||||
auth = require_authentication(self._auth_service)
|
||||
|
||||
self._app.route('/api/users', methods=['GET'])(auth(self.get_users))
|
||||
self._app.route('/api/users', methods=['POST'])(auth(self.create_user))
|
||||
self._app.route('/api/users/<int:user_id>', methods=['GET'])(auth(self.get_user))
|
||||
self._app.route('/api/users/<int:user_id>', methods=['PUT'])(auth(self.update_user))
|
||||
self._app.route('/api/users/<int:user_id>', methods=['DELETE'])(auth(self.delete_user))
|
||||
self._app.route('/api/users/<int:user_id>/toggle', methods=['POST'])(auth(self.toggle_user))
|
||||
|
||||
def get_users(self) -> Response:
|
||||
"""分页查询用户列表。"""
|
||||
try:
|
||||
page = request.args.get('page', 1, type=int)
|
||||
page_size = request.args.get('page_size', 50, type=int)
|
||||
self._logger.info(f"List users requested: page={page}, page_size={page_size}")
|
||||
|
||||
users = self._user_service.get_users(page=page, page_size=page_size)
|
||||
total = self._user_service.get_total_users_count()
|
||||
|
||||
return jsonify({
|
||||
'users': users,
|
||||
'total': total,
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
'total_pages': (total + page_size - 1) // page_size
|
||||
})
|
||||
except Exception as exc:
|
||||
self._logger.error(f'Error getting users: {exc}')
|
||||
return jsonify({'error': str(exc)}), 500
|
||||
|
||||
def get_user(self, user_id: int) -> Response:
|
||||
"""查询单个用户。"""
|
||||
try:
|
||||
user = self._user_service.get_user_by_id(user_id)
|
||||
if not user:
|
||||
self._logger.warning(f"Get user failed: user_id={user_id} not found")
|
||||
return jsonify({'error': 'User not found'}), 404
|
||||
return jsonify(user)
|
||||
except Exception as exc:
|
||||
self._logger.error(f'Error getting user: {exc}')
|
||||
return jsonify({'error': str(exc)}), 500
|
||||
|
||||
def create_user(self) -> Response:
|
||||
"""创建用户。"""
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
username = data.get('username')
|
||||
ip_address = data.get('ip_address')
|
||||
|
||||
if not username:
|
||||
self._logger.warning("Create user rejected: username is empty")
|
||||
return jsonify({'error': 'Username is required'}), 400
|
||||
if not ip_address:
|
||||
self._logger.warning("Create user rejected: ip_address is empty")
|
||||
return jsonify({'error': 'IP address is required'}), 400
|
||||
|
||||
normalized_ip = normalize_ip(ip_address)
|
||||
if not is_valid_ip(normalized_ip):
|
||||
self._logger.warning(f"Create user rejected: invalid ip_address={ip_address!r}")
|
||||
return jsonify({'error': 'Invalid IP address'}), 400
|
||||
|
||||
user_id = self._user_service.create_user(username, normalized_ip)
|
||||
if not user_id:
|
||||
self._logger.warning(f"Create user failed: username={username!r}, ip={normalized_ip}")
|
||||
return jsonify({'error': 'Failed to create user or IP already exists'}), 400
|
||||
|
||||
self._logger.info(f"Create user succeeded: user_id={user_id}, username={username!r}, ip={normalized_ip}")
|
||||
return jsonify({'id': user_id, 'message': 'User created successfully'}), 201
|
||||
except Exception as exc:
|
||||
self._logger.error(f'Error creating user: {exc}')
|
||||
return jsonify({'error': str(exc)}), 500
|
||||
|
||||
def update_user(self, user_id: int) -> Response:
|
||||
"""更新用户信息。"""
|
||||
try:
|
||||
data = request.get_json(silent=True)
|
||||
if not data:
|
||||
self._logger.warning(f"Update user rejected: no payload, user_id={user_id}")
|
||||
return jsonify({'error': 'No data provided'}), 400
|
||||
|
||||
username = data.get('username')
|
||||
ip_address = data.get('ip_address')
|
||||
whitelist_access_enabled = data.get('whitelist_access_enabled')
|
||||
|
||||
normalized_ip = None
|
||||
if ip_address is not None:
|
||||
normalized_ip = normalize_ip(ip_address)
|
||||
if not is_valid_ip(normalized_ip):
|
||||
self._logger.warning(f"Update user rejected: invalid ip={ip_address!r}, user_id={user_id}")
|
||||
return jsonify({'error': 'Invalid IP address'}), 400
|
||||
|
||||
success = self._user_service.update_user(
|
||||
user_id,
|
||||
username,
|
||||
normalized_ip,
|
||||
whitelist_access_enabled,
|
||||
)
|
||||
if not success:
|
||||
self._logger.warning(f"Update user failed: user_id={user_id}")
|
||||
return jsonify({'error': 'Failed to update user'}), 400
|
||||
|
||||
self._logger.info(f"Update user succeeded: user_id={user_id}")
|
||||
return jsonify({'message': 'User updated successfully'})
|
||||
except Exception as exc:
|
||||
self._logger.error(f'Error updating user: {exc}')
|
||||
return jsonify({'error': str(exc)}), 500
|
||||
|
||||
def delete_user(self, user_id: int) -> Response:
|
||||
"""删除用户。"""
|
||||
try:
|
||||
if not self._user_service.delete_user(user_id):
|
||||
self._logger.warning(f"Delete user failed: user_id={user_id}")
|
||||
return jsonify({'error': 'Failed to delete user'}), 400
|
||||
self._logger.info(f"Delete user succeeded: user_id={user_id}")
|
||||
return jsonify({'message': 'User deleted successfully'})
|
||||
except Exception as exc:
|
||||
self._logger.error(f'Error deleting user: {exc}')
|
||||
return jsonify({'error': str(exc)}), 500
|
||||
|
||||
def toggle_user(self, user_id: int) -> Response:
|
||||
"""切换用户白名单状态。"""
|
||||
try:
|
||||
if not self._config_manager.is_chat_whitelist_enabled():
|
||||
return jsonify({'error': 'Whitelist control is disabled'}), 400
|
||||
if not self._user_service.toggle_user_status(user_id):
|
||||
self._logger.warning(f"Toggle user whitelist failed: user_id={user_id}")
|
||||
return jsonify({'error': 'Failed to toggle user status'}), 400
|
||||
self._logger.info(f"Toggle user whitelist succeeded: user_id={user_id}")
|
||||
return jsonify({'message': 'User status toggled successfully'})
|
||||
except Exception as exc:
|
||||
self._logger.error(f'Error toggling user status: {exc}')
|
||||
return jsonify({'error': str(exc)}), 500
|
||||
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""页面与统计控制器。"""
|
||||
|
||||
from flask import Response, jsonify, render_template, request
|
||||
|
||||
from ..application.app_context import AppContext
|
||||
from ..services import AuthenticationService, LogService
|
||||
from .decorators import require_authentication
|
||||
|
||||
|
||||
class WebController:
|
||||
"""处理页面渲染与统计相关 API。"""
|
||||
|
||||
def __init__(self, ctx: AppContext, log_service: LogService, auth_service: AuthenticationService):
|
||||
self._ctx = ctx
|
||||
self._app = ctx.flask_app
|
||||
self._logger = ctx.logger
|
||||
self._config_manager = ctx.config_manager
|
||||
self._log_service = log_service
|
||||
self._auth_service = auth_service
|
||||
self._register_routes()
|
||||
|
||||
def _register_routes(self) -> None:
|
||||
"""注册页面与统计路由。"""
|
||||
auth = require_authentication(self._auth_service)
|
||||
|
||||
self._app.route('/')(auth(self.index))
|
||||
self._app.route('/users')(auth(self.users_page))
|
||||
|
||||
self._app.route('/api/statistics', methods=['GET'])(auth(self.get_statistics))
|
||||
self._app.route('/api/request-logs', methods=['GET'])(auth(self.get_request_logs))
|
||||
self._app.route('/api/usernames', methods=['GET'])(auth(self.get_usernames))
|
||||
self._app.route('/api/request-models', methods=['GET'])(auth(self.get_request_models))
|
||||
|
||||
def index(self) -> str:
|
||||
"""首页。"""
|
||||
return render_template('index.html')
|
||||
|
||||
def users_page(self) -> str:
|
||||
"""用户管理页。"""
|
||||
return render_template(
|
||||
'users.html',
|
||||
chat_whitelist_enabled=self._config_manager.is_chat_whitelist_enabled(),
|
||||
)
|
||||
|
||||
def get_statistics(self) -> Response:
|
||||
"""查询统计聚合数据。"""
|
||||
try:
|
||||
self._logger.info(
|
||||
"Statistics queried: start_date=%s end_date=%s username=%s request_model=%s",
|
||||
request.args.get('start_date'),
|
||||
request.args.get('end_date'),
|
||||
request.args.get('username'),
|
||||
request.args.get('request_model'),
|
||||
)
|
||||
stats = self._log_service.get_statistics(
|
||||
request.args.get('start_date'),
|
||||
request.args.get('end_date'),
|
||||
request.args.get('username'),
|
||||
request.args.get('request_model'),
|
||||
)
|
||||
return jsonify(stats)
|
||||
except Exception as exc:
|
||||
self._logger.error(f'Error getting statistics: {exc}')
|
||||
return jsonify({'error': str(exc)}), 500
|
||||
|
||||
def get_request_logs(self) -> Response:
|
||||
"""查询请求日志分页数据。"""
|
||||
try:
|
||||
page = max(int(request.args.get('page', 1)), 1)
|
||||
page_size = min(max(int(request.args.get('page_size', 50)), 1), 200)
|
||||
self._logger.info(f"Request logs queried: page={page}, page_size={page_size}")
|
||||
|
||||
logs = self._log_service.get_request_logs(
|
||||
page,
|
||||
page_size,
|
||||
request.args.get('start_date'),
|
||||
request.args.get('end_date'),
|
||||
request.args.get('username'),
|
||||
request.args.get('request_model'),
|
||||
)
|
||||
return jsonify(logs)
|
||||
except ValueError:
|
||||
return jsonify({'error': 'page and page_size must be integers'}), 400
|
||||
except Exception as exc:
|
||||
self._logger.error(f'Error getting request logs: {exc}')
|
||||
return jsonify({'error': str(exc)}), 500
|
||||
|
||||
def get_usernames(self) -> Response:
|
||||
"""查询已出现过请求记录的用户名列表。"""
|
||||
try:
|
||||
usernames = self._log_service.get_unique_usernames()
|
||||
self._logger.info(f"Usernames queried: count={len(usernames)}")
|
||||
return jsonify(usernames)
|
||||
except Exception as exc:
|
||||
self._logger.error(f'Error getting usernames: {exc}')
|
||||
return jsonify({'error': str(exc)}), 500
|
||||
|
||||
def get_request_models(self) -> Response:
|
||||
"""查询出现过请求日志的请求模型列表。"""
|
||||
try:
|
||||
models = self._log_service.get_unique_request_models()
|
||||
self._logger.info(f"Request models queried: count={len(models)}")
|
||||
return jsonify(models)
|
||||
except Exception as exc:
|
||||
self._logger.error(f'Error getting request models: {exc}')
|
||||
return jsonify({'error': str(exc)}), 500
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""仓储层导出。"""
|
||||
|
||||
from .user_repository import UserRepository
|
||||
from .log_repository import LogRepository
|
||||
|
||||
__all__ = [
|
||||
'UserRepository',
|
||||
'LogRepository',
|
||||
]
|
||||
@@ -0,0 +1,270 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""请求日志仓储。"""
|
||||
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from ..utils.database import ConnectionFactory
|
||||
|
||||
|
||||
class LogRepository:
|
||||
"""负责 request_logs 与 daily_request_stats 的数据访问。"""
|
||||
|
||||
def __init__(self, get_connection: ConnectionFactory):
|
||||
self._get_connection = get_connection
|
||||
self._ensure_table()
|
||||
|
||||
def _ensure_table(self) -> None:
|
||||
"""初始化日志相关数据表与索引。"""
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
'''
|
||||
CREATE TABLE IF NOT EXISTS request_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ip_address TEXT,
|
||||
request_model TEXT NOT NULL,
|
||||
response_model TEXT,
|
||||
total_tokens INTEGER,
|
||||
prompt_tokens INTEGER DEFAULT 0,
|
||||
completion_tokens INTEGER DEFAULT 0,
|
||||
start_time TIMESTAMP NOT NULL,
|
||||
end_time TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
'''
|
||||
)
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_start_time ON request_logs(start_time)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_ip_address ON request_logs(ip_address)')
|
||||
cursor.execute(
|
||||
'''
|
||||
CREATE TABLE IF NOT EXISTS daily_request_stats (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
stat_date TEXT NOT NULL,
|
||||
ip_address TEXT,
|
||||
request_model TEXT NOT NULL,
|
||||
response_model TEXT NOT NULL DEFAULT '',
|
||||
request_count INTEGER NOT NULL DEFAULT 0,
|
||||
total_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
prompt_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
completion_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(stat_date, ip_address, request_model, response_model)
|
||||
)
|
||||
'''
|
||||
)
|
||||
cursor.execute(
|
||||
'CREATE INDEX IF NOT EXISTS idx_daily_stats_date ON daily_request_stats(stat_date)'
|
||||
)
|
||||
cursor.execute(
|
||||
'CREATE INDEX IF NOT EXISTS idx_daily_stats_ip ON daily_request_stats(ip_address)'
|
||||
)
|
||||
|
||||
def insert(
|
||||
self,
|
||||
request_model: str,
|
||||
response_model: Optional[str],
|
||||
total_tokens: int,
|
||||
prompt_tokens: int = 0,
|
||||
completion_tokens: int = 0,
|
||||
start_time: Optional[datetime] = None,
|
||||
end_time: Optional[datetime] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
) -> Optional[int]:
|
||||
"""写入单条请求日志,并同步更新日聚合统计。"""
|
||||
if start_time is None:
|
||||
start_time = datetime.now()
|
||||
if end_time is None:
|
||||
end_time = datetime.now()
|
||||
safe_total_tokens = int(total_tokens or 0)
|
||||
safe_prompt_tokens = int(prompt_tokens or 0)
|
||||
safe_completion_tokens = int(completion_tokens or 0)
|
||||
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
response_model_key = response_model or ''
|
||||
cursor.execute(
|
||||
'''
|
||||
INSERT INTO request_logs
|
||||
(ip_address, request_model, response_model, total_tokens,
|
||||
prompt_tokens, completion_tokens, start_time, end_time)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''',
|
||||
(
|
||||
ip_address,
|
||||
request_model,
|
||||
response_model,
|
||||
safe_total_tokens,
|
||||
safe_prompt_tokens,
|
||||
safe_completion_tokens,
|
||||
start_time,
|
||||
end_time,
|
||||
),
|
||||
)
|
||||
stat_date = start_time.strftime('%Y-%m-%d')
|
||||
cursor.execute(
|
||||
'''
|
||||
INSERT INTO daily_request_stats
|
||||
(
|
||||
stat_date, ip_address, request_model, response_model,
|
||||
request_count, total_tokens, prompt_tokens, completion_tokens, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, 1, ?, ?, ?, ?)
|
||||
ON CONFLICT(stat_date, ip_address, request_model, response_model)
|
||||
DO UPDATE SET
|
||||
request_count = request_count + 1,
|
||||
total_tokens = total_tokens + excluded.total_tokens,
|
||||
prompt_tokens = prompt_tokens + excluded.prompt_tokens,
|
||||
completion_tokens = completion_tokens + excluded.completion_tokens,
|
||||
updated_at = excluded.updated_at
|
||||
''',
|
||||
(
|
||||
stat_date,
|
||||
ip_address,
|
||||
request_model,
|
||||
response_model_key,
|
||||
safe_total_tokens,
|
||||
safe_prompt_tokens,
|
||||
safe_completion_tokens,
|
||||
datetime.now(),
|
||||
),
|
||||
)
|
||||
return cursor.lastrowid
|
||||
|
||||
def get_statistics(
|
||||
self,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
request_model: Optional[str] = None,
|
||||
) -> List[sqlite3.Row]:
|
||||
"""按条件查询聚合统计。"""
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
conditions = []
|
||||
params = []
|
||||
|
||||
if start_date:
|
||||
conditions.append('d.stat_date >= ?')
|
||||
params.append(start_date)
|
||||
if end_date:
|
||||
conditions.append('d.stat_date <= ?')
|
||||
params.append(end_date)
|
||||
if username:
|
||||
conditions.append('u.username = ?')
|
||||
params.append(username)
|
||||
if request_model:
|
||||
conditions.append('d.request_model = ?')
|
||||
params.append(request_model)
|
||||
|
||||
where_clause = ' AND '.join(conditions) if conditions else '1=1'
|
||||
query = f'''
|
||||
SELECT
|
||||
d.ip_address,
|
||||
COALESCE(u.username, '-') as username,
|
||||
d.request_model,
|
||||
NULLIF(d.response_model, '') as response_model,
|
||||
COALESCE(SUM(d.request_count), 0) as request_count,
|
||||
COALESCE(SUM(d.total_tokens), 0) as total_tokens,
|
||||
COALESCE(SUM(d.prompt_tokens), 0) as prompt_tokens,
|
||||
COALESCE(SUM(d.completion_tokens), 0) as completion_tokens
|
||||
FROM daily_request_stats d
|
||||
LEFT JOIN users u ON d.ip_address = u.ip_address
|
||||
WHERE {where_clause}
|
||||
GROUP BY d.ip_address, u.username, d.request_model, d.response_model
|
||||
ORDER BY total_tokens DESC
|
||||
'''
|
||||
cursor.execute(query, params)
|
||||
return cursor.fetchall()
|
||||
|
||||
def get_logs(
|
||||
self,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
request_model: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""按条件分页查询原始请求日志。"""
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
conditions = []
|
||||
params = []
|
||||
|
||||
if start_date:
|
||||
conditions.append('l.start_time >= ?')
|
||||
params.append(f'{start_date} 00:00:00')
|
||||
if end_date:
|
||||
conditions.append('l.start_time < DATE(?, "+1 day")')
|
||||
params.append(end_date)
|
||||
if username:
|
||||
conditions.append('u.username = ?')
|
||||
params.append(username)
|
||||
if request_model:
|
||||
conditions.append('l.request_model = ?')
|
||||
params.append(request_model)
|
||||
|
||||
where_clause = ' AND '.join(conditions) if conditions else '1=1'
|
||||
|
||||
count_query = (
|
||||
'SELECT COUNT(*) as total FROM request_logs l '
|
||||
'LEFT JOIN users u ON l.ip_address = u.ip_address '
|
||||
f'WHERE {where_clause}'
|
||||
)
|
||||
cursor.execute(count_query, params)
|
||||
total = cursor.fetchone()['total']
|
||||
|
||||
data_query = f'''
|
||||
SELECT l.id, l.ip_address, COALESCE(u.username, '-') as username, l.request_model, l.response_model,
|
||||
l.total_tokens, l.prompt_tokens, l.completion_tokens, l.start_time, l.end_time
|
||||
FROM request_logs l
|
||||
LEFT JOIN users u ON l.ip_address = u.ip_address
|
||||
WHERE {where_clause}
|
||||
ORDER BY l.start_time DESC
|
||||
LIMIT ? OFFSET ?
|
||||
'''
|
||||
cursor.execute(data_query, params + [page_size, offset])
|
||||
logs = cursor.fetchall()
|
||||
|
||||
return {
|
||||
'total': total,
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
'total_pages': (total + page_size - 1) // page_size,
|
||||
'logs': [dict(log) for log in logs],
|
||||
}
|
||||
|
||||
def get_unique_request_models(self) -> List[str]:
|
||||
"""查询有日志记录的请求模型列表。"""
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
'''
|
||||
SELECT DISTINCT request_model
|
||||
FROM request_logs
|
||||
WHERE request_model IS NOT NULL AND request_model != ''
|
||||
ORDER BY request_model
|
||||
'''
|
||||
)
|
||||
return [row['request_model'] for row in cursor.fetchall()]
|
||||
|
||||
def get_unique_usernames(self) -> List[str]:
|
||||
"""查询有日志记录的用户名列表。"""
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
'''
|
||||
SELECT DISTINCT u.username
|
||||
FROM users u
|
||||
INNER JOIN request_logs l ON l.ip_address = u.ip_address
|
||||
ORDER BY u.username
|
||||
'''
|
||||
)
|
||||
return [row['username'] for row in cursor.fetchall()]
|
||||
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""用户仓储。"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ..utils.database import ConnectionFactory
|
||||
|
||||
|
||||
class UserRepository:
|
||||
"""负责 users 表的数据访问。"""
|
||||
|
||||
def __init__(self, get_connection: ConnectionFactory):
|
||||
self._get_connection = get_connection
|
||||
self._ensure_table()
|
||||
|
||||
def _ensure_table(self) -> None:
|
||||
"""初始化用户表与索引。"""
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL,
|
||||
ip_address TEXT NOT NULL UNIQUE,
|
||||
whitelist_access_enabled INTEGER DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_ip ON users(ip_address)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)")
|
||||
|
||||
def create(self, username: str, ip_address: str) -> Optional[int]:
|
||||
"""创建用户记录。"""
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO users (username, ip_address, whitelist_access_enabled, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(username, ip_address, 1, datetime.now()),
|
||||
)
|
||||
return cursor.lastrowid
|
||||
|
||||
def get_by_id(self, user_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""按 ID 查询用户。"""
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT id, username, ip_address, whitelist_access_enabled, created_at, updated_at
|
||||
FROM users
|
||||
WHERE id = ?
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def get(self, page: int = 1, page_size: int = 50) -> List[Dict[str, Any]]:
|
||||
"""分页查询用户及其聚合统计。"""
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
offset = (page - 1) * page_size
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT
|
||||
u.id,
|
||||
u.username,
|
||||
u.ip_address,
|
||||
u.whitelist_access_enabled,
|
||||
u.created_at,
|
||||
u.updated_at,
|
||||
COALESCE(s.total_request_count, 0) AS total_request_count,
|
||||
COALESCE(s.total_tokens, 0) AS total_tokens,
|
||||
COALESCE(s.prompt_tokens, 0) AS prompt_tokens,
|
||||
COALESCE(s.completion_tokens, 0) AS completion_tokens
|
||||
FROM users u
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
ip_address,
|
||||
SUM(request_count) AS total_request_count,
|
||||
SUM(total_tokens) AS total_tokens,
|
||||
SUM(prompt_tokens) AS prompt_tokens,
|
||||
SUM(completion_tokens) AS completion_tokens
|
||||
FROM daily_request_stats
|
||||
GROUP BY ip_address
|
||||
) s ON u.ip_address = s.ip_address
|
||||
ORDER BY u.created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
(page_size, offset),
|
||||
)
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
def get_count(self) -> int:
|
||||
"""查询用户总数。"""
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT COUNT(*) AS count FROM users")
|
||||
return cursor.fetchone()["count"]
|
||||
|
||||
def update(
|
||||
self,
|
||||
user_id: int,
|
||||
username: Optional[str] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
whitelist_access_enabled: Optional[bool] = None,
|
||||
) -> bool:
|
||||
"""更新用户记录。"""
|
||||
updates = []
|
||||
params = []
|
||||
|
||||
if username is not None:
|
||||
updates.append("username = ?")
|
||||
params.append(username)
|
||||
if ip_address is not None:
|
||||
updates.append("ip_address = ?")
|
||||
params.append(ip_address)
|
||||
if whitelist_access_enabled is not None:
|
||||
updates.append("whitelist_access_enabled = ?")
|
||||
params.append(1 if whitelist_access_enabled else 0)
|
||||
|
||||
if not updates:
|
||||
return False
|
||||
|
||||
updates.append("updated_at = ?")
|
||||
params.append(datetime.now())
|
||||
params.append(user_id)
|
||||
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(f"UPDATE users SET {', '.join(updates)} WHERE id = ?", params)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def delete(self, user_id: int) -> bool:
|
||||
"""删除用户记录。"""
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def get_by_ip(self, ip_address: str) -> Optional[Dict[str, Any]]:
|
||||
"""按 IP 查询用户。"""
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT id, username, ip_address, whitelist_access_enabled, created_at, updated_at
|
||||
FROM users
|
||||
WHERE ip_address = ?
|
||||
""",
|
||||
(ip_address,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
return dict(row) if row else None
|
||||
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""服务层导出。"""
|
||||
|
||||
from .authentication_service import AuthenticationService
|
||||
from .log_service import LogService
|
||||
from .proxy_service import ProxyService
|
||||
from .user_service import UserService
|
||||
|
||||
__all__ = ["AuthenticationService", "UserService", "LogService", "ProxyService"]
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""认证服务。"""
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from ..application.app_context import AppContext, Logger
|
||||
|
||||
|
||||
class AuthenticationService:
|
||||
"""处理管理员登录与内存会话管理。"""
|
||||
|
||||
def __init__(self, ctx: AppContext):
|
||||
self._ctx = ctx
|
||||
self._config_manager = ctx.config_manager
|
||||
self._logger: Logger = ctx.logger
|
||||
self._sessions: Dict[str, Dict[str, Any]] = {}
|
||||
self._session_max_age = 86400
|
||||
|
||||
def is_auth_enabled(self) -> bool:
|
||||
"""判断是否启用认证。"""
|
||||
return self._config_manager.is_auth_enabled()
|
||||
|
||||
def authenticate(self, username: str, password: str) -> bool:
|
||||
"""校验管理员用户名与密码。"""
|
||||
admin_config = self._config_manager.get_admin_config() or {}
|
||||
expected_username = admin_config.get("username")
|
||||
expected_password = admin_config.get("password")
|
||||
|
||||
if not expected_username or not expected_password:
|
||||
self._logger.warning("Authentication skipped: admin credentials are not configured")
|
||||
return False
|
||||
|
||||
# 使用 compare_digest 可降低时序攻击风险,避免通过比较耗时推断凭据内容。
|
||||
is_valid = (
|
||||
secrets.compare_digest(str(username), str(expected_username))
|
||||
and secrets.compare_digest(str(password), str(expected_password))
|
||||
)
|
||||
if not is_valid:
|
||||
self._logger.warning(f"Authentication failed for username={username!r}")
|
||||
return is_valid
|
||||
|
||||
def create_session(self, username: str) -> str:
|
||||
"""创建新会话并返回 session token。"""
|
||||
self._cleanup_expired_sessions()
|
||||
|
||||
session_token = secrets.token_urlsafe(32)
|
||||
expires = datetime.now() + timedelta(seconds=self._session_max_age)
|
||||
|
||||
self._sessions[session_token] = {"username": username, "expires": expires}
|
||||
self._logger.info(f"Created session for user={username!r}")
|
||||
return session_token
|
||||
|
||||
def validate_session(self, session_token: Optional[str]) -> bool:
|
||||
"""校验会话是否存在且未过期。"""
|
||||
if not session_token:
|
||||
return False
|
||||
|
||||
session = self._sessions.get(session_token)
|
||||
if not session:
|
||||
return False
|
||||
|
||||
if datetime.now() > session["expires"]:
|
||||
del self._sessions[session_token]
|
||||
self._logger.info("Session expired and removed")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def destroy_session(self, session_token: str) -> None:
|
||||
"""销毁指定会话。"""
|
||||
session = self._sessions.pop(session_token, None)
|
||||
if session:
|
||||
self._logger.info(f"Destroyed session for user={session['username']!r}")
|
||||
|
||||
def get_cookie_settings(self) -> Dict[str, Any]:
|
||||
"""返回 session cookie 配置。"""
|
||||
return {
|
||||
"max_age": self._session_max_age,
|
||||
"httponly": True,
|
||||
"secure": False,
|
||||
"samesite": "Lax",
|
||||
}
|
||||
|
||||
def _cleanup_expired_sessions(self) -> None:
|
||||
"""清理内存中过期会话。"""
|
||||
now = datetime.now()
|
||||
expired = [token for token, session in self._sessions.items() if now > session["expires"]]
|
||||
for token in expired:
|
||||
del self._sessions[token]
|
||||
if expired:
|
||||
self._logger.info(f"Expired sessions cleaned: count={len(expired)}")
|
||||
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""日志服务。"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ..application.app_context import AppContext
|
||||
from ..repositories import LogRepository
|
||||
|
||||
|
||||
class LogService:
|
||||
"""封装请求日志相关业务逻辑。"""
|
||||
|
||||
def __init__(self, ctx: AppContext, repository: LogRepository):
|
||||
self._ctx = ctx
|
||||
self._logger = ctx.logger
|
||||
self._repository = repository
|
||||
|
||||
def log_request(
|
||||
self,
|
||||
request_model: str,
|
||||
response_model: Optional[str],
|
||||
total_tokens: int,
|
||||
prompt_tokens: int = 0,
|
||||
completion_tokens: int = 0,
|
||||
start_time: Optional[datetime] = None,
|
||||
end_time: Optional[datetime] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
) -> Optional[int]:
|
||||
"""记录一次请求日志。"""
|
||||
try:
|
||||
log_id = self._repository.insert(
|
||||
request_model=request_model,
|
||||
response_model=response_model,
|
||||
total_tokens=total_tokens,
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
self._logger.info(
|
||||
"Request log saved: id=%s model=%s response_model=%s total_tokens=%s ip=%s",
|
||||
log_id,
|
||||
request_model,
|
||||
response_model,
|
||||
total_tokens,
|
||||
ip_address,
|
||||
)
|
||||
return log_id
|
||||
except Exception as exc:
|
||||
self._logger.error(f"Failed to log request: {exc}")
|
||||
return None
|
||||
|
||||
def get_statistics(
|
||||
self,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
request_model: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""获取统计聚合数据。"""
|
||||
try:
|
||||
rows = self._repository.get_statistics(
|
||||
start_date,
|
||||
end_date,
|
||||
username=username,
|
||||
request_model=request_model,
|
||||
)
|
||||
result = [
|
||||
{
|
||||
"request_model": row["request_model"],
|
||||
"response_model": row["response_model"],
|
||||
"ip_address": row["ip_address"],
|
||||
"username": row["username"],
|
||||
"request_count": row["request_count"],
|
||||
"total_tokens": row["total_tokens"],
|
||||
"prompt_tokens": row["prompt_tokens"],
|
||||
"completion_tokens": row["completion_tokens"],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
self._logger.info(
|
||||
"Statistics queried: start_date=%s end_date=%s username=%s rows=%s",
|
||||
start_date,
|
||||
end_date,
|
||||
username,
|
||||
len(result),
|
||||
)
|
||||
return result
|
||||
except Exception as exc:
|
||||
self._logger.error(f"Failed to get statistics: {exc}")
|
||||
return []
|
||||
|
||||
def get_request_logs(
|
||||
self,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
request_model: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""获取请求日志分页数据。"""
|
||||
try:
|
||||
result = self._repository.get_logs(
|
||||
page,
|
||||
page_size,
|
||||
start_date,
|
||||
end_date,
|
||||
username=username,
|
||||
request_model=request_model,
|
||||
)
|
||||
self._logger.info(
|
||||
"Request logs queried: page=%s page_size=%s total=%s",
|
||||
page,
|
||||
page_size,
|
||||
result.get("total", 0),
|
||||
)
|
||||
return result
|
||||
except Exception as exc:
|
||||
self._logger.error(f"Failed to get request logs: {exc}")
|
||||
return {
|
||||
"total": 0,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_pages": 0,
|
||||
"logs": [],
|
||||
}
|
||||
|
||||
def get_unique_usernames(self) -> List[str]:
|
||||
"""获取有日志记录的用户名列表。"""
|
||||
try:
|
||||
usernames = self._repository.get_unique_usernames()
|
||||
self._logger.info(f"Unique usernames queried: count={len(usernames)}")
|
||||
return usernames
|
||||
except Exception as exc:
|
||||
self._logger.error(f"Failed to get unique usernames: {exc}")
|
||||
return []
|
||||
|
||||
def get_unique_request_models(self) -> List[str]:
|
||||
"""获取有日志记录的请求模型列表。"""
|
||||
try:
|
||||
models = self._repository.get_unique_request_models()
|
||||
self._logger.info(f"Unique request models queried: count={len(models)}")
|
||||
return models
|
||||
except Exception as exc:
|
||||
self._logger.error(f"Failed to get unique request models: {exc}")
|
||||
return []
|
||||
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""代理服务。"""
|
||||
|
||||
import threading
|
||||
from typing import Any, Callable, Dict, Iterator, Optional, Tuple
|
||||
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
from flask import Response, stream_with_context
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
from ..application.app_context import AppContext
|
||||
from ..external import LLMProvider
|
||||
from ..hooks import HookContext
|
||||
|
||||
|
||||
class _PrefetchedStreamResponse:
|
||||
def __init__(self, response: requests.Response, first_chunk: bytes):
|
||||
self._response = response
|
||||
self._first_chunk = first_chunk
|
||||
self.status_code = response.status_code
|
||||
self.headers = response.headers
|
||||
|
||||
def iter_content(self, chunk_size: Optional[int] = None) -> Iterator[bytes]:
|
||||
if self._first_chunk:
|
||||
yield self._first_chunk
|
||||
self._first_chunk = b""
|
||||
yield from self._response.iter_content(chunk_size=chunk_size)
|
||||
|
||||
def close(self) -> None:
|
||||
self._response.close()
|
||||
|
||||
|
||||
class _BufferedUpstreamResponse:
|
||||
def __init__(self, response: requests.Response, body: bytes):
|
||||
self._response = response
|
||||
self.content = body
|
||||
self.status_code = response.status_code
|
||||
self.headers = response.headers
|
||||
|
||||
def close(self) -> None:
|
||||
self._response.close()
|
||||
|
||||
|
||||
class ProxyService:
|
||||
"""处理上游 LLM 代理请求。"""
|
||||
|
||||
def __init__(self, ctx: AppContext):
|
||||
self._logger = ctx.logger
|
||||
self._root_path = ctx.root_path
|
||||
self._http_local = threading.local()
|
||||
|
||||
def proxy_request(
|
||||
self,
|
||||
provider: LLMProvider,
|
||||
request_data: Dict[str, Any],
|
||||
request_headers: Dict[str, str],
|
||||
on_complete: Optional[Callable[[Dict[str, Any]], None]] = None,
|
||||
) -> Tuple[Optional[Response], int]:
|
||||
"""代理请求到指定 provider,并执行重试与输出钩子。"""
|
||||
target_url = provider.api
|
||||
model_name = request_data["model"]
|
||||
|
||||
timeout_seconds = provider.timeout_seconds
|
||||
max_retries = provider.max_retries
|
||||
verify_ssl = provider.verify_ssl
|
||||
|
||||
def build_request(attempt: int) -> Tuple[Dict[str, str], Dict[str, Any], HookContext]:
|
||||
"""构建重试请求的 headers/body/context。"""
|
||||
headers = dict(request_headers)
|
||||
headers["content-type"] = "application/json"
|
||||
|
||||
if provider.api_key:
|
||||
headers["authorization"] = f"Bearer {provider.api_key}"
|
||||
|
||||
request_ctx = HookContext(retry=attempt, root_path=self._root_path, logger=self._logger)
|
||||
headers = provider.apply_header_hook(request_ctx, headers)
|
||||
|
||||
body = dict(request_data)
|
||||
body = provider.apply_input_body_hook(request_ctx, body)
|
||||
body["model"] = model_name.rsplit("/", 1)[-1]
|
||||
return headers, body, request_ctx
|
||||
|
||||
for attempt in range(max_retries):
|
||||
headers, body, request_ctx = build_request(attempt)
|
||||
requested_stream = bool(body.get("stream", False))
|
||||
http_session: Optional[requests.Session] = None
|
||||
self._logger.info(
|
||||
"Proxying upstream request: provider=%s model=%s attempt=%s/%s stream=%s",
|
||||
provider.name,
|
||||
body.get("model"),
|
||||
attempt + 1,
|
||||
max_retries,
|
||||
requested_stream,
|
||||
)
|
||||
|
||||
try:
|
||||
http_session = self._get_http_session()
|
||||
upstream_response = http_session.post(
|
||||
target_url,
|
||||
headers=headers,
|
||||
json=body,
|
||||
stream=requested_stream,
|
||||
verify=verify_ssl,
|
||||
timeout=timeout_seconds,
|
||||
)
|
||||
|
||||
status_code = upstream_response.status_code
|
||||
should_retry_status = self._should_retry_status_code(status_code)
|
||||
if should_retry_status and attempt < max_retries - 1:
|
||||
self._logger.warning(
|
||||
"Retryable upstream status (attempt %s/%s): provider=%s status=%s, retrying",
|
||||
attempt + 1,
|
||||
max_retries,
|
||||
provider.name,
|
||||
status_code,
|
||||
)
|
||||
upstream_response.close()
|
||||
continue
|
||||
|
||||
content_type = (upstream_response.headers.get("Content-Type") or "").lower()
|
||||
upstream_stream = "text/event-stream" in content_type
|
||||
response_for_hook: Any = upstream_response
|
||||
if requested_stream and not upstream_stream:
|
||||
response_for_hook, is_stream = self._probe_stream_response(upstream_response)
|
||||
self._logger.warning(
|
||||
"Stream mode probed for mismatch: requested_stream=%s upstream_stream=%s probed_stream=%s content_type=%s",
|
||||
requested_stream,
|
||||
upstream_stream,
|
||||
is_stream,
|
||||
content_type,
|
||||
)
|
||||
else:
|
||||
is_stream = upstream_stream
|
||||
|
||||
if requested_stream != upstream_stream:
|
||||
self._logger.warning(
|
||||
"Stream mode mismatch: requested_stream=%s, upstream_stream=%s",
|
||||
requested_stream,
|
||||
upstream_stream,
|
||||
)
|
||||
|
||||
response = provider.apply_output_body_hook(
|
||||
request_ctx,
|
||||
response_for_hook,
|
||||
is_stream,
|
||||
self._filter_response_headers,
|
||||
stream_with_context,
|
||||
self._logger,
|
||||
on_complete=on_complete,
|
||||
)
|
||||
self._logger.info(
|
||||
"Upstream request completed: provider=%s status=%s stream=%s",
|
||||
provider.name,
|
||||
status_code,
|
||||
is_stream,
|
||||
)
|
||||
if is_stream:
|
||||
def _close_upstream_resources() -> None:
|
||||
response_for_hook.close()
|
||||
|
||||
response.call_on_close(_close_upstream_resources)
|
||||
else:
|
||||
response_for_hook.close()
|
||||
return response, status_code
|
||||
except requests.exceptions.RequestException as exc:
|
||||
self._logger.error(
|
||||
"Request error (attempt %s/%s): %s",
|
||||
attempt + 1,
|
||||
max_retries,
|
||||
exc,
|
||||
)
|
||||
if attempt < max_retries - 1:
|
||||
continue
|
||||
|
||||
return None, 502
|
||||
|
||||
@staticmethod
|
||||
def _should_retry_status_code(status_code: int) -> bool:
|
||||
retryable_status_codes = {408, 409, 425, 429, 500, 502, 503, 504}
|
||||
return status_code in retryable_status_codes
|
||||
|
||||
def _get_http_session(self) -> requests.Session:
|
||||
session = getattr(self._http_local, "session", None)
|
||||
if session is None:
|
||||
session = self._build_http_session()
|
||||
self._http_local.session = session
|
||||
return session
|
||||
|
||||
@staticmethod
|
||||
def _build_http_session() -> requests.Session:
|
||||
session = requests.Session()
|
||||
adapter = HTTPAdapter(
|
||||
pool_connections=100,
|
||||
pool_maxsize=100,
|
||||
max_retries=Retry(total=0, connect=0, read=0, redirect=0, status=0),
|
||||
)
|
||||
session.mount("http://", adapter)
|
||||
session.mount("https://", adapter)
|
||||
return session
|
||||
|
||||
@staticmethod
|
||||
def _looks_like_sse_chunk(chunk: bytes) -> bool:
|
||||
if not chunk:
|
||||
return False
|
||||
text = chunk.decode("utf-8", errors="ignore").lstrip()
|
||||
return text.startswith("data:") or text.startswith("event:") or text.startswith(":")
|
||||
|
||||
def _probe_stream_response(self, upstream_response: requests.Response) -> Tuple[Any, bool]:
|
||||
chunk_iter = upstream_response.iter_content(chunk_size=None)
|
||||
first_chunk = b""
|
||||
for chunk in chunk_iter:
|
||||
if chunk:
|
||||
first_chunk = chunk
|
||||
break
|
||||
|
||||
if not first_chunk:
|
||||
return _BufferedUpstreamResponse(upstream_response, b""), False
|
||||
|
||||
if self._looks_like_sse_chunk(first_chunk):
|
||||
return _PrefetchedStreamResponse(upstream_response, first_chunk), True
|
||||
|
||||
remaining = b"".join(chunk_iter)
|
||||
return _BufferedUpstreamResponse(upstream_response, first_chunk + remaining), False
|
||||
|
||||
@staticmethod
|
||||
def _filter_response_headers(headers: Any) -> Dict[str, str]:
|
||||
"""过滤不应透传给客户端的 hop-by-hop 响应头。"""
|
||||
excluded = {
|
||||
"transfer-encoding",
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailers",
|
||||
"upgrade",
|
||||
"set-cookie",
|
||||
}
|
||||
return {k: v for k, v in headers.items() if k.lower() not in excluded}
|
||||
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""用户服务。"""
|
||||
|
||||
import threading
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ..application.app_context import AppContext
|
||||
from ..repositories import UserRepository
|
||||
from ..utils import is_valid_ip
|
||||
|
||||
|
||||
class UserService:
|
||||
"""封装用户管理业务逻辑。"""
|
||||
|
||||
def __init__(self, ctx: AppContext, repository: UserRepository):
|
||||
self._ctx = ctx
|
||||
self._logger = ctx.logger
|
||||
self._repository = repository
|
||||
self._cache_lock = threading.RLock()
|
||||
self._user_by_ip_cache: Dict[str, Optional[Dict[str, Any]]] = {}
|
||||
|
||||
def _get_cached_user_by_ip(self, ip_address: str) -> tuple[bool, Optional[Dict[str, Any]]]:
|
||||
"""读取 IP 缓存,返回是否命中与缓存值。"""
|
||||
with self._cache_lock:
|
||||
if ip_address not in self._user_by_ip_cache:
|
||||
return False, None
|
||||
return True, self._user_by_ip_cache[ip_address]
|
||||
|
||||
def _set_cached_user_by_ip(self, ip_address: str, user: Optional[Dict[str, Any]]) -> None:
|
||||
"""写入 IP 对应缓存。"""
|
||||
with self._cache_lock:
|
||||
self._user_by_ip_cache[ip_address] = user
|
||||
|
||||
def _invalidate_ip_cache(self, *ip_addresses: Optional[str]) -> None:
|
||||
"""按 IP 失效缓存。"""
|
||||
with self._cache_lock:
|
||||
for ip_address in ip_addresses:
|
||||
if ip_address:
|
||||
self._user_by_ip_cache.pop(ip_address, None)
|
||||
|
||||
def create_user(self, username: str, ip_address: str) -> Optional[int]:
|
||||
"""创建用户。"""
|
||||
try:
|
||||
if not is_valid_ip(ip_address):
|
||||
self._logger.error(f'Invalid IP address: {ip_address}')
|
||||
return None
|
||||
|
||||
existing_ip = self._repository.get_by_ip(ip_address)
|
||||
if existing_ip:
|
||||
self._logger.error(f'IP address already in use: {ip_address}')
|
||||
return None
|
||||
|
||||
user_id = self._repository.create(username, ip_address)
|
||||
self._invalidate_ip_cache(ip_address)
|
||||
self._logger.info(f"User created: user_id={user_id}, username={username!r}, ip={ip_address}")
|
||||
return user_id
|
||||
except Exception as e:
|
||||
self._logger.error(f'Failed to create user: {e}')
|
||||
return None
|
||||
|
||||
def get_user_by_id(self, user_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""按 ID 查询用户。"""
|
||||
try:
|
||||
return self._repository.get_by_id(user_id)
|
||||
except Exception as e:
|
||||
self._logger.error(f'Failed to get user: {e}')
|
||||
return None
|
||||
|
||||
def get_users(self, page: int = 1, page_size: int = 50) -> List[Dict[str, Any]]:
|
||||
"""分页查询用户列表。"""
|
||||
try:
|
||||
return self._repository.get(page=page, page_size=page_size)
|
||||
except Exception as e:
|
||||
self._logger.error(f'Failed to get users: {e}')
|
||||
return []
|
||||
|
||||
def get_total_users_count(self) -> int:
|
||||
"""查询用户总数。"""
|
||||
try:
|
||||
return self._repository.get_count()
|
||||
except Exception as e:
|
||||
self._logger.error(f'Failed to get users count: {e}')
|
||||
return 0
|
||||
|
||||
def update_user(
|
||||
self,
|
||||
user_id: int,
|
||||
username: Optional[str] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
whitelist_access_enabled: Optional[bool] = None,
|
||||
) -> bool:
|
||||
"""更新用户信息。"""
|
||||
try:
|
||||
existing_user = self._repository.get_by_id(user_id)
|
||||
if not existing_user:
|
||||
return False
|
||||
|
||||
if ip_address:
|
||||
existing = self._repository.get_by_ip(ip_address)
|
||||
if existing and existing['id'] != user_id:
|
||||
self._logger.error(f'IP address already in use: {ip_address}')
|
||||
return False
|
||||
|
||||
updated = self._repository.update(
|
||||
user_id,
|
||||
username,
|
||||
ip_address,
|
||||
whitelist_access_enabled,
|
||||
)
|
||||
if updated:
|
||||
self._invalidate_ip_cache(existing_user.get('ip_address'), ip_address)
|
||||
self._logger.info(f"User updated: user_id={user_id}")
|
||||
return updated
|
||||
except Exception as e:
|
||||
self._logger.error(f'Failed to update user: {e}')
|
||||
return False
|
||||
|
||||
def delete_user(self, user_id: int) -> bool:
|
||||
"""删除用户。"""
|
||||
try:
|
||||
existing_user = self._repository.get_by_id(user_id)
|
||||
deleted = self._repository.delete(user_id)
|
||||
if deleted and existing_user:
|
||||
self._invalidate_ip_cache(existing_user.get('ip_address'))
|
||||
self._logger.info(f"User deleted: user_id={user_id}")
|
||||
return deleted
|
||||
except Exception as e:
|
||||
self._logger.error(f'Failed to delete user: {e}')
|
||||
return False
|
||||
|
||||
def toggle_user_status(self, user_id: int) -> bool:
|
||||
"""切换用户白名单状态。"""
|
||||
try:
|
||||
user = self._repository.get_by_id(user_id)
|
||||
if not user:
|
||||
return False
|
||||
updated = self._repository.update(
|
||||
user_id,
|
||||
whitelist_access_enabled=not bool(user['whitelist_access_enabled']),
|
||||
)
|
||||
if updated:
|
||||
self._invalidate_ip_cache(user.get('ip_address'))
|
||||
self._logger.info(
|
||||
f"User whitelist toggled: user_id={user_id}, enabled={not bool(user['whitelist_access_enabled'])}"
|
||||
)
|
||||
return updated
|
||||
except Exception as e:
|
||||
self._logger.error(f'Failed to toggle user status: {e}')
|
||||
return False
|
||||
|
||||
def get_user_by_ip(
|
||||
self,
|
||||
ip_address: str,
|
||||
require_whitelist_access: bool = False,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""按 IP 查询用户,可选要求白名单开关为启用。"""
|
||||
try:
|
||||
if not ip_address:
|
||||
return None
|
||||
|
||||
hit, cached_user = self._get_cached_user_by_ip(ip_address)
|
||||
if not hit:
|
||||
cached_user = self._repository.get_by_ip(ip_address)
|
||||
self._set_cached_user_by_ip(ip_address, cached_user)
|
||||
|
||||
if not cached_user:
|
||||
return None
|
||||
if require_whitelist_access and not bool(cached_user.get('whitelist_access_enabled')):
|
||||
return None
|
||||
return cached_user
|
||||
except Exception as e:
|
||||
self._logger.error(f'Failed to get user by IP: {e}')
|
||||
return None
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""工具模块导出。"""
|
||||
|
||||
from .net import is_valid_ip, normalize_ip
|
||||
|
||||
__all__ = ['is_valid_ip', 'normalize_ip']
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""数据库工具函数。"""
|
||||
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Callable, ContextManager, Iterator
|
||||
|
||||
|
||||
ConnectionFactory = Callable[[], ContextManager[sqlite3.Connection]]
|
||||
|
||||
|
||||
def create_connection_factory(db_path: Path) -> ConnectionFactory:
|
||||
"""创建 SQLite 连接上下文工厂。"""
|
||||
resolved_db_path = db_path.resolve()
|
||||
resolved_db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@contextmanager
|
||||
def connection_context() -> Iterator[sqlite3.Connection]:
|
||||
conn = sqlite3.connect(str(resolved_db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return connection_context
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""网络相关工具函数。"""
|
||||
|
||||
import ipaddress
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def normalize_ip(ip_value: Optional[str]) -> str:
|
||||
"""规范化客户端 IP,并去除 IPv6 映射前缀。"""
|
||||
if not ip_value:
|
||||
return ''
|
||||
|
||||
value = ip_value.strip()
|
||||
if value.startswith('::ffff:'):
|
||||
value = value[7:]
|
||||
|
||||
try:
|
||||
parsed = ipaddress.ip_address(value)
|
||||
return str(parsed)
|
||||
except ValueError:
|
||||
return value
|
||||
|
||||
|
||||
def is_valid_ip(ip_value: Optional[str]) -> bool:
|
||||
"""校验 IPv4/IPv6 地址格式。"""
|
||||
if not ip_value:
|
||||
return False
|
||||
|
||||
try:
|
||||
ipaddress.ip_address(ip_value.strip().removeprefix('::ffff:'))
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
Reference in new issue
Block a user