a3216 commited on
Commit
86211da
·
1 Parent(s): dc599fb

fix: 修复v6备份blob静默丢失 + 新增日志查询API key功能

Browse files

blob丢失根因修复:
- store_blob推送Hub改为带3次重试+指数退避,成功置hub_ok标记
- commit前新增ensure_blobs_on_hub强制确认所有blob已落Hub,失败拒绝提交
- batch_check命中项增加可用性校验(本地+Hub清单),内容已丢失的降级为miss让客户端重传
- get_blob_local_path从Hub回源成功时标记hub_ok,Space重建后避免冗余重传
- backup_v6_blobs新增hub_ok列(幂等迁移),纯本地模式不受影响

日志查询API key(可吊销、随机生成):
- 新表log_api_keys,admin端点:POST/GET /admin/api/log-keys、POST /admin/api/log-keys/revoke
- 新路由/api/logs:凭X-XTC-Log-Key头或?key=参数查询请求日志(列表+详情),
不在request_log记录范围内,key不落日志

.gitignore CHANGED
@@ -35,3 +35,7 @@ ENV/
35
  # OS
36
  .DS_Store
37
  Thumbs.db
 
 
 
 
 
35
  # OS
36
  .DS_Store
37
  Thumbs.db
38
+
39
+ _smoke_deps/
40
+ _smoke_tmp/
41
+ _commit_msg.txt
app/api/admin.py CHANGED
@@ -184,6 +184,43 @@ async def revoke_token(
184
  return ok_with_cors({"revoked": ok})
185
 
186
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  # ===== 配置 =====
188
 
189
  @router.get("/config")
 
184
  return ok_with_cors({"revoked": ok})
185
 
186
 
187
+ # ===== 日志查询 API Key(凭 key 调用 /api/logs,可吊销)=====
188
+
189
+ @router.post("/log-keys")
190
+ async def generate_log_key(
191
+ body: dict = Body(default={}),
192
+ _admin: str = Depends(_require_admin),
193
+ ):
194
+ """生成日志查询 API key。完整 key 仅本次响应返回一次。"""
195
+ from ..services import log_api_key_store
196
+
197
+ name = str(body.get("name") or "").strip()
198
+ info = log_api_key_store.generate_key(name)
199
+ return ok_with_cors(info)
200
+
201
+
202
+ @router.get("/log-keys")
203
+ async def list_log_keys(_admin: str = Depends(_require_admin)):
204
+ from ..services import log_api_key_store
205
+
206
+ return ok_with_cors({"keys": log_api_key_store.list_keys()})
207
+
208
+
209
+ @router.post("/log-keys/revoke")
210
+ async def revoke_log_key(
211
+ body: dict = Body(default={}),
212
+ _admin: str = Depends(_require_admin),
213
+ ):
214
+ """吊销日志查询 API key(body: {key: 完整 key})。"""
215
+ from ..services import log_api_key_store
216
+
217
+ key = body.get("key")
218
+ if not key:
219
+ raise HttpError("key is required", status=400, code="bad_request")
220
+ revoked = log_api_key_store.revoke_key(str(key))
221
+ return ok_with_cors({"revoked": revoked})
222
+
223
+
224
  # ===== 配置 =====
225
 
226
  @router.get("/config")
app/api/log_query.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """日志查询 API(凭 log API key 鉴权):/api/logs/*
2
+
3
+ 用途:不依赖 admin key,凭可吊销的随机 API key 查询请求日志,
4
+ 方便外部工具/脚本排查线上问题(key 由 /admin/api/log-keys 生成)。
5
+
6
+ 鉴权方式(二选一):
7
+ - 请求头 X-XTC-Log-Key: <key>(推荐,避免 key 进代理/访问日志)
8
+ - query 参数 ?key=<key>
9
+
10
+ 注意:/api/logs 不在 request_log 中间件的记录范围内(只记录 /v1/ /admin/api/ /u/api/),
11
+ 查询行为本身不会被记录,避免 key 泄露到日志存储。
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from typing import Optional
16
+
17
+ from fastapi import APIRouter, Query, Request
18
+
19
+ from ..errors import HttpError
20
+ from ..services import log_api_key_store, request_log_store
21
+ from ._common import ok_with_cors
22
+
23
+ router = APIRouter(prefix="/api/logs", tags=["log-query"])
24
+
25
+
26
+ def _require_log_key(request: Request) -> str:
27
+ key = request.headers.get("x-xtc-log-key") or request.query_params.get("key") or ""
28
+ if not key:
29
+ raise HttpError(
30
+ "missing log api key: pass header X-XTC-Log-Key or query ?key=",
31
+ status=401, code="unauthorized",
32
+ )
33
+ if not log_api_key_store.verify_key(key):
34
+ raise HttpError(
35
+ "log api key invalid or revoked",
36
+ status=401, code="unauthorized",
37
+ )
38
+ return key
39
+
40
+
41
+ @router.get("")
42
+ async def list_logs(
43
+ request: Request,
44
+ limit: int = Query(default=50, ge=1, le=500),
45
+ offset: int = Query(default=0, ge=0),
46
+ path: Optional[str] = Query(default=None),
47
+ method: Optional[str] = Query(default=None),
48
+ status: Optional[str] = Query(default=None),
49
+ ok: Optional[int] = Query(default=None, ge=0, le=1),
50
+ hours: Optional[int] = Query(default=None, ge=1, le=720),
51
+ keyword: Optional[str] = Query(default=None),
52
+ category: Optional[str] = Query(default="business"),
53
+ device_id: Optional[str] = Query(default=None),
54
+ ):
55
+ _require_log_key(request)
56
+ result = request_log_store.list_records(
57
+ limit=limit, offset=offset,
58
+ path=path, method=method, status=status,
59
+ ok=bool(ok) if ok is not None else None,
60
+ hours=hours, keyword=keyword, category=category,
61
+ device_id=device_id,
62
+ )
63
+ return ok_with_cors(result)
64
+
65
+
66
+ @router.get("/{rid}")
67
+ async def get_log_detail(rid: int, request: Request):
68
+ _require_log_key(request)
69
+ rec = request_log_store.get_record(rid)
70
+ if not rec:
71
+ raise HttpError(f"request log not found: {rid}", status=404, code="not_found")
72
+ return ok_with_cors({"record": rec})
app/api/user_backups_v6.py CHANGED
@@ -148,6 +148,18 @@ async def commit_manifest(set_id: str, request: Request):
148
  "blob_missing", "manifest 引用了未上传的 blob",
149
  missing_keys=still_missing[:20],
150
  )
 
 
 
 
 
 
 
 
 
 
 
 
151
  try:
152
  result = backup_v6_store.commit_set(set_id, manifest, uid)
153
  except ValueError as e:
@@ -342,7 +354,12 @@ async def batch_check(request: Request):
342
  except Exception:
343
  mtime = 0
344
  cleaned.append({"path": path, "size": size, "mtime": mtime})
345
- result = backup_v6_store.batch_check(uid, cleaned)
 
 
 
 
 
346
  return ok_with_cors(result)
347
 
348
 
 
148
  "blob_missing", "manifest 引用了未上传的 blob",
149
  missing_keys=still_missing[:20],
150
  )
151
+ # ★ 提交前强制确认所有 blob 已落 Hub(堵住静默丢失:本地有但 Hub 没有,
152
+ # Space 重启清盘后 blob 永久丢失。失败则拒绝提交,客户端可重试)
153
+ try:
154
+ push_failed = await backup_v6_store.ensure_blobs_on_hub(keys)
155
+ except Exception as e:
156
+ raise _err("blob_push_failed", f"blob 同步到 Hub 失败: {e}", status=503)
157
+ if push_failed:
158
+ raise _err(
159
+ "blob_push_failed", "部分 blob 同步到 Hub 失败,请稍后重试提交",
160
+ push_failed=push_failed[:20],
161
+ status=503,
162
+ )
163
  try:
164
  result = backup_v6_store.commit_set(set_id, manifest, uid)
165
  except ValueError as e:
 
354
  except Exception:
355
  mtime = 0
356
  cleaned.append({"path": path, "size": size, "mtime": mtime})
357
+ # batch_check 现含 blob 可用性校验(本地缺失时有一次 Hub 清单网络往返),
358
+ # 必须放 to_thread,否则阻塞事件循环
359
+ try:
360
+ result = await asyncio.to_thread(backup_v6_store.batch_check, uid, cleaned)
361
+ except Exception as e:
362
+ raise _err("batch_check_failed", str(e), status=500)
363
  return ok_with_cors(result)
364
 
365
 
app/database.py CHANGED
@@ -365,10 +365,23 @@ def _create_schema(conn: sqlite3.Connection) -> None:
365
  device TEXT
366
  );
367
  CREATE INDEX IF NOT EXISTS idx_backup_v6_sets_user ON backup_v6_sets(user_id, created_at DESC);
 
 
 
 
 
 
 
 
 
368
  """
369
  )
