a3216 commited on
Commit
41577fb
·
verified ·
1 Parent(s): bc29ff1

sync from GitHub dc52e4f: fix: 修复文件上传999报错与Web管理手机端适配

Browse files

Auto-synced from GitHub commit dc52e4f9ae5eb2d5b5e506091153c6d13a8bbf29

app/admin_html.py CHANGED
@@ -171,10 +171,63 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
171
  details.req-block summary { padding: 8px 12px; cursor: pointer; font-size: 13px; font-weight: 500; }
172
  details.req-block[open] summary { border-bottom: 1px solid var(--border); }
173
  details.req-block pre { margin: 0; border: none; border-radius: 0; max-height: 320px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  </style>
175
  </head>
176
  <body data-disabled="__DISABLED_ATTR__">
177
  <header>
 
178
  <h1>XTC 后台管理</h1>
179
  <div class="row" style="gap:8px">
180
  <span class="svc-status" id="svcStatus">服务: 检测中</span>
@@ -491,13 +544,43 @@ function esc(s) {
491
  return String(s==null?'':s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
492
  }
493
 
494
- // Tab 切换
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
495
  document.querySelectorAll('nav.tabs button').forEach(btn => {
496
  btn.onclick = () => {
497
  document.querySelectorAll('nav.tabs button').forEach(b => b.classList.remove('active'));
498
  btn.classList.add('active');
499
  const tab = btn.dataset.tab;
500
  document.querySelectorAll('.tab-content').forEach(c => c.classList.toggle('active', c.dataset.tab === tab));
 
 
501
  if (tab === 'overview') loadOverview();
502
  else if (tab === 'providers') loadProviders();
503
  else if (tab === 'tokens') loadTokens();
 
171
  details.req-block summary { padding: 8px 12px; cursor: pointer; font-size: 13px; font-weight: 500; }
172
  details.req-block[open] summary { border-bottom: 1px solid var(--border); }
173
  details.req-block pre { margin: 0; border: none; border-radius: 0; max-height: 320px; }
174
+
175
+ /* ===== 响应式:手机适配 ===== */
176
+ /* 移动端菜单按钮(默认隐藏,窄屏显示) */
177
+ .menu-toggle { display: none; }
178
+
179
+ /* 表格横向滚动容器(窄屏防溢出) */
180
+ .table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
181
+ .table-wrap table { min-width: 480px; }
182
+
183
+ /* 窄屏(<768px):侧边栏改为可折叠抽屉,主内容全宽 */
184
+ @media (max-width: 768px) {
185
+ header { padding: 10px 12px; gap: 8px; }
186
+ header h1 { font-size: 16px; }
187
+ .menu-toggle {
188
+ display: inline-block;
189
+ background: var(--panel2); border: 1px solid var(--border); color: var(--text);
190
+ padding: 6px 10px; border-radius: 6px; font-size: 18px; line-height: 1;
191
+ cursor: pointer; flex: 0 0 auto;
192
+ }
193
+ .layout { flex-direction: column; }
194
+ nav.tabs {
195
+ width: 100%; flex-shrink: 1; flex-wrap: wrap; gap: 4px;
196
+ padding: 0 8px 8px; border-right: none; border-bottom: 1px solid var(--border);
197
+ display: none; /* 默认折叠,点菜单按钮展开 */
198
+ }
199
+ nav.tabs.open { display: flex; }
200
+ nav.tabs button { flex: 1 1 auto; text-align: center; }
201
+ main { padding: 12px; }
202
+ /* KPI/stats 网格窄屏降为 2 列 */
203
+ .kpi-grid, .stats-grid { grid-template-columns: repeat(2, 1fr); gap: 8px; }
204
+ .kpi .value { font-size: 18px; }
205
+ /* filter-row 控件窄屏单列堆叠 */
206
+ .filter-row { gap: 6px; }
207
+ .filter-row input, .filter-row select, .filter-row .grow, .filter-row .shrink {
208
+ flex: 1 1 100%; min-width: 0;
209
+ }
210
+ .filter-row button { flex: 1 1 calc(50% - 3px); }
211
+ /* 分段选择器窄屏允许换行 */
212
+ .seg { flex-wrap: wrap; width: 100%; }
213
+ .seg button { flex: 1 1 auto; }
214
+ /* 流量分析横排窄屏换行 */
215
+ .ring-wrap, .traffic-head { flex-wrap: wrap; }
216
+ .bar-chart { height: 70px; }
217
+ .msg-bubble { max-width: 95%; }
218
+ /* 卡片内边距收紧 */
219
+ .card { padding: 12px; }
220
+ }
221
+
222
+ /* 极窄屏(<400px):KPI 单列 */
223
+ @media (max-width: 400px) {
224
+ .kpi-grid, .stats-grid { grid-template-columns: 1fr; }
225
+ }
226
  </style>
227
  </head>
228
  <body data-disabled="__DISABLED_ATTR__">
229
  <header>
230
+ <button class="menu-toggle" onclick="toggleNav()" aria-label="菜单">≡</button>
231
  <h1>XTC 后台管理</h1>
232
  <div class="row" style="gap:8px">
233
  <span class="svc-status" id="svcStatus">服务: 检测中</span>
 
544
  return String(s==null?'':s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
545
  }
546
 
547
+ // 手机端侧边栏抽屉开关
548
+ function toggleNav() {
549
+ document.querySelector('nav.tabs').classList.toggle('open');
550
+ }
551
+
552
+ // 自动给裸 table 包裹横向滚动容器(窄屏防溢出)。
553
+ // 用 MutationObserver 监听动态插入的表格,避免逐处改 innerHTML。
554
+ (function wrapTables() {
555
+ function wrapAll(root) {
556
+ root.querySelectorAll('table').forEach(t => {
557
+ if (t.parentElement && t.parentElement.classList.contains('table-wrap')) return;
558
+ const w = document.createElement('div');
559
+ w.className = 'table-wrap';
560
+ t.parentNode.insertBefore(w, t);
561
+ w.appendChild(t);
562
+ });
563
+ }
564
+ if (document.readyState === 'loading') {
565
+ document.addEventListener('DOMContentLoaded', () => {
566
+ wrapAll(document);
567
+ new MutationObserver(muts => muts.forEach(m => m.addedNodes.forEach(n => {
568
+ if (n.nodeType === 1) wrapAll(n);
569
+ }))).observe(document.body, { childList: true, subtree: true });
570
+ });
571
+ } else {
572
+ wrapAll(document);
573
+ }
574
+ })();
575
+
576
  document.querySelectorAll('nav.tabs button').forEach(btn => {
577
  btn.onclick = () => {
578
  document.querySelectorAll('nav.tabs button').forEach(b => b.classList.remove('active'));
579
  btn.classList.add('active');
580
  const tab = btn.dataset.tab;
581
  document.querySelectorAll('.tab-content').forEach(c => c.classList.toggle('active', c.dataset.tab === tab));
582
+ // 手机端选完 tab 后自动收起侧边栏
583
+ document.querySelector('nav.tabs').classList.remove('open');
584
  if (tab === 'overview') loadOverview();
585
  else if (tab === 'providers') loadProviders();
586
  else if (tab === 'tokens') loadTokens();
app/api/_common.py CHANGED
@@ -65,26 +65,43 @@ def record_usage(
65
  ok: bool,
66
  error_code: Optional[str] = None,
67
  ) -> None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  try:
69
- now = int(time.time())
70
- with get_conn() as conn:
71
- conn.execute(
72
- "INSERT INTO usage_log(ts, access_key, provider, model, prompt_tokens, completion_tokens, total_tokens, ok, error_code) "
73
- "VALUES(?,?,?,?,?,?,?,?,?)",
74
- (
75
- now,
76
- access_key,
77
- provider,
78
- model,
79
- int((usage or {}).get("prompt_tokens") or 0),
80
- int((usage or {}).get("completion_tokens") or 0),
81
- int((usage or {}).get("total_tokens") or 0),
82
- 1 if ok else 0,
83
- error_code,
84
- ),
85
- )
86
- except Exception as e:
87
- print(f"[usage] record failed: {e}")
88
 
89
  # Webhook 通知(fire-and-forget,失败不影响主流程)
90
  try:
 
65
  ok: bool,
66
  error_code: Optional[str] = None,
67
  ) -> None:
68
+ now = int(time.time())
69
+ sql = (
70
+ "INSERT INTO usage_log(ts, access_key, provider, model, prompt_tokens, completion_tokens, total_tokens, ok, error_code) "
71
+ "VALUES(?,?,?,?,?,?,?,?,?)"
72
+ )
73
+ args = (
74
+ now,
75
+ access_key,
76
+ provider,
77
+ model,
78
+ int((usage or {}).get("prompt_tokens") or 0),
79
+ int((usage or {}).get("completion_tokens") or 0),
80
+ int((usage or {}).get("total_tokens") or 0),
81
+ 1 if ok else 0,
82
+ error_code,
83
+ )
84
+
85
+ def _task(c):
86
+ try:
87
+ c.execute(sql, args)
88
+ except Exception as e:
89
+ print(f"[usage] record failed: {e}")
90
+
91
+ # 转交后台写线程,避免在事件循环上同步 INSERT
92
  try:
93
+ from ..db_writer import enqueue, is_started
94
+ if is_started():
95
+ enqueue(_task)
96
+ else:
97
+ with get_conn() as conn:
98
+ _task(conn)
99
+ except Exception:
100
+ try:
101
+ with get_conn() as conn:
102
+ _task(conn)
103
+ except Exception as e:
104
+ print(f"[usage] record failed: {e}")
 
 
 
 
 
 
 
105
 
106
  # Webhook 通知(fire-and-forget,失败不影响主流程)
107
  try:
app/api/pseudo_stream.py CHANGED
@@ -25,6 +25,7 @@ from ._common import (
25
  record_usage,
26
  select_provider_and_key,
27
  )
 
28
 
29
  router = APIRouter(prefix="/v1/xtc/chat/pseudo", tags=["pseudo-stream"])
30
 
@@ -35,8 +36,19 @@ async def pseudo_start(
35
  _key: str = Depends(require_access_key),
36
  x_provider: Optional[str] = Header(default=None, alias="x-provider"),
37
  ):
38
- """启动伪流式会话:立即返回 session_id,后台异步执行对话。"""
39
- body = await request.json()
 
 
 
 
 
 
 
 
 
 
 
40
  if not isinstance(body, dict):
41
  raise HttpError("invalid body", status=400, code="bad_request")
42
 
 
25
  record_usage,
26
  select_provider_and_key,
27
  )
28
+ from .xtc import _parse_multipart
29
 
30
  router = APIRouter(prefix="/v1/xtc/chat/pseudo", tags=["pseudo-stream"])
31
 
 
36
  _key: str = Depends(require_access_key),
37
  x_provider: Optional[str] = Header(default=None, alias="x-provider"),
38
  ):
39
+ """启动伪流式会话:立即返回 session_id,后台异步执行对话。
40
+
41
+ 支持 JSON 与 multipart/form-data 两种请求体。multipart 用于带文件/图片
42
+ 的场景:传统做法是带文件走 /v1/xtc/chat 的长连接(stream=false,后端等
43
+ 上游完整返回),但手表平台的 request.upload 默认超时较短(约 30s),
44
+ 上游 LLM 生成耗时超过该值会被平台单方面掐断并报 999。让带文件的请求也
45
+ 走伪流式(立即返回 session_id,后台处理),可避开长连接超时。
46
+ """
47
+ content_type = (request.headers.get("content-type") or "").lower()
48
+ if "multipart/form-data" in content_type:
49
+ body = await _parse_multipart(request)
50
+ else:
51
+ body = await request.json()
52
  if not isinstance(body, dict):
53
  raise HttpError("invalid body", status=400, code="bad_request")
54
 
app/api/xtc.py CHANGED
@@ -5,6 +5,7 @@
5
  from __future__ import annotations
6
 
7
  import json
 
8
  from typing import Any, AsyncIterator, Optional
9
 
10
  from fastapi import APIRouter, Depends, Header, Query, Request
@@ -33,6 +34,10 @@ from ._common import (
33
 
34
  router = APIRouter(prefix="/v1/xtc", tags=["xtc"])
35
 
 
 
 
 
36
 
37
  @router.get("/providers")
38
  async def list_providers(_key: str = Depends(require_access_key)) -> dict:
@@ -388,13 +393,11 @@ async def _simple_stream_gen(
388
  access_key: str,
389
  ) -> AsyncIterator[str]:
390
  """simple 模式:把 OpenAI chunk 流转换为 {type: text/thought/done, delta}。"""
391
- import re
392
-
393
  in_thought = False
394
  thought_open_sent = False
395
  thought_close_sent = False
396
- open_tag_re = re.compile(r"<thought>\s*$", re.IGNORECASE)
397
- close_tag_re = re.compile(r"^\s*</thought>", re.IGNORECASE)
398
 
399
  try:
400
  if provider.type == "gemini":
 
5
  from __future__ import annotations
6
 
7
  import json
8
+ import re
9
  from typing import Any, AsyncIterator, Optional
10
 
11
  from fastapi import APIRouter, Depends, Header, Query, Request
 
34
 
35
  router = APIRouter(prefix="/v1/xtc", tags=["xtc"])
36
 
37
+ # simple 流式:thought 标签识别(提到模块级,避免每次调用重新 import + compile)
38
+ _THOUGHT_OPEN_RE = re.compile(r"<thought>\s*$", re.IGNORECASE)
39
+ _THOUGHT_CLOSE_RE = re.compile(r"^\s*</thought>", re.IGNORECASE)
40
+
41
 
42
  @router.get("/providers")
43
  async def list_providers(_key: str = Depends(require_access_key)) -> dict:
 
393
  access_key: str,
394
  ) -> AsyncIterator[str]:
395
  """simple 模式:把 OpenAI chunk 流转换为 {type: text/thought/done, delta}。"""
 
 
396
  in_thought = False
397
  thought_open_sent = False
398
  thought_close_sent = False
399
+ open_tag_re = _THOUGHT_OPEN_RE
400
+ close_tag_re = _THOUGHT_CLOSE_RE
401
 
402
  try:
403
  if provider.type == "gemini":
app/db_writer.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """后台 DB 写入线程:把分析类写入(usage_log / request_log 等)移出事件循环。
2
+
3
+ 为什么是「单独线程 + 独立连接」而不是 asyncio.to_thread:
4
+ - sqlite3.Connection 即使设了 check_same_thread=False,也不允许跨线程并发使用
5
+ (并发会抛 ProgrammingError 或损坏游标状态)。主事件循环线程负责读,本写线程用
6
+ 独立连接负责写,靠 WAL 模式实现读写并发,互不阻塞。
7
+ - 写入是 fire-and-forget 的分析数据,丢失不影响主流程:任务异常仅记日志。
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import queue
13
+ import sqlite3
14
+ import threading
15
+ from typing import Callable, Optional
16
+
17
+ _log = logging.getLogger(__name__)
18
+
19
+ _q: "queue.Queue[Optional[Callable[[sqlite3.Connection], None]]]" = queue.Queue()
20
+ _conn: Optional[sqlite3.Connection] = None
21
+ _thread: Optional[threading.Thread] = None
22
+ _started = False
23
+
24
+
25
+ def start() -> None:
26
+ """启动后台写线程。必须在 init_db() 之后调用(依赖已建好的 schema)。"""
27
+ global _conn, _thread, _started
28
+ if _started:
29
+ return
30
+ _started = True
31
+ from .database import _resolve_db_path
32
+
33
+ db_path = _resolve_db_path()
34
+ _conn = sqlite3.connect(db_path, check_same_thread=False, isolation_level=None)
35
+ _conn.row_factory = sqlite3.Row
36
+ _conn.execute("PRAGMA journal_mode=WAL")
37
+ _conn.execute("PRAGMA synchronous=NORMAL")
38
+
39
+ def _loop() -> None:
40
+ while True:
41
+ item = _q.get()
42
+ if item is None: # 关闭信号
43
+ break
44
+ try:
45
+ item(_conn) # type: ignore[arg-type]
46
+ except Exception as e:
47
+ _log.warning("db_writer task failed: %s", e)
48
+
49
+ _thread = threading.Thread(target=_loop, daemon=True, name="xtc-db-writer")
50
+ _thread.start()
51
+
52
+
53
+ def stop() -> None:
54
+ """停止后台写线程,drain 队列。"""
55
+ global _conn, _started
56
+ if not _started:
57
+ return
58
+ _q.put(None)
59
+ if _thread is not None:
60
+ _thread.join(timeout=2.0)
61
+ if _conn is not None:
62
+ try:
63
+ _conn.close()
64
+ except Exception:
65
+ pass
66
+ _conn = None
67
+ _started = False
68
+
69
+
70
+ def enqueue(fn: Callable[[sqlite3.Connection], None]) -> None:
71
+ """提交一个写任务。``fn`` 接收写线程的连接,在写线程内同步执行。"""
72
+ _q.put(fn)
73
+
74
+
75
+ def is_started() -> bool:
76
+ return _started
app/main.py CHANGED
@@ -11,6 +11,7 @@ from fastapi.responses import JSONResponse
11
 
12
  from .config import get_settings
13
  from .database import close_db, init_db
 
14
  from .errors import HttpError, http_error_handler, unhandled_exception_handler
15
  from .http_client import close_http_client
16
  from .services import config_store, pseudo_store
@@ -21,6 +22,8 @@ async def lifespan(app: FastAPI):
21
  # 启动
22
  logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
23
  init_db()
 
 
24
  # 预热配置
25
  try:
26
  cfg = await config_store.load_config()
@@ -40,6 +43,7 @@ async def lifespan(app: FastAPI):
40
  except asyncio.CancelledError:
41
  pass
42
  await close_http_client()
 
43
  close_db()
44
 
45
 
 
11
 
12
  from .config import get_settings
13
  from .database import close_db, init_db
14
+ from .db_writer import start as start_db_writer, stop as stop_db_writer
15
  from .errors import HttpError, http_error_handler, unhandled_exception_handler
16
  from .http_client import close_http_client
17
  from .services import config_store, pseudo_store
 
22
  # 启动
23
  logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
24
  init_db()
25
+ # 启动后台 DB 写线程(usage_log / request_log 等分析类写入移出事件循环)
26
+ start_db_writer()
27
  # 预热配置
28
  try:
29
  cfg = await config_store.load_config()
 
43
  except asyncio.CancelledError:
44
  pass
45
  await close_http_client()
46
+ stop_db_writer()
47
  close_db()
48
 
49
 
app/services/config_store.py CHANGED
@@ -28,6 +28,10 @@ _HUB_CONFIG_KEY = "app_config.json"
28
 
29
  _lock = asyncio.Lock()
30
 
 
 
 
 
31
 
32
  def _now_ts() -> int:
33
  return int(time.time())
@@ -93,39 +97,49 @@ def _seed_from_env() -> Optional[AppConfig]:
93
 
94
 
95
  async def load_config() -> AppConfig:
96
- """加载配置(DB 优先 → HF Hub 拉取 → env 种子)。"""
 
 
 
 
 
 
97
  async with _lock:
 
 
 
98
  cfg = _load_from_db()
99
- if cfg.providers:
100
- return cfg.normalize()
101
-
102
- # 尝试从 HF Hub 拉取配置备份
103
- try:
104
- hub_cfg = await _load_from_hub()
105
- if hub_cfg and hub_cfg.providers:
106
- _save_to_db(hub_cfg)
107
- return hub_cfg.normalize()
108
- except Exception as e:
109
- print(f"[config_store] HF Hub load failed: {e}")
110
-
111
- # 用 env 种子
112
- seeded = _seed_from_env()
113
- if seeded:
114
- _save_to_db(seeded)
115
  try:
116
- await _push_to_hub(seeded)
 
 
 
117
  except Exception as e:
118
- print(f"[config_store] HF Hub push failed: {e}")
119
- return seeded.normalize()
120
-
121
- return AppConfig(providers=[], default_provider_id=None).normalize()
 
 
 
 
 
 
 
 
 
 
122
 
123
 
124
  async def save_config(config: AppConfig) -> AppConfig:
125
  """保存配置:写 DB + 异步推 HF Hub + Webhook 通知。"""
 
126
  config.normalize()
127
  async with _lock:
128
  _save_to_db(config)
 
129
  # HF Hub 同步失败不影响主流程
130
  try:
131
  await _push_to_hub(config)
 
28
 
29
  _lock = asyncio.Lock()
30
 
31
+ # 内存缓存:命中后直接返回深拷贝,避免每请求 SELECT kv + json.loads + pydantic 校验,
32
+ # 也消除 _lock 造成的并发请求串行化。save_config 时更新,保证写后即生效。
33
+ _cache: Optional[AppConfig] = None
34
+
35
 
36
  def _now_ts() -> int:
37
  return int(time.time())
 
97
 
98
 
99
  async def load_config() -> AppConfig:
100
+ """加载配置(DB 优先 → HF Hub 拉取 → env 种子)。
101
+
102
+ 热路径命中内存缓存时直接返回深拷贝(无 DB、无校验、无锁争用)。
103
+ """
104
+ global _cache
105
+ if _cache is not None:
106
+ return _cache.model_copy(deep=True)
107
  async with _lock:
108
+ # double-check:并发请求可能在等锁期间已被前一个持有者填充
109
+ if _cache is not None:
110
+ return _cache.model_copy(deep=True)
111
  cfg = _load_from_db()
112
+ if not cfg.providers:
113
+ # 尝试从 HF Hub 拉取配置备份
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  try:
115
+ hub_cfg = await _load_from_hub()
116
+ if hub_cfg and hub_cfg.providers:
117
+ _save_to_db(hub_cfg)
118
+ cfg = hub_cfg
119
  except Exception as e:
120
+ print(f"[config_store] HF Hub load failed: {e}")
121
+ if not cfg.providers:
122
+ # 用 env 种子
123
+ seeded = _seed_from_env()
124
+ if seeded:
125
+ _save_to_db(seeded)
126
+ try:
127
+ await _push_to_hub(seeded)
128
+ except Exception as e:
129
+ print(f"[config_store] HF Hub push failed: {e}")
130
+ cfg = seeded
131
+ cfg = cfg.normalize()
132
+ _cache = cfg
133
+ return cfg.model_copy(deep=True)
134
 
135
 
136
  async def save_config(config: AppConfig) -> AppConfig:
137
  """保存配置:写 DB + 异步推 HF Hub + Webhook 通知。"""
138
+ global _cache
139
  config.normalize()
140
  async with _lock:
141
  _save_to_db(config)
142
+ _cache = config.model_copy(deep=True)
143
  # HF Hub 同步失败不影响主流程
144
  try:
145
  await _push_to_hub(config)
app/services/request_log_store.py CHANGED
@@ -79,20 +79,29 @@ def _truncate(s: Optional[str], max_len: int) -> Optional[str]:
79
 
80
  # ===== 容量配置 =====
81
 
 
 
 
 
82
  def get_limit() -> int:
83
- """读取容量上限。0 = 禁用记录。"""
 
 
 
84
  try:
85
  with get_conn() as conn:
86
  row = conn.execute(
87
  "SELECT value FROM kv WHERE key = 'request_log_limit'"
88
  ).fetchone()
89
- return int(row["value"]) if row else 500
90
  except Exception:
91
  return 500
 
92
 
93
 
94
  def set_limit(limit: int) -> int:
95
  """设置容量上限,并立即触发轮转。"""
 
96
  limit = max(0, min(5000, int(limit)))
97
  now = int(time.time())
98
  with get_conn() as conn:
@@ -101,70 +110,98 @@ def set_limit(limit: int) -> int:
101
  "ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at",
102
  ("request_log_limit", str(limit), now),
103
  )
 
104
  if limit > 0:
105
  _rotate(limit)
106
  return limit
107
 
108
 
109
  def _rotate(limit: int) -> int:
110
- """删除超限的旧记录。返回删除条数。"""
111
  with get_conn() as conn:
112
- # 找到要保留的最小 id
113
- row = conn.execute(
114
- "SELECT id FROM request_log ORDER BY id DESC LIMIT 1 OFFSET ?",
115
- (limit,),
116
- ).fetchone()
117
- if not row:
118
- return 0
119
- threshold = int(row["id"])
120
- cur = conn.execute("DELETE FROM request_log WHERE id < ?", (threshold,))
121
- return cur.rowcount
 
 
 
 
122
 
123
 
124
  # ===== 写入 =====
125
 
 
 
 
 
126
  def insert(record: dict) -> Optional[int]:
127
- """插入一条请求日志。返回 id如果 limit=0 则不插入返回 None)。"""
 
 
 
 
 
128
  limit = get_limit()
129
  if limit <= 0:
130
  return None
131
  now = int(time.time())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  with get_conn() as conn:
133
- cur = conn.execute(
134
- """INSERT INTO request_log(
135
- ts, method, path, query, status_code, elapsed_ms,
136
- access_key, client_ip, user_agent, provider, model, stream,
137
- ok, error_code, error_message,
138
- request_headers, request_body, response_body, response_headers
139
- ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
140
- (
141
- now,
142
- record.get("method", ""),
143
- record.get("path", ""),
144
- record.get("query"),
145
- record.get("status_code"),
146
- record.get("elapsed_ms"),
147
- record.get("access_key"),
148
- record.get("client_ip"),
149
- record.get("user_agent"),
150
- record.get("provider"),
151
- record.get("model"),
152
- 1 if record.get("stream") else 0,
153
- 1 if record.get("ok") else 0,
154
- record.get("error_code"),
155
- record.get("error_message"),
156
- _truncate(record.get("request_headers"), _MAX_HEADERS_LEN),
157
- _truncate(record.get("request_body"), _MAX_BODY_LEN),
158
- _truncate(record.get("response_body"), _MAX_BODY_LEN),
159
- _truncate(record.get("response_headers"), _MAX_HEADERS_LEN),
160
- ),
161
- )
162
- rid = int(cur.lastrowid)
163
- # 轮转(异步感觉没必要,每次插入后检查一次成本低)
164
- # 用阈值触发,避免每次都查 count
165
- if rid % 50 == 0:
166
- _rotate(limit)
167
- return rid
168
 
169
 
170
  # ===== 查询 =====
 
79
 
80
  # ===== 容量配置 =====
81
 
82
+ # 内存缓存:limit 只在管理员改设置时变化,缓存后避免每次 insert/list 都 SELECT kv。
83
+ _limit_cache: Optional[int] = None
84
+
85
+
86
  def get_limit() -> int:
87
+ """读取容量上限。0 = 禁用记录。命中缓存直接返回。"""
88
+ global _limit_cache
89
+ if _limit_cache is not None:
90
+ return _limit_cache
91
  try:
92
  with get_conn() as conn:
93
  row = conn.execute(
94
  "SELECT value FROM kv WHERE key = 'request_log_limit'"
95
  ).fetchone()
96
+ _limit_cache = int(row["value"]) if row else 500
97
  except Exception:
98
  return 500
99
+ return _limit_cache
100
 
101
 
102
  def set_limit(limit: int) -> int:
103
  """设置容量上限,并立即触发轮转。"""
104
+ global _limit_cache
105
  limit = max(0, min(5000, int(limit)))
106
  now = int(time.time())
107
  with get_conn() as conn:
 
110
  "ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at",
111
  ("request_log_limit", str(limit), now),
112
  )
113
+ _limit_cache = limit
114
  if limit > 0:
115
  _rotate(limit)
116
  return limit
117
 
118
 
119
  def _rotate(limit: int) -> int:
120
+ """删除超限的旧记录(事件循环线程调用)。返回删除条数。"""
121
  with get_conn() as conn:
122
+ return _do_rotate(conn, limit)
123
+
124
+
125
+ def _do_rotate(conn, limit: int) -> int:
126
+ """在给定连接上执行轮转。"""
127
+ row = conn.execute(
128
+ "SELECT id FROM request_log ORDER BY id DESC LIMIT 1 OFFSET ?",
129
+ (limit,),
130
+ ).fetchone()
131
+ if not row:
132
+ return 0
133
+ threshold = int(row["id"])
134
+ cur = conn.execute("DELETE FROM request_log WHERE id < ?", (threshold,))
135
+ return cur.rowcount
136
 
137
 
138
  # ===== 写入 =====
139
 
140
+ # 后台写线程的插入计数器,用于阈值触发轮转(单线程写,无需加锁)。
141
+ _insert_count = 0
142
+
143
+
144
  def insert(record: dict) -> Optional[int]:
145
+ """插入一条请求日志(异步:转交后台写线程,立即返回 None)。
146
+
147
+ limit=0 时不插入。脱敏/截断在事件循环上完成(开销小),INSERT + 轮转
148
+ 在后台写线程执行,避免阻塞事件循环。
149
+ """
150
+ global _insert_count
151
  limit = get_limit()
152
  if limit <= 0:
153
  return None
154
  now = int(time.time())
155
+ args = (
156
+ now,
157
+ record.get("method", ""),
158
+ record.get("path", ""),
159
+ record.get("query"),
160
+ record.get("status_code"),
161
+ record.get("elapsed_ms"),
162
+ record.get("access_key"),
163
+ record.get("client_ip"),
164
+ record.get("user_agent"),
165
+ record.get("provider"),
166
+ record.get("model"),
167
+ 1 if record.get("stream") else 0,
168
+ 1 if record.get("ok") else 0,
169
+ record.get("error_code"),
170
+ record.get("error_message"),
171
+ _truncate(record.get("request_headers"), _MAX_HEADERS_LEN),
172
+ _truncate(record.get("request_body"), _MAX_BODY_LEN),
173
+ _truncate(record.get("response_body"), _MAX_BODY_LEN),
174
+ _truncate(record.get("response_headers"), _MAX_HEADERS_LEN),
175
+ )
176
+ sql = (
177
+ """INSERT INTO request_log(
178
+ ts, method, path, query, status_code, elapsed_ms,
179
+ access_key, client_ip, user_agent, provider, model, stream,
180
+ ok, error_code, error_message,
181
+ request_headers, request_body, response_body, response_headers
182
+ ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"""
183
+ )
184
+ rotate_limit = limit
185
+
186
+ def _task(c):
187
+ global _insert_count
188
+ c.execute(sql, args)
189
+ _insert_count += 1
190
+ # 每 50 次写入触发一次轮转(阈值触发,避免每次都查)
191
+ if _insert_count % 50 == 0:
192
+ _do_rotate(c, rotate_limit)
193
+
194
+ try:
195
+ from ..db_writer import enqueue, is_started
196
+ if is_started():
197
+ enqueue(_task)
198
+ return None
199
+ except Exception:
200
+ pass
201
+ # 回退:写线程未启动(如启动前/测试),在事件循环上内联写入
202
  with get_conn() as conn:
203
+ conn.execute(sql, args)
204
+ return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
205
 
206
 
207
  # ===== 查询 =====