Spaces:
Running
Running
fix: 修复v6备份blob静默丢失 + 新增日志查询API key功能
Browse filesblob丢失根因修复:
- 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 +4 -0
- app/api/admin.py +37 -0
- app/api/log_query.py +72 -0
- app/api/user_backups_v6.py +18 -1
- app/database.py +13 -0
- app/main.py +2 -0
- app/services/backup_v6_store.py +157 -6
- app/services/log_api_key_store.py +92 -0
.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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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) ->
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
p = _blob_local_path(key)
|
| 147 |
if not p.exists():
|
| 148 |
-
return
|
|
|
|
| 149 |
try:
|
| 150 |
-
|
| 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]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|