feat: 拆分日志配置并增强管理页

This commit is contained in:
XianYuDaXian committed 2026-03-28 11:00:08 +08:00
1 parent e08244ed20
commit 4b24fb700e
9 files changed
+224 -40

No files matched your search

+56 -1
View File
@@ -1,10 +1,12 @@
import os
import shutil
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from app.core.auth import verify_app_key
from app.core.config import config
from app.core.logger import logger
from app.core.logger import logger, LOG_DIR
from app.services.grok.services.model import ModelService
from app.services.token import get_token_manager
from app.core.storage import (
@@ -18,6 +20,44 @@ from app.services.cf_refresh.scheduler import refresh_once
router = APIRouter()
def _clear_log_dir() -> dict:
"""清空日志目录内容,保留目录本身。"""
log_dir = Path(LOG_DIR)
log_dir.mkdir(parents=True, exist_ok=True)
deleted_files = 0
deleted_dirs = 0
released_bytes = 0
for child in log_dir.iterdir():
try:
if child.is_file() or child.is_symlink():
try:
released_bytes += child.stat().st_size
except OSError:
pass
child.unlink(missing_ok=True)
deleted_files += 1
continue
if child.is_dir():
for nested in child.rglob("*"):
if nested.is_file():
try:
released_bytes += nested.stat().st_size
except OSError:
pass
shutil.rmtree(child, ignore_errors=False)
deleted_dirs += 1
except FileNotFoundError:
continue
return {
"log_dir": str(log_dir),
"deleted_files": deleted_files,
"deleted_dirs": deleted_dirs,
"released_bytes": released_bytes,
}
@router.get("/verify", dependencies=[Depends(verify_app_key)])
async def admin_verify():
"""验证后台访问密钥(app_key)"""
@@ -65,6 +105,21 @@ async def refresh_cf_clearance():
raise HTTPException(status_code=500, detail=str(e))
@router.post("/config/clear-logs", dependencies=[Depends(verify_app_key)])
async def clear_logs():
"""清空日志目录。"""
try:
result = _clear_log_dir()
return {
"status": "success",
"message": "日志文件夹已清空",
"data": result,
}
except Exception as e:
logger.error(f"Clear log directory failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/model-routing/meta", dependencies=[Depends(verify_app_key)])
async def get_model_routing_meta():
"""获取模型池路由界面所需的模型与池元数据。"""
+2 -24
View File
@@ -2,11 +2,11 @@
Chat Completions API 路由
"""
import asyncio
from typing import Any, Dict, List, Optional, Union
import base64
import binascii
import asyncio
import time
from typing import Any, Dict, List, Optional, Union
import orjson
from fastapi import APIRouter, Request
@@ -28,14 +28,6 @@ from app.services.grok.utils.response import (
wrap_image_content,
)
try:
with open("app_traffic.log", "w", encoding="utf-8") as f:
import datetime
f.write(f"=== app_traffic.log Cleared on Uvicorn Loaded [{datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] ===\n\n")
except Exception:
pass
class MessageItem(BaseModel):
"""消息项"""
@@ -1041,20 +1033,6 @@ async def chat_completions(request: ChatCompletionRequest, raw_request: Request)
except Exception as e:
return _chat_error_as_success_response(request.model, _video_error_message(e))
else:
try:
with open("app_traffic.log", "a", encoding="utf-8") as f:
import datetime
f.write(f"--- Chat Request [{datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] ---\n")
dump = {
"model": request.model,
"messages": [m.model_dump() for m in request.messages],
"stream": request.stream,
"tools": request.tools if request.tools else None,
}
f.write(orjson.dumps(dump, option=orjson.OPT_INDENT_2).decode('utf-8') + "\n\n")
except Exception:
pass
result = await ChatService.completions(
model=request.model,
messages=[msg.model_dump() for msg in request.messages],
+35 -2
View File
@@ -50,6 +50,8 @@ def _migrate_deprecated_config(
"grok.thinking": "app.thinking",
"grok.dynamic_statsig": "app.dynamic_statsig",
"grok.filter_tags": "app.filter_tags",
"chat.capture_enabled": "app.chat_capture_enabled",
"chat.capture_file": "app.chat_capture_file",
"grok.timeout": "voice.timeout",
"grok.base_proxy_url": "proxy.base_proxy_url",
"grok.asset_proxy_url": "proxy.asset_proxy_url",
@@ -154,13 +156,16 @@ def _migrate_deprecated_config(
"thinking": "thinking",
"dynamic_statsig": "dynamic_statsig",
"filter_tags": "filter_tags",
"capture_enabled": "chat_capture_enabled",
"capture_file": "chat_capture_file",
}
chat_section = config.get("chat")
if isinstance(chat_section, dict):
app_section = result.setdefault("app", {})
for old_key, new_key in legacy_chat_map.items():
if old_key in chat_section and new_key not in app_section:
app_section[new_key] = chat_section[old_key]
if old_key in chat_section:
if new_key not in app_section:
app_section[new_key] = chat_section[old_key]
if isinstance(result.get("chat"), dict):
result["chat"].pop(old_key, None)
migrated_count += 1
@@ -292,10 +297,38 @@ class Config:
storage = get_storage()
async with storage.acquire_lock("config_save", timeout=10):
self._ensure_defaults()
previous = deepcopy(self._config or {})
base = _deep_merge(self._defaults, self._config or {})
merged = _deep_merge(base, new_config or {})
await storage.save_config(merged)
self._config = merged
self._apply_runtime_updates(previous, self._config)
def _apply_runtime_updates(self, previous: Dict[str, Any], current: Dict[str, Any]):
"""配置变更后立即应用可热更新的运行时设置。"""
prev_app = previous.get("app") if isinstance(previous, dict) else {}
curr_app = current.get("app") if isinstance(current, dict) else {}
prev_app = prev_app if isinstance(prev_app, dict) else {}
curr_app = curr_app if isinstance(curr_app, dict) else {}
log_related_keys = {
"app_log_enabled",
"chat_capture_enabled",
"chat_capture_file",
}
if any(prev_app.get(key) != curr_app.get(key) for key in log_related_keys):
try:
from app.core.logger import setup_logging
import os
setup_logging(
level=os.getenv("LOG_LEVEL", "INFO"),
json_console=False,
file_logging=bool(curr_app.get("app_log_enabled", True)),
)
logger.info("Applied runtime log configuration update.")
except Exception as e:
logger.warning(f"Failed to hot-apply log configuration: {e}")
# 全局配置实例
+15 -1
View File
@@ -16,9 +16,19 @@ if not hasattr(logger, "isEnabledFor"):
# 日志目录
DEFAULT_LOG_DIR = Path(__file__).parent.parent.parent / "logs"
LOG_DIR = Path(os.getenv("LOG_DIR", str(DEFAULT_LOG_DIR)))
LEGACY_TRAFFIC_LOG = DEFAULT_LOG_DIR.parent / "app_traffic.log"
_LOG_DIR_READY = False
def _cleanup_legacy_logs() -> None:
"""清理历史遗留的根目录日志文件。"""
try:
if LEGACY_TRAFFIC_LOG.exists() and LEGACY_TRAFFIC_LOG.is_file():
LEGACY_TRAFFIC_LOG.unlink()
except Exception:
pass
def _prepare_log_dir() -> bool:
"""确保日志目录可用"""
global LOG_DIR, _LOG_DIR_READY
@@ -26,6 +36,7 @@ def _prepare_log_dir() -> bool:
return True
try:
LOG_DIR.mkdir(parents=True, exist_ok=True)
_cleanup_legacy_logs()
_LOG_DIR_READY = True
return True
except Exception:
@@ -108,10 +119,13 @@ def _file_json_sink(message):
def setup_logging(
level: str = "DEBUG",
json_console: bool = True,
file_logging: bool = True,
file_logging: bool | None = None,
):
"""设置日志配置"""
logger.remove()
if file_logging is None:
from app.core.config import get_config
file_logging = bool(get_config("app.app_log_enabled", True))
file_logging = _env_flag("LOG_FILE_ENABLED", file_logging)
enqueue_logs = _should_enqueue_logs()
+5 -2
View File
@@ -343,10 +343,13 @@ def _build_capture_event(resp: Dict[str, Any], model: str, phase: str, seq: int)
def _capture_app_chat_event(resp: Dict[str, Any], model: str, phase: str, seq: int) -> None:
if not get_config("chat.capture_enabled"):
if not get_config("app.chat_capture_enabled", get_config("chat.capture_enabled", False)):
return
capture_file = get_config("chat.capture_file") or str(LOG_DIR / "app_chat_capture.jsonl")
capture_file = get_config(
"app.chat_capture_file",
get_config("chat.capture_file", str(LOG_DIR / "app_chat_capture.jsonl")),
) or str(LOG_DIR / "app_chat_capture.jsonl")
try:
event = _build_capture_event(resp, model, phase, seq)
with open(capture_file, "ab") as f:
+16
View File
@@ -21,6 +21,22 @@
color: var(--accents-7);
}
.config-section-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-top: 12px;
margin-bottom: 4px;
}
.config-footer-actions {
display: flex;
justify-content: flex-start;
margin-top: 8px;
padding-top: 4px;
}
.config-grid {
display: grid;
gap: 14px;
+82 -4
View File
@@ -50,7 +50,10 @@ const LOCALE_MAP = {
"stream": { title: "流式响应", desc: "是否默认启用流式输出。" },
"thinking": { title: "思维链", desc: "是否默认启用思维链输出。" },
"dynamic_statsig": { title: "动态指纹", desc: "是否默认启用动态生成 Statsig 指纹。" },
"filter_tags": { title: "过滤标签", desc: "设置自动过滤 Grok 响应中的特殊标签。" }
"filter_tags": { title: "过滤标签", desc: "设置自动过滤 Grok 响应中的特殊标签。" },
"app_log_enabled": { title: "应用运行日志", desc: "控制是否将服务运行日志写入 logs/app_YYYY-MM-DD.log,用于排查启动、接口错误和系统异常。" },
"chat_capture_enabled": { title: "对话抓包日志", desc: "控制是否记录对话上游原始响应到抓包文件,用于排查图片来源、卡片附件和搜索结果关联问题。" },
"chat_capture_file": { title: "对话抓包文件", desc: "对话抓包日志的写入路径,建议保留在 logs 目录下统一管理。" }
},
@@ -182,6 +185,9 @@ const CF_MANAGED_PROXY_KEYS = ['cf_clearance', 'browser', 'user_agent'];
const CF_REFRESH_SUB_KEYS = ['flaresolverr_url', 'refresh_interval', 'timeout'];
const SECTION_ORDER = new Map(Object.keys(LOCALE_MAP).map((key, index) => [key, index]));
const HIDDEN_CONFIG_KEYS = new Map([
['chat', new Set(['capture_enabled', 'capture_file'])],
]);
function getText(section, key) {
if (LOCALE_MAP[section] && LOCALE_MAP[section][key]) {
@@ -374,7 +380,8 @@ function renderConfig(data) {
const keyOrder = localeSection ? new Map(Object.keys(localeSection).map((k, i) => [k, i])) : null;
const allKeys = sortByOrder(Object.keys(items), keyOrder);
const visibleKeys = allKeys.filter(key => !(section === 'proxy' && key === 'cf_cookies'));
const hiddenKeys = HIDDEN_CONFIG_KEYS.get(section) || new Set();
const visibleKeys = allKeys.filter(key => !(section === 'proxy' && key === 'cf_cookies') && !hiddenKeys.has(key));
if (visibleKeys.length > 0) {
const card = document.createElement('div');
@@ -385,7 +392,7 @@ function renderConfig(data) {
if (section === 'proxy') {
const actionRow = document.createElement('div');
actionRow.className = 'flex items-center gap-2 mt-3 mb-4';
actionRow.className = 'config-section-actions';
const refreshBtn = document.createElement('button');
refreshBtn.type = 'button';
@@ -404,7 +411,7 @@ function renderConfig(data) {
actionRow.appendChild(refreshBtn);
header.appendChild(actionRow);
}
// 添加部分说明(如果有)
if (SECTION_DESCRIPTIONS[section]) {
const descP = document.createElement('p');
@@ -424,6 +431,27 @@ function renderConfig(data) {
});
card.appendChild(grid);
if (section === 'app') {
const footerActions = document.createElement('div');
footerActions.className = 'config-footer-actions';
const clearLogsBtn = document.createElement('button');
clearLogsBtn.type = 'button';
clearLogsBtn.id = 'clear-logs-btn';
clearLogsBtn.className = 'geist-button-danger gap-2';
clearLogsBtn.innerHTML = `
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18"></path>
<path d="M8 6V4h8v2"></path>
<path d="M19 6l-1 14H6L5 6"></path>
<path d="M10 11v6"></path>
<path d="M14 11v6"></path>
</svg>
清空日志文件夹
`;
clearLogsBtn.addEventListener('click', clearLogsDirectory);
card.appendChild(footerActions);
footerActions.appendChild(clearLogsBtn);
}
if (grid.children.length > 0) {
fragment.appendChild(card);
}
@@ -467,6 +495,56 @@ async function manualRefreshCfClearance() {
}
}
function formatBytes(bytes) {
const size = Number(bytes || 0);
if (!size) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let value = size;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${value >= 10 || unitIndex === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`;
}
async function clearLogsDirectory() {
const btn = byId('clear-logs-btn');
if (!btn) return;
const confirmed = window.confirm('将清空 logs 文件夹中的全部日志文件,是否继续?');
if (!confirmed) return;
const originalHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = `
<svg class="animate-spin" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12a9 9 0 1 1-6.219-8.56"></path>
</svg>
清理中...
`;
try {
const res = await fetch('/v1/admin/config/clear-logs', {
method: 'POST',
headers: buildAuthHeaders(apiKey)
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.detail || data.message || `HTTP ${res.status}`);
}
const info = data.data || {};
const filesText = Number(info.deleted_files || 0);
const dirsText = Number(info.deleted_dirs || 0);
const bytesText = formatBytes(info.released_bytes || 0);
showToast(`已清空日志:${filesText} 个文件,${dirsText} 个目录,释放 ${bytesText}`, 'success');
} catch (e) {
showToast(`清空失败: ${e.message || e}`, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = originalHtml;
}
}
function normalizeModelRoutingAssignments(raw) {
const next = {};
const source = raw && typeof raw === 'object' ? raw : {};
+6 -4
View File
@@ -26,6 +26,12 @@ thinking = true
dynamic_statsig = true
# 过滤的特殊标签列表
filter_tags = ["xaiartifact","xai:tool_usage_card"]
# 是否将应用运行日志写入 logs/app_YYYY-MM-DD.log
app_log_enabled = true
# 是否记录对话详细抓包日志(用于排查图片来源、卡片原始返回等)
chat_capture_enabled = true
# 对话详细抓包日志文件路径(相对项目根目录)
chat_capture_file = "logs/app_chat_capture.jsonl"
# ==================== 代理配置 ====================
@@ -99,10 +105,6 @@ limit_mb = 512
[chat]
# Reverse 接口并发上限
concurrent = 50
# 是否将 app-chat 上游关键响应写入抓包文件
capture_enabled = true
# app-chat 详细抓包文件路径(JSONL)
capture_file = "logs/app_chat_capture.jsonl"
# 连接超时时间(秒),用于弱网快速失败
connect_timeout = 12
# Reverse 接口超时时间(秒)
+7 -2
View File
@@ -49,9 +49,9 @@ from fastapi.staticfiles import StaticFiles
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用生命周期管理"""
# 在 worker 启动阶段初始化日志,避免 Granian 下模块导入时绑定的标准流失效。
# 启动早期先仅初始化控制台日志,配置加载后再按配置决定是否写文件。
setup_logging(
level=os.getenv("LOG_LEVEL", "INFO"), json_console=False, file_logging=True
level=os.getenv("LOG_LEVEL", "INFO"), json_console=False, file_logging=False
)
# 1. 注册服务默认配置
@@ -62,6 +62,11 @@ async def lifespan(app: FastAPI):
# 2. 加载配置
await config.load()
setup_logging(
level=os.getenv("LOG_LEVEL", "INFO"),
json_console=False,
file_logging=get_config("app.app_log_enabled", True),
)
# 3. 启动服务显示
logger.info("Starting Grok2API...")