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

sync from GitHub c8523f8: feat(admin): one-click clear all V6 backups, merge backup panels, logs API

Browse files
.gitignore CHANGED
@@ -35,7 +35,3 @@ ENV/
35
  # OS
36
  .DS_Store
37
  Thumbs.db
38
-
39
- _smoke_deps/
40
- _smoke_tmp/
41
- _commit_msg.txt
 
35
  # OS
36
  .DS_Store
37
  Thumbs.db
 
 
 
 
app/admin_html.py CHANGED
@@ -363,11 +363,11 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
363
  <button data-tab="users">用户</button>
364
  <button data-tab="files">文件</button>
365
  <button data-tab="backups">备份</button>
366
- <button data-tab="backups_v5">v5备份</button>
367
  <button data-tab="usage">用量</button>
368
  <button data-tab="webhooks">Webhook</button>
369
  <button data-tab="audit">审计</button>
370
  <button data-tab="requests">请求历史</button>
 
371
  <button data-tab="devices">设备</button>
372
  <button data-tab="tts">TTS</button>
373
  <button data-tab="music-records">识曲录音</button>
@@ -537,10 +537,10 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
537
  </div>
538
  </div>
539
 
540
- <!-- 备份 -->
541
  <div class="tab-content" data-tab="backups">
542
  <div class="card">
543
- <h2>备份管理(全部用户)</h2>
544
  <div class="row" style="margin-bottom:12px;flex-wrap:wrap;gap:8px">
545
  <input id="backupsQ" placeholder="搜索别名/账号" style="flex:1;min-width:160px">
546
  <input id="backupsUser" placeholder="用户ID过滤" style="width:120px">
@@ -551,19 +551,18 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
551
  <p class="hint">说明:Space 重启后首次访问会自动从 Hub 同步,60 秒内不重复拉取。若列表为空且确认有数据,点"从 Hub 同步"强制刷新。</p>
552
  <div id="backupsList">点查询开始</div>
553
  </div>
554
- </div>
555
 
556
- <!-- v5 备份 -->
557
- <div class="tab-content" data-tab="backups_v5">
558
  <div class="card">
559
- <h2>v5 备份管理(全部用户)</h2>
560
  <div class="row" style="margin-bottom:12px;flex-wrap:wrap;gap:8px">
561
- <input id="backupsV5Q" placeholder="搜索别名" style="flex:1;min-width:160px">
562
- <button onclick="loadBackupsV5()">查询</button>
563
- <button class="ghost" onclick="loadBackupsV5()">刷新</button>
 
 
564
  </div>
565
- <p class="hint">说明:基于 backup_sets 表的 v5 备份内容寻址 blob + manifest),跨用户视图。</p>
566
- <div id="backupsV5List">点查询开始</div>
567
  </div>
568
  </div>
569
 
@@ -721,6 +720,44 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
721
  </div>
722
  </div>
723
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
724
  <!-- 设备 -->
725
  <div class="tab-content" data-tab="devices">
726
  <div class="card">