370
  # 兼容已存在的库:若旧表无 device_id 列则补加(ALTER TABLE 幂等检查)
371
  _ensure_column(conn, "request_log", "device_id", "TEXT")
 
 
 
 
372
  # file_meta 增加 alias 列:原始文件名(可能是中文),filename 改存随机英文名
373
  _ensure_column(conn, "file_meta", "alias", "TEXT")
374
  # user_tokens 增加 revoked_at 列:token rotation 宽限期支持
 
365
  device TEXT
366
  );
367
  CREATE INDEX IF NOT EXISTS idx_backup_v6_sets_user ON backup_v6_sets(user_id, created_at DESC);
368
+
369
+ -- 日志查询 API Key(供外部凭 key 调用 /api/logs 查询请求日志,可吊销)
370
+ CREATE TABLE IF NOT EXISTS log_api_keys (
371
+ key TEXT PRIMARY KEY,
372
+ name TEXT,
373
+ created_at INTEGER NOT NULL,
374
+ revoked_at INTEGER NOT NULL DEFAULT 0,
375
+ last_used_at INTEGER NOT NULL DEFAULT 0
376
+ );
377
  """
378
  )
379
  # 兼容已存在的库:若旧表无 device_id 列则补加(ALTER TABLE 幂等检查)
380
  _ensure_column(conn, "request_log", "device_id", "TEXT")
381
+ # v6 备份 blob 增加 hub_ok 标记:1 = 已确认推送到 HF Hub。
382
+ # 修复 blob 静默丢失:此前 fire-and-forget 推送失败无感知,Space 重启后
383
+ # 本地盘被清空导致 blob 永久丢失(manifest 还在但内容没了)。
384
+ _ensure_column(conn, "backup_v6_blobs", "hub_ok", "INTEGER NOT NULL DEFAULT 0")
385
  # file_meta 增加 alias 列:原始文件名(可能是中文),filename 改存随机英文名
386
  _ensure_column(conn, "file_meta", "alias", "TEXT")
387
  # user_tokens 增加 revoked_at 列:token rotation 宽限期支持
app/main.py CHANGED
@@ -261,6 +261,7 @@ def create_app() -> FastAPI:
261
  admin_data,
262
  health,
263
  image_fix,
 
264
  logs,
265
  music,
266
  music_records,
@@ -292,6 +293,7 @@ def create_app() -> FastAPI:
292
  app.include_router(usage_audit.router)
293
  app.include_router(webhooks.router)
294
  app.include_router(request_logs.router)
 
295
  app.include_router(admin_html_router)
296
  app.include_router(user_html_router)
297
  app.include_router(user_files.router)
 
261
  admin_data,
262
  health,
263
  image_fix,
264
+ log_query,
265
  logs,
266
  music,
267
  music_records,
 
293
  app.include_router(usage_audit.router)
294
  app.include_router(webhooks.router)
295
  app.include_router(request_logs.router)
296
+ app.include_router(log_query.router)
297
  app.include_router(admin_html_router)
298
  app.include_router(user_html_router)
299
  app.include_router(user_files.router)
app/services/backup_v6_store.py CHANGED
@@ -120,6 +120,15 @@ def store_blob(content: bytes, mime: str) -> dict:
120
  try:
121
  p.parent.mkdir(parents=True, exist_ok=True)
122
  p.write_bytes(content)
 
 
 
 
 
 
 
 
 
123
  if is_hub_enabled():
124
  asyncio.ensure_future(_push_blob_to_hub(key))
125
  except Exception as e:
@@ -142,16 +151,111 @@ def store_blob(content: bytes, mime: str) -> dict:
142
  return {"key": key, "size": size, "exists": False}
143
 
144
 
145
- async def _push_blob_to_hub(key: str) -> None:
 
 
 
 
 
146
  p = _blob_local_path(key)
147
  if not p.exists():
148
- return
 
149
  try:
150
- sub_key = f"{key[:2]}/{key}"
151
- upload_bytes(NS_BLOBS, sub_key, p.read_bytes())
152
- await push_to_hub(NS_BLOBS, sub_key)
153
  except Exception as e:
154
- logger.warning("[v6_blob] push to hub failed key=%s: %s", key[:8], e)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
 
156
 
157
  def get_blob_local_path(key: str) -> Optional[Path]:
@@ -165,6 +269,14 @@ def get_blob_local_path(key: str) -> Optional[Path]:
165
  try:
166
  p.parent.mkdir(parents=True, exist_ok=True)
167
  p.write_bytes(data)
 
 
 
 
 
 
 
 
168
  return p
169
  except Exception as e:
170
  logger.warning("[v6_blob] fetch from hub failed key=%s: %s", key[:8], e)
@@ -640,9 +752,48 @@ def batch_check(user_id: str, paths: list[dict]) -> dict:
640
  hits.append({"path": path, "key": prev["key"], "size": size, "mtime": mtime})
641
  else:
642
  misses.append({"path": path})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
643
  return {"hits": hits, "misses": misses}
644
 
645
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
646
  # ===== ZIP 分卷打包 =====
647
 
648
  def _volume_count(total_size: int) -> int:
 
120
  try:
121
  p.parent.mkdir(parents=True, exist_ok=True)
122
  p.write_bytes(content)
123
+ # 本地曾丢失 → hub_ok 状态不可信,重置为 0,
124
+ # 由后台推送 / commit 前的 ensure_blobs_on_hub 重新确认
125
+ try:
126
+ with get_conn() as conn:
127
+ conn.execute(
128
+ "UPDATE backup_v6_blobs SET hub_ok = 0 WHERE key = ?", (key,)
129
+ )
130
+ except Exception:
131
+ pass
132
  if is_hub_enabled():
133
  asyncio.ensure_future(_push_blob_to_hub(key))
134
  except Exception as e:
 
151
  return {"key": key, "size": size, "exists": False}
152
 
153
 
154
+ async def _push_blob_to_hub(key: str) -> bool:
155
+ """推送 blob 到 Hub(带重试 + 指数退避)。成功返回 True 并置 hub_ok=1。
156
+
157
+ v6 实际运行踩坑:fire-and-forget 单次推送失败即静默丢失,Space 重启后
158
+ 本地盘清空 → blob 永久丢失(manifest 还在,内容没了,恢复 404)。
159
+ """
160
  p = _blob_local_path(key)
161
  if not p.exists():
162
+ return False
163
+ sub_key = f"{key[:2]}/{key}"
164
  try:
165
+ data = p.read_bytes()
 
 
166
  except Exception as e:
167
+ logger.warning("[v6_blob] read local file failed key=%s: %s", key[:8], e)
168
+ return False
169
+ max_retries = 3
170
+ for attempt in range(max_retries):
171
+ try:
172
+ upload_bytes(NS_BLOBS, sub_key, data)
173
+ ok = await push_to_hub(NS_BLOBS, sub_key)
174
+ if ok:
175
+ try:
176
+ with get_conn() as conn:
177
+ conn.execute(
178
+ "UPDATE backup_v6_blobs SET hub_ok = 1 WHERE key = ?", (key,)
179
+ )
180
+ except Exception as e:
181
+ logger.warning("[v6_blob] mark hub_ok failed key=%s: %s", key[:8], e)
182
+ return True
183
+ # push_to_hub 内部已捕获异常返回 False,走重试
184
+ logger.warning(
185
+ "[v6_blob] push to hub returned false (attempt %d/%d) key=%s",
186
+ attempt + 1, max_retries, key[:8],
187
+ )
188
+ except Exception as e:
189
+ logger.warning(
190
+ "[v6_blob] push to hub failed (attempt %d/%d) key=%s: %s",
191
+ attempt + 1, max_retries, key[:8], e,
192
+ )
193
+ if attempt < max_retries - 1:
194
+ await asyncio.sleep(2 ** attempt) # 1s / 2s 指数退避
195
+ logger.error("[v6_blob] push to hub FAILED after %d retries key=%s", max_retries, key[:8])
196
+ return False
197
+
198
+
199
+ async def ensure_blobs_on_hub(keys: list[str], concurrency: int = 4) -> list[str]:
200
+ """确保 blob 已落到 Hub(按 hub_ok 标记)。
201
+
202
+ commit 前调用,堵住"manifest 提交成功但 blob 没上 Hub"的静默丢失漏洞:
203
+ - hub_ok=1 的跳过
204
+ - 本地有文件:并发推送(带重试),成功置 hub_ok=1
205
+ - 本地没文件:查 Hub 清单确认,已在 Hub 的直接置 hub_ok=1(Space 重建场景,
206
+ 避免对已在 Hub 的 blob 做冗余重传)
207
+ 返回仍失败的 key 列表(调用方应拒绝提交)。
208
+ """
209
+ uniq = [k for k in dict.fromkeys(keys) if k]
210
+ if not uniq:
211
+ return []
212
+ # 纯本地模式(未配置 HF_TOKEN / HF_CONFIG_REPO):降级为不做 Hub 校验,
213
+ # 否则所有 blob 都会被误判为推送失败、拒绝提交
214
+ if not is_hub_enabled():
215
+ return []
216
+ placeholders = ",".join("?" * len(uniq))
217
+ with get_conn() as conn:
218
+ rows = conn.execute(
219
+ f"SELECT key, hub_ok FROM backup_v6_blobs WHERE key IN ({placeholders})", uniq
220
+ ).fetchall()
221
+ need = [str(r["key"]) for r in rows if not int(r["hub_ok"] or 0)]
222
+ if not need:
223
+ return []
224
+
225
+ have_local = [k for k in need if _blob_local_path(k).exists()]
226
+ no_local = [k for k in need if k not in set(have_local)]
227
+
228
+ confirmed: list[str] = []
229
+ if no_local and is_hub_enabled():
230
+ try:
231
+ hub_set = set(await asyncio.to_thread(list_hub_keys, NS_BLOBS))
232
+ confirmed = [k for k in no_local if k in hub_set]
233
+ except Exception as e:
234
+ logger.warning("[v6_blob] list hub keys failed: %s", e)
235
+ if confirmed:
236
+ try:
237
+ with get_conn() as conn:
238
+ for k in confirmed:
239
+ conn.execute(
240
+ "UPDATE backup_v6_blobs SET hub_ok = 1 WHERE key = ?", (k,)
241
+ )
242
+ except Exception as e:
243
+ logger.warning("[v6_blob] mark confirmed hub_ok failed: %s", e)
244
+
245
+ failed: list[str] = []
246
+ if have_local:
247
+ sem = asyncio.Semaphore(max(1, concurrency))
248
+
249
+ async def _one(k: str) -> None:
250
+ async with sem:
251
+ ok = await _push_blob_to_hub(k)
252
+ if not ok:
253
+ failed.append(k)
254
+
255
+ await asyncio.gather(*[_one(k) for k in have_local])
256
+ # 本地与 Hub 都没有的也返回(调用方按 blob_missing 处理)
257
+ lost = [k for k in no_local if k not in set(confirmed)]
258
+ return failed + lost
259
 
260
 
261
  def get_blob_local_path(key: str) -> Optional[Path]:
 
269
  try:
270
  p.parent.mkdir(parents=True, exist_ok=True)
271
  p.write_bytes(data)
272
+ # 从 Hub 成功拉回 = Hub 上确实有,标记 hub_ok
273
+ try:
274
+ with get_conn() as conn:
275
+ conn.execute(
276
+ "UPDATE backup_v6_blobs SET hub_ok = 1 WHERE key = ?", (key,)
277
+ )
278
+ except Exception:
279
+ pass
280
  return p
281
  except Exception as e:
282
  logger.warning("[v6_blob] fetch from hub failed key=%s: %s", key[:8], e)
 
752
  hits.append({"path": path, "key": prev["key"], "size": size, "mtime": mtime})
753
  else:
754
  misses.append({"path": path})
755
+
756
+ # ★ 可用性过滤:命中但 blob 内容已丢失(本地 + Hub 都没有)的降级为 miss,
757
+ # 让客户端重传。否则客户端跳过这些文件 → commit 时 blob_missing。
758
+ # (v6 实际故障:Hub 推送静默失败 + Space 重启清盘 → manifest 还在、内容没了)
759
+ if hits:
760
+ lost_keys = set(_unavailable_blob_keys([h["key"] for h in hits]))
761
+ if lost_keys:
762
+ kept = []
763
+ for h in hits:
764
+ if h["key"] in lost_keys:
765
+ logger.warning(
766
+ "[v6_batch_check] blob content lost, downgrade to miss "
767
+ "key=%s path=%s", h["key"][:8], h["path"],
768
+ )
769
+ misses.append({"path": h["path"]})
770
+ else:
771
+ kept.append(h)
772
+ hits = kept
773
  return {"hits": hits, "misses": misses}
774
 
775
 
776
+ def _unavailable_blob_keys(keys: list[str]) -> list[str]:
777
+ """返回本地与 Hub 都没有内容的 key 列表(含一次 Hub 清单往返,调用方放 to_thread)。
778
+
779
+ Hub 清单列举失败时返回空列表(宁可信其有,避免误判引发全量重传)。
780
+ """
781
+ uniq = [k for k in dict.fromkeys(keys) if k]
782
+ if not uniq:
783
+ return []
784
+ missing_local = [k for k in uniq if not _blob_local_path(k).exists()]
785
+ if not missing_local:
786
+ return []
787
+ if not is_hub_enabled():
788
+ return missing_local
789
+ try:
790
+ hub_keys = set(list_hub_keys(NS_BLOBS))
791
+ except Exception as e:
792
+ logger.warning("[v6_batch_check] list hub keys failed: %s", e)
793
+ return []
794
+ return [k for k in missing_local if k not in hub_keys]
795
+
796
+
797
  # ===== ZIP 分卷打包 =====
798
 
799
  def _volume_count(total_size: int) -> int:
app/services/log_api_key_store.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """日志查询 API Key 管理。
2
+
3
+ 用途:生成可吊销的随机 API Key,凭 key 调用 /api/logs 查询请求日志
4
+ (不需要 admin key,方便外部工具/脚本排查线上问题)。
5
+
6
+ - key 随机生成(xtclog_ 前缀 + 32 字节 urlsafe 随机数),只在建时完整返回一次
7
+ - 可随时吊销(revoked_at > 0 即失效,立即生效)
8
+ - 记录 last_used_at 便于审计
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import secrets
13
+ import time
14
+ from typing import Optional
15
+
16
+ from ..database import get_conn
17
+
18
+ KEY_PREFIX = "xtclog_"
19
+
20
+
21
+ def _now() -> int:
22
+ return int(time.time())
23
+
24
+
25
+ def generate_key(name: str = "") -> dict:
26
+ """生成新 key。返回 {key, name, createdAt}(key 仅此一次完整返回)。"""
27
+ key = KEY_PREFIX + secrets.token_urlsafe(24)
28
+ now = _now()
29
+ with get_conn() as conn:
30
+ conn.execute(
31
+ "INSERT INTO log_api_keys(key, name, created_at, revoked_at, last_used_at) "
32
+ "VALUES(?, ?, ?, 0, 0)",
33
+ (key, str(name or "")[:100], now),
34
+ )
35
+ return {"key": key, "name": str(name or "")[:100], "createdAt": now}
36
+
37
+
38
+ def list_keys() -> list[dict]:
39
+ """列出全部 key(脱敏:只显示前缀 + 前 6 位)。"""
40
+ with get_conn() as conn:
41
+ rows = conn.execute(
42
+ "SELECT key, name, created_at, revoked_at, last_used_at "
43
+ "FROM log_api_keys ORDER BY created_at DESC"
44
+ ).fetchall()
45
+ out = []
46
+ for r in rows:
47
+ k = str(r["key"])
48
+ out.append({
49
+ "keyMasked": k[:len(KEY_PREFIX) + 6] + "..." if len(k) > len(KEY_PREFIX) + 6 else k,
50
+ "name": r["name"] or "",
51
+ "createdAt": int(r["created_at"]),
52
+ "revoked": int(r["revoked_at"] or 0) > 0,
53
+ "revokedAt": int(r["revoked_at"]) if int(r["revoked_at"] or 0) > 0 else None,
54
+ "lastUsedAt": int(r["last_used_at"] or 0) or None,
55
+ })
56
+ return out
57
+
58
+
59
+ def revoke_key(key: str) -> bool:
60
+ """吊销 key(支持完整 key 或脱敏前缀匹配的完整 key)。返回是否吊销成功。"""
61
+ k = str(key or "").strip()
62
+ if not k:
63
+ return False
64
+ now = _now()
65
+ with get_conn() as conn:
66
+ cur = conn.execute(
67
+ "UPDATE log_api_keys SET revoked_at = ? "
68
+ "WHERE key = ? AND revoked_at = 0",
69
+ (now, k),
70
+ )
71
+ return cur.rowcount > 0
72
+
73
+
74
+ def verify_key(key: str) -> bool:
75
+ """校验 key 有效(存在且未吊销),成功时更新 last_used_at。"""
76
+ k = str(key or "").strip()
77
+ if not k.startswith(KEY_PREFIX):
78
+ return False
79
+ now = _now()
80
+ with get_conn() as conn:
81
+ row = conn.execute(
82
+ "SELECT revoked_at FROM log_api_keys WHERE key = ?", (k,)
83
+ ).fetchone()
84
+ if not row or int(row["revoked_at"] or 0) > 0:
85
+ return False
86
+ try:
87
+ conn.execute(
88
+ "UPDATE log_api_keys SET last_used_at = ? WHERE key = ?", (now, k)
89
+ )
90
+ except Exception:
91
+ pass
92
+ return True