feat: 拆分日志配置并增强管理页
This commit is contained in:
1 parent
e08244ed20
commit
4b24fb700e
9 files changed
+224
-40
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 : {};
|
||||
|
||||
@@ -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 接口超时时间(秒)
|
||||
|
||||
@@ -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...")
|
||||
|
||||
Reference in new issue
Block a user