This commit is contained in:
Von committed 2026-03-10 23:29:02 +08:00
commit 37a608fb45
44 files changed
+5222

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
codemate
*.pyc
.claude
data
logs
.opencode
.history
config.yaml
+147
View File
@@ -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 时应明确返回原对象或改造此行为。
- 会话是进程内存态,不适合多实例部署;如果未来需要横向扩展,应改为共享会话存储。
+98
View File
@@ -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`
+37
View File
@@ -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:
+40
View File
@@ -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
+52
View File
@@ -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()
+8
View File
@@ -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']
+31
View File
@@ -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"
+166
View File
@@ -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()
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""配置层导出。"""
from .config_manager import ConfigManager
from .provider_manager import ProviderManager
__all__ = [
"ConfigManager",
"ProviderManager",
]
+70
View File
@@ -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 {}
+162
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""外部服务层导出。"""
from .llm_provider import LLMProvider
__all__ = ['LLMProvider']
+202
View File
@@ -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'),
)
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Hook public exports."""
from .contracts import BaseHook, HookContext, HookModule
__all__ = ['BaseHook', 'HookContext', 'HookModule']
+42
View File
@@ -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
+17
View File
@@ -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',
]
+22
View File
@@ -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
+82
View File
@@ -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
+32
View File
@@ -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
+180
View File
@@ -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}
+434
View File
@@ -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;
}
}
+183
View File
@@ -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;
}
}
+116
View File
@@ -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;
}
}
+315
View File
@@ -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;
}
}
+83
View File
@@ -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 */
+568
View File
@@ -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()">&lt;</span>
<span class="page-info" id="pageInfo">第 1 页 / 共 1 页</span>
<span id="nextBtn" class="page-nav-text disabled" onclick="nextPage()">&gt;</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>
+78
View File
@@ -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>
+561
View File
@@ -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()">&lt;</span>
<span class="page-info" id="pageInfo">第 1 页 / 共 1 页</span>
<span id="nextBtn" class="page-nav-text disabled" onclick="nextPage()">&gt;</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>
+158
View File
@@ -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
+108
View File
@@ -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
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""仓储层导出。"""
from .user_repository import UserRepository
from .log_repository import LogRepository
__all__ = [
'UserRepository',
'LogRepository',
]
+270
View File
@@ -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()]
+161
View File
@@ -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
+10
View File
@@ -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"]
+94
View File
@@ -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)}")
+150
View File
@@ -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 []
+241
View File
@@ -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}
+174
View File
@@ -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
+7
View File
@@ -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']
+32
View File
@@ -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
+34
View File
@@ -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