@@ -1012,7 +1049,6 @@ document.querySelectorAll('nav.tabs button').forEach(btn => {
1012
  else if (tab === 'users') loadUsers();
1013
  else if (tab === 'files') loadFiles();
1014
  else if (tab === 'backups') loadBackups();
1015
- else if (tab === 'backups_v5') loadBackupsV5();
1016
  else if (tab === 'webhooks') loadWebhooks();
1017
  else if (tab === 'requests') { loadReqLimit(); loadRequests(1); loadReqStats(); }
1018
  else if (tab === 'devices') loadDevices(1);
@@ -1439,15 +1475,17 @@ async function loadFiles() {
1439
  async function loadBackups() {
1440
  return _loadDataItems('/admin/api/backups', 'backupsList', 'backupsQ', $('backupsUser').value.trim(), '备份');
1441
  }
1442
- async function loadBackupsV5() {
1443
- const el = $('backupsV5List');
1444
  el.innerHTML = '<div class="muted">加载中...</div>';
1445
  try {
1446
  const p = new URLSearchParams();
1447
- const q = $('backupsV5Q') ? $('backupsV5Q').value.trim() : '';
 
1448
  if (q) p.set('q', q);
 
1449
  p.set('limit', '200');
1450
- const data = await api('/admin/api/backups_v5' + (p.toString() ? ('?' + p.toString()) : ''));
1451
  const items = data.items || [];
1452
  if (!items.length) { el.innerHTML = '<div class="muted">暂无数据</div>'; return; }
1453
  el.innerHTML = '<table><tr><th>Set ID</th><th>用户</th><th>别名</th><th>大小</th><th>文件数</th><th>创建时间</th><th>是否最新</th><th>操作</th></tr>' +
@@ -1459,19 +1497,79 @@ async function loadBackupsV5() {
1459
  <td>${it.blob_count||0}</td>
1460
  <td>${fmtTime(it.created_at)}</td>
1461
  <td>${it.is_latest?'<span class="tag ok">是</span>':'-'}</td>
1462
- <td><button class="danger small" onclick="delBackupV5('${esc(it.set_id)}')">删除</button></td>
1463
  </tr>`).join('') +
1464
  '</table><p class="muted" style="margin-top:8px">本页 ' + items.length + ' 项 / 总计 ' + (data.count||items.length) + '</p>';
1465
  } catch (e) { el.innerHTML = '<div class="muted">' + esc(e.message) + '</div>'; }
1466
  }
1467
- async function delBackupV5(setId) {
1468
- if (!confirm('确认删除 v5 备份集 ' + (setId||'').slice(0,8) + '...? 不可恢复!')) return;
1469
  try {
1470
- await api('/admin/api/backups_v5/' + encodeURIComponent(setId), { method: 'DELETE' });
1471
  toast('已删除');
1472
- loadBackupsV5();
 
 
 
 
 
 
 
 
 
1473
  } catch (e) { toast(e.message, true); }
1474
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1475
  function _refreshActiveDataTab() {
1476
  if ($('filesList').innerHTML && $('filesList').innerHTML.indexOf('加载中') < 0) loadFiles();
1477
  if ($('backupsList').innerHTML && $('backupsList').innerHTML.indexOf('加载中') < 0) loadBackups();
 
363
  <button data-tab="users">用户</button>
364
  <button data-tab="files">文件</button>
365
  <button data-tab="backups">备份</button>
 
366
  <button data-tab="usage">用量</button>
367
  <button data-tab="webhooks">Webhook</button>
368
  <button data-tab="audit">审计</button>
369
  <button data-tab="requests">请求历史</button>
370
+ <button data-tab="logs">日志</button>
371
  <button data-tab="devices">设备</button>
372
  <button data-tab="tts">TTS</button>
373
  <button data-tab="music-records">识曲录音</button>
 
537
  </div>
538
  </div>
539
 
540
+ <!-- 备份(普通 + V6,已合并) -->
541
  <div class="tab-content" data-tab="backups">
542
  <div class="card">
543
+ <h2>普通备份管理(全部用户)</h2>
544
  <div class="row" style="margin-bottom:12px;flex-wrap:wrap;gap:8px">
545
  <input id="backupsQ" placeholder="搜索别名/账号" style="flex:1;min-width:160px">
546
  <input id="backupsUser" placeholder="用户ID过滤" style="width:120px">
 
551
  <p class="hint">说明:Space 重启后首次访问会自动从 Hub 同步,60 秒内不重复拉取。若列表为空且确认有数据,点"从 Hub 同步"强制刷新。</p>
552
  <div id="backupsList">点查询开始</div>
553
  </div>
 
554
 
 
 
555
  <div class="card">
556
+ <h2>V6 备份管理(全部用户)</h2>
557
  <div class="row" style="margin-bottom:12px;flex-wrap:wrap;gap:8px">
558
+ <input id="backupsV6Q" placeholder="搜索别名" style="flex:1;min-width:160px">
559
+ <input id="backupsV6User" placeholder="用户ID过滤" style="width:120px">
560
+ <button onclick="loadBackupsV6()">查询</button>
561
+ <button class="ghost" onclick="loadBackupsV6()">刷新</button>
562
+ <button class="danger" onclick="clearAllV6Backups()" title="清空所有用户的 V6 备份点与 blob(DB/本地/Hub 三处)。清空后客户端下次备份将判定为非增量,强制刷新成全量备份一次,用于修复 v6 备份集因同步问题损坏的场景。此操作不可恢复!">一键清空 V6 备份</button>
563
  </div>
564
+ <p class="hint">说明:基于 backup_v6_sets 表的 v6 备份快照模型 + 分片上传 + ZIP 卷),跨用户视图。若 v6 备份集因同步问题损坏,点"一键清空 V6 备份"重置到最初始状态,强制全量刷新。</p>
565
+ <div id="backupsV6List">点查询开始</div>
566
  </div>
567
  </div>
568
 
 
720
  </div>
721
  </div>
722
 
723
+ <!-- 日志(日志 API 密钥生成 + 请求日志查询) -->
724
+ <div class="tab-content" data-tab="logs">
725
+ <div class="card">
726
+ <h2>日志 API 密钥</h2>
727
+ <p class="hint">生成一个临时访问令牌(JWT),用于携带调用日志查询接口 <code>/logs/api/query</code>。</p>
728
+ <div class="row" style="gap:8px;flex-wrap:wrap">
729
+ <button onclick="genLogApiKey()">生成日志 API 密钥</button>
730
+ <button class="ghost" onclick="clearLogApiKey()">清空</button>
731
+ </div>
732
+ <div id="logApiKeyBox" class="muted" style="margin-top:12px">未生成。生成后密钥会显示在此处,可配合下方"通过密钥查询"使用。</div>
733
+ <div class="row" style="margin-top:8px;gap:6px">
734
+ <input id="logApiKeyInput" placeholder="将密钥填写到此处,也可直接粘贴使用">
735
+ <button class="ghost" onclick="copyLogApiKey()">复制</button>
736
+ </div>
737
+ <p class="muted">携带方式二选一:<code>Authorization: Bearer &lt;key&gt;</code> 或 <code>x-xtc-access-key: &lt;key&gt;</code>。</p>
738
+ </div>
739
+
740
+ <div class="card">
741
+ <h2>通过密钥查询请求日志</h2>
742
+ <div class="row" style="margin-bottom:12px;flex-wrap:wrap;gap:8px">
743
+ <select id="logApiHours">
744
+ <option value="24" selected>最近 24 小时</option>
745
+ <option value="1">最近 1 小时</option>
746
+ <option value="48">最近 48 小时</option>
747
+ <option value="168">最近 7 天</option>
748
+ </select>
749
+ <select id="logApiLimit">
750
+ <option value="50">50 条</option>
751
+ <option value="100" selected>100 条</option>
752
+ <option value="200">200 条</option>
753
+ <option value="500">500 条</option>
754
+ </select>
755
+ <button onclick="queryLogsByKey()">查询</button>
756
+ </div>
757
+ <div id="logApiList"><div class="muted">用上方生成的密钥查询业务请求(/v1/*)摘要。</div></div>
758
+ </div>
759
+ </div>
760
+
761
  <!-- 设备 -->
762
  <div class="tab-content" data-tab="devices">
763
  <div class="card">
 
1049
  else if (tab === 'users') loadUsers();
1050
  else if (tab === 'files') loadFiles();
1051
  else if (tab === 'backups') loadBackups();
 
1052
  else if (tab === 'webhooks') loadWebhooks();
1053
  else if (tab === 'requests') { loadReqLimit(); loadRequests(1); loadReqStats(); }
1054
  else if (tab === 'devices') loadDevices(1);
 
1475
  async function loadBackups() {
1476
  return _loadDataItems('/admin/api/backups', 'backupsList', 'backupsQ', $('backupsUser').value.trim(), '备份');
1477
  }
1478
+ async function loadBackupsV6() {
1479
+ const el = $('backupsV6List');
1480
  el.innerHTML = '<div class="muted">加载中...</div>';
1481
  try {
1482
  const p = new URLSearchParams();
1483
+ const q = $('backupsV6Q') ? $('backupsV6Q').value.trim() : '';
1484
+ const uid = $('backupsV6User') ? $('backupsV6User').value.trim() : '';
1485
  if (q) p.set('q', q);
1486
+ if (uid) p.set('user_id', uid);
1487
  p.set('limit', '200');
1488
+ const data = await api('/admin/api/backups_v6' + (p.toString() ? ('?' + p.toString()) : ''));
1489
  const items = data.items || [];
1490
  if (!items.length) { el.innerHTML = '<div class="muted">暂无数据</div>'; return; }
1491
  el.innerHTML = '<table><tr><th>Set ID</th><th>用户</th><th>别名</th><th>大小</th><th>文件数</th><th>创建时间</th><th>是否最新</th><th>操作</th></tr>' +
 
1497
  <td>${it.blob_count||0}</td>
1498
  <td>${fmtTime(it.created_at)}</td>
1499
  <td>${it.is_latest?'<span class="tag ok">是</span>':'-'}</td>
1500
+ <td><button class="danger small" onclick="delBackupV6('${esc(it.set_id)}')">删除</button></td>
1501
  </tr>`).join('') +
1502
  '</table><p class="muted" style="margin-top:8px">本页 ' + items.length + ' 项 / 总计 ' + (data.count||items.length) + '</p>';
1503
  } catch (e) { el.innerHTML = '<div class="muted">' + esc(e.message) + '</div>'; }
1504
  }
1505
+ async function delBackupV6(setId) {
1506
+ if (!confirm('确认删除 V6 备份集 ' + (setId||'').slice(0,8) + '...? 不可恢复!')) return;
1507
  try {
1508
+ await api('/admin/api/backups_v6/' + encodeURIComponent(setId), { method: 'DELETE' });
1509
  toast('已删除');
1510
+ loadBackupsV6();
1511
+ } catch (e) { toast(e.message, true); }
1512
+ }
1513
+ async function clearAllV6Backups() {
1514
+ if (!confirm('确认一键清空【所有用户】的 V6 备份(DB/本地/Hub 三处全部删除)?\n\n清空后客户端下次备份将判定为非增量,强制刷新成全量备份一次。\n此操作不可恢复!确定继续?')) return;
1515
+ if (!confirm('再次确认:这会把所有用户的 V6 备份点与 blob 全部清除,无法撤销。')) return;
1516
+ try {
1517
+ const r = await api('/admin/api/backups_v6/clear-all', { method: 'DELETE' });
1518
+ toast('已清空 V6 备份:备份点 ' + (r.deletedSets||0) + ' 个,blob ' + (r.deletedBlobs||0) + ' 个');
1519
+ loadBackupsV6();
1520
  } catch (e) { toast(e.message, true); }
1521
  }
1522
+ // ===== 日志 API 密钥 =====
1523
+ async function genLogApiKey() {
1524
+ try {
1525
+ const data = await api('/admin/api/xtc-access-token', { method: 'POST', body: {} });
1526
+ const token = data.token || data.access_token || data.jti || '';
1527
+ if (!token) throw new Error('接口未返回 token');
1528
+ $('logApiKeyBox').innerHTML = '<div class="mono" style="word-break:break-all">' + esc(token) + '</div>';
1529
+ if ($('logApiKeyInput')) $('logApiKeyInput').value = token;
1530
+ toast('已生成日志 API 密钥');
1531
+ } catch (e) { toast('生成失败:' + e.message, true); }
1532
+ }
1533
+ function clearLogApiKey() {
1534
+ if ($('logApiKeyInput')) $('logApiKeyInput').value = '';
1535
+ $('logApiKeyBox').textContent = '未生成。生成后密钥会显示在此处,可配合下方"通过密钥查询"使用。';
1536
+ }
1537
+ function copyLogApiKey() {
1538
+ const input = $('logApiKeyInput');
1539
+ if (!input || !input.value) return toast('请先生成并填入密钥', true);
1540
+ input.select();
1541
+ navigator.clipboard && navigator.clipboard.writeText(input.value).then(() => toast('已复制')).catch(() => toast('复制失败', true));
1542
+ }
1543
+ async function queryLogsByKey() {
1544
+ const key = $('logApiKeyInput') ? $('logApiKeyInput').value.trim() : '';
1545
+ if (!key) return toast('请先生成并填入日志 API 密钥', true);
1546
+ const el = $('logApiList');
1547
+ el.innerHTML = '<div class="muted">加载中...</div>';
1548
+ try {
1549
+ const hours = $('logApiHours').value;
1550
+ const limit = $('logApiLimit').value;
1551
+ const r = await fetch('/logs/api/query?hours=' + hours + '&limit=' + limit, {
1552
+ headers: { 'x-xtc-access-key': key }
1553
+ });
1554
+ const data = await r.json().catch(() => ({}));
1555
+ if (!r.ok || data.ok === false) {
1556
+ throw new Error((data.error && data.error.message) || ('HTTP ' + r.status));
1557
+ }
1558
+ const items = data.items || [];
1559
+ if (!items.length) { el.innerHTML = '<div class="muted">暂无业务请求日志(最近 ' + hours + ' 小时)</div>'; return; }
1560
+ el.innerHTML = '<table><tr><th>时间</th><th>方法</th><th>路径</th><th>状态</th><th>耗时</th><th>模型</th><th>结果</th></tr>' +
1561
+ items.map(it => `<tr>
1562
+ <td class="mono">${fmtTime(it.ts)}</td>
1563
+ <td>${esc(it.method||'')}</td>
1564
+ <td class="mono">${esc(it.path||'')}</td>
1565
+ <td>${it.status_code||'-'}</td>
1566
+ <td class="mono">${it.elapsed_ms!=null ? (it.elapsed_ms+'ms') : '-'}</td>
1567
+ <td class="mono">${esc(it.model||'-')}</td>
1568
+ <td>${it.ok?'<span class="tag ok">成功</span>':('<span class="tag err">失败</span>' + (it.error_code?(' '+esc(it.error_code)):''))}</td>
1569
+ </tr>`).join('') +
1570
+ '</table><p class="muted" style="margin-top:8px">本页 ' + items.length + ' 项 / 总计 ' + (data.total||items.length) + '</p>';
1571
+ } catch (e) { el.innerHTML = '<div class="muted">' + esc(e.message) + '</div>'; }
1572
+ }
1573
  function _refreshActiveDataTab() {
1574
  if ($('filesList').innerHTML && $('filesList').innerHTML.indexOf('加载中') < 0) loadFiles();
1575
  if ($('backupsList').innerHTML && $('backupsList').innerHTML.indexOf('加载中') < 0) loadBackups();
app/api/admin.py CHANGED
@@ -184,43 +184,6 @@ async def revoke_token(
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")
 
184
  return ok_with_cors({"revoked": ok})
185
 
186
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  # ===== 配置 =====
188
 
189
  @router.get("/config")
app/api/admin_data.py CHANGED
@@ -16,6 +16,7 @@
16
  from __future__ import annotations
17
 
18
  import asyncio
 
19
  import logging
20
  import time
21
  import urllib.parse
@@ -26,7 +27,7 @@ from fastapi.responses import Response
26
  from ..database import get_conn
27
  from ..errors import HttpError
28
  from ..hf_storage import is_hub_enabled
29
- from ..services import backup_v5_store, session_store, user_files_store
30
  from ._common import CORS_HEADERS, audit_admin, ok_with_cors, read_json_body
31
  from .admin import _require_admin
32
 
@@ -466,6 +467,112 @@ async def delete_backup_v5(
466
  return ok_with_cors({"deleted": True, "set_id": set_id, **result})
467
 
468
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
469
  # ===== 文件 / 备份 管理操作 =====
470
 
471
  def _get_meta_row(key: str):
 
16
  from __future__ import annotations
17
 
18
  import asyncio
19
+ import json
20
  import logging
21
  import time
22
  import urllib.parse
 
27
  from ..database import get_conn
28
  from ..errors import HttpError
29
  from ..hf_storage import is_hub_enabled
30
+ from ..services import backup_v5_store, backup_v6_store, session_store, user_files_store
31
  from ._common import CORS_HEADERS, audit_admin, ok_with_cors, read_json_body
32
  from .admin import _require_admin
33
 
 
467
  return ok_with_cors({"deleted": True, "set_id": set_id, **result})
468
 
469
 
470
+ # ===== v6 备份管理(新备份体系,跨用户视图)=====
471
+
472
+ @router.get("/backups_v6")
473
+ async def list_backups_v6(
474
+ _admin: str = Depends(_require_admin),
475
+ limit: int = Query(default=100, ge=1, le=500),
476
+ offset: int = Query(default=0, ge=0),
477
+ user_id: str | None = Query(default=None, alias="user_id"),
478
+ q: str | None = Query(default=None),
479
+ ):
480
+ """列出所有用户的 v6 备份点(基于 backup_v6_sets 表,跨用户视图)。
481
+
482
+ 返回字段:set_id, user_id, alias, total_size, blob_count,
483
+ created_at, is_latest, prev_set_id, mode, device, manifest。
484
+ """
485
+ where = " WHERE 1=1"
486
+ args: list = []
487
+ if user_id:
488
+ where += " AND user_id = ?"
489
+ args.append(user_id)
490
+ if q:
491
+ where += " AND alias LIKE ?"
492
+ args.append(f"%{q}%")
493
+ with get_conn() as conn:
494
+ total = conn.execute(
495
+ "SELECT COUNT(*) AS c FROM backup_v6_sets" + where, args
496
+ ).fetchone()["c"]
497
+ items = []
498
+ if limit > 0:
499
+ rows = conn.execute(
500
+ "SELECT set_id, user_id, alias, manifest, total_size, blob_count, "
501
+ "created_at, is_latest, prev_set_id, mode, device "
502
+ "FROM backup_v6_sets" + where +
503
+ " ORDER BY created_at DESC LIMIT ? OFFSET ?",
504
+ args + [limit, offset],
505
+ ).fetchall()
506
+ for r in rows:
507
+ d = dict(r)
508
+ try:
509
+ m = json.loads(d["manifest"] or "{}")
510
+ except Exception:
511
+ m = {}
512
+ d["set_id"] = str(d["set_id"])
513
+ d["exportedAt"] = (m.get("exportedAt") if isinstance(m, dict) else None) \
514
+ or int(d["created_at"])
515
+ items.append(d)
516
+ return ok_with_cors({"items": items, "count": total})
517
+
518
+
519
+ @router.delete("/backups_v6/clear-all")
520
+ async def clear_all_v6_backups(
521
+ admin_key: str = Depends(_require_admin),
522
+ ):
523
+ """一键清空全部 V6 备份(所有用户的备份点 + blob,DB/本地/Hub 三处清理)。
524
+
525
+ 用于修复"v6 备份集因同步问题损坏":清空后各用户下次备份判定为非增量,
526
+ 强制刷新成全量备份一次。该操作不可恢复。
527
+ """
528
+ result = await asyncio.to_thread(backup_v6_store.purge_all_v6)
529
+ logger.info(
530
+ "[admin] clear all v6 backups: sets=%s blobs=%s",
531
+ result.get("deletedSets"), result.get("deletedBlobs"),
532
+ )
533
+ _audit_admin(
534
+ "admin.backup_v6.clear_all",
535
+ admin_key,
536
+ target="all",
537
+ deletedSets=result.get("deletedSets", 0),
538
+ deletedBlobs=result.get("deletedBlobs", 0),
539
+ )
540
+ return ok_with_cors({"ok": True, **result})
541
+
542
+
543
+ @router.delete("/backups_v6/{set_id}")
544
+ async def delete_backup_v6(
545
+ set_id: str,
546
+ admin_key: str = Depends(_require_admin),
547
+ ):
548
+ """管理员删除任意用户的 v6 备份点(不需 user_id 校验,可直接指定 set_id)。"""
549
+ owner = backup_v6_store.get_set_owner(set_id)
550
+ if not owner:
551
+ raise HttpError(f"v6 backup set not found: {set_id}", status=404, code="not_found")
552
+ try:
553
+ result = backup_v6_store.delete_set(set_id, owner)
554
+ except ValueError as e:
555
+ msg = str(e)
556
+ if msg == "set_not_found":
557
+ raise HttpError(f"v6 backup set not found: {set_id}", status=404, code="not_found")
558
+ if msg == "forbidden":
559
+ raise HttpError("forbidden", status=403, code="forbidden")
560
+ raise HttpError(f"delete failed: {msg}", status=500, code="delete_failed")
561
+ logger.info(
562
+ "[admin delete] v6 backup set=%s owner=%s freedBlobs=%s freedBytes=%s",
563
+ set_id, owner, result.get("freedBlobs"), result.get("freedBytes"),
564
+ )
565
+ _audit_admin(
566
+ "admin.backup_v6.delete",
567
+ admin_key,
568
+ target=set_id,
569
+ owner=owner,
570
+ freedBlobs=result.get("freedBlobs", 0),
571
+ freedBytes=result.get("freedBytes", 0),
572
+ )
573
+ return ok_with_cors({"deleted": True, "set_id": set_id, **result})
574
+
575
+
576
  # ===== 文件 / 备份 管理操作 =====
577
 
578
  def _get_meta_row(key: str):
app/api/logs.py CHANGED
@@ -5,6 +5,7 @@
5
  - POST /logs/api/log/batch 批量日志(需 access_key)
6
  - GET /logs/api/stats 统计(admin)
7
  - GET /logs/api/logs 查询(admin)
 
8
  """
9
  from __future__ import annotations
10
 
@@ -16,6 +17,7 @@ from fastapi import APIRouter, Depends, Query
16
 
17
  from ..auth import require_access_key, require_admin_key
18
  from ..database import get_conn
 
19
  from ._common import ok_with_cors
20
 
21
  router = APIRouter(prefix="/logs/api", tags=["logs"])
@@ -157,3 +159,30 @@ async def list_logs(
157
  }
158
  )
159
  return ok_with_cors({"items": items, "count": len(items)})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  - POST /logs/api/log/batch 批量日志(需 access_key)
6
  - GET /logs/api/stats 统计(admin)
7
  - GET /logs/api/logs 查询(admin)
8
+ - GET /logs/api/query 查询请求日志(需 access_key,供日志API密钥调用)
9
  """
10
  from __future__ import annotations
11
 
 
17
 
18
  from ..auth import require_access_key, require_admin_key
19
  from ..database import get_conn
20
+ from ..services import request_log_store
21
  from ._common import ok_with_cors
22
 
23
  router = APIRouter(prefix="/logs/api", tags=["logs"])
 
159
  }
160
  )
161
  return ok_with_cors({"items": items, "count": len(items)})
162
+
163
+
164
+ @router.get("/query")
165
+ async def query_request_logs(
166
+ _key: str = Depends(require_access_key),
167
+ hours: int = Query(default=24, ge=1, le=720),
168
+ limit: int = Query(default=100, ge=1, le=500),
169
+ offset: int = Query(default=0, ge=0),
170
+ ):
171
+ """通过日志 API 密钥(access key / 临时令牌)查询后端的请求日志。
172
+
173
+ 携带密钥方式(二选一,与 XTC 客户端一致):
174
+ - Authorization: Bearer <key>
175
+ - x-xtc-access-key: <key>
176
+
177
+ 默认只返回业务请求(/v1/*)摘要,不含请求/响应体等敏感全文。
178
+ """
179
+ result = request_log_store.list_records(
180
+ limit=limit, offset=offset, hours=hours, category="business",
181
+ )
182
+ return ok_with_cors({
183
+ "items": result.get("items", []),
184
+ "count": result.get("count", 0),
185
+ "total": result.get("total", 0),
186
+ "limit": limit,
187
+ "offset": offset,
188
+ })
app/api/user_backups_v6.py CHANGED
@@ -148,18 +148,6 @@ async def commit_manifest(set_id: str, request: Request):
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,12 +342,7 @@ async def batch_check(request: Request):
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
 
 
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
  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
 
app/database.py CHANGED
@@ -365,23 +365,10 @@ 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
- -- 日志查询 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 宽限期支持
 
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 宽限期支持
app/main.py CHANGED
@@ -261,7 +261,6 @@ def create_app() -> FastAPI:
261
  admin_data,
262
  health,
263
  image_fix,
264
- log_query,
265
  logs,
266
  music,
267
  music_records,
@@ -293,7 +292,6 @@ def create_app() -> FastAPI:
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)
 
261
  admin_data,
262
  health,
263
  image_fix,
 
264
  logs,
265
  music,
266
  music_records,
 
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)
app/services/backup_v6_store.py CHANGED
@@ -120,15 +120,6 @@ 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
- # 本地曾丢失 → 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,111 +142,16 @@ def store_blob(content: bytes, mime: str) -> dict:
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,14 +165,6 @@ 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,48 +640,9 @@ def batch_check(user_id: str, paths: list[dict]) -> dict:
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:
@@ -1247,3 +1096,56 @@ def purge_legacy_v5(user_id: Optional[str] = None) -> dict:
1247
  deleted_blobs = -1 # 全局模式下不精确统计
1248
 
1249
  return {"deletedSets": deleted_sets, "deletedBlobs": deleted_blobs, "freedBytes": freed_bytes}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
  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
  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
  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:
 
1096
  deleted_blobs = -1 # 全局模式下不精确统计
1097
 
1098
  return {"deletedSets": deleted_sets, "deletedBlobs": deleted_blobs, "freedBytes": freed_bytes}
1099
+
1100
+
1101
+ def purge_all_v6() -> dict:
1102
+ """清空全部 v6 备份数据(所有用户)。
1103
+
1104
+ 用于管理面板"一键清空 v6 备份":删除所有用户的备份点与 blob
1105
+ (DB + 本地文件 + Hub 命名空间),并清空内存中的待提交备份点 / 分片上传缓冲。
1106
+ 清空后各用户已同步的备份点全部作废,客户端下次备份将判定为"非增量",
1107
+ 强制生成全新全量备份,用以修复"v6 备份集因同步问题损坏"的场景。
1108
+
1109
+ ★ 本函数会在工作线程(asyncio.to_thread)中执行,只做同步 SQL + 文件/网络
1110
+ 操作,绝不调用含 asyncio.create_task / ensure_future 的函数。
1111
+
1112
+ 返回 {deletedSets, deletedBlobs}。
1113
+ """
1114
+ # 清空内存中的待提交备份点 / 分片上传会话,避免残留污染新备份
1115
+ _pending_sets.clear()
1116
+ _pending_uploads.clear()
1117
+
1118
+ with get_conn() as conn:
1119
+ s_row = conn.execute("SELECT COUNT(*) AS c FROM backup_v6_sets").fetchone()
1120
+ b_row = conn.execute("SELECT COUNT(*) AS c FROM backup_v6_blobs").fetchone()
1121
+ deleted_sets = int(s_row["c"]) if s_row else 0
1122
+ deleted_blobs = int(b_row["c"]) if b_row else 0
1123
+ conn.execute("DELETE FROM backup_v6_sets")
1124
+ conn.execute("DELETE FROM backup_v6_blobs")
1125
+
1126
+ # 本地文件:删除整个命名空间目录树(blob 与 manifest)
1127
+ import shutil
1128
+ from ..hf_storage import _local_path
1129
+ for ns in (NS_BLOBS, NS_SETS):
1130
+ try:
1131
+ root = _local_path(ns, "_").parent
1132
+ if root.exists():
1133
+ shutil.rmtree(root, ignore_errors=True)
1134
+ except Exception as e:
1135
+ logger.warning("[v6_purge_all] remove local ns %s failed: %s", ns, e)
1136
+
1137
+ # Hub 命名空间:删除所有对象(避免 HF Space 重建后被冷备回灌)
1138
+ if is_hub_enabled():
1139
+ for ns in (NS_BLOBS, NS_SETS):
1140
+ try:
1141
+ for sub in list_hub_keys(ns):
1142
+ try:
1143
+ delete_from_hub(ns, sub)
1144
+ except Exception:
1145
+ pass
1146
+ except Exception as e:
1147
+ logger.warning("[v6_purge_all] list hub ns %s failed: %s", ns, e)
1148
+
1149
+ logger.info("[v6_purge_all] purged all v6 backups: sets=%d blobs=%d",
1150
+ deleted_sets, deleted_blobs)
1151
+ return {"deletedSets": deleted_sets, "deletedBlobs": deleted_blobs}