Spaces:
Running
Running
sync from GitHub c8523f8: feat(admin): one-click clear all V6 backups, merge backup panels, logs API
Browse files- .gitignore +0 -4
- app/admin_html.py +120 -22
- app/api/admin.py +0 -37
- app/api/admin_data.py +108 -1
- app/api/logs.py +29 -0
- app/api/user_backups_v6.py +1 -18
- app/database.py +0 -13
- app/main.py +0 -2
- app/services/backup_v6_store.py +59 -157
.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>
|
| 560 |
<div class="row" style="margin-bottom:12px;flex-wrap:wrap;gap:8px">
|
| 561 |
-
<input id="
|
| 562 |
-
<
|
| 563 |
-
<button
|
|
|
|
|
|
|
| 564 |
</div>
|
| 565 |
-
<p class="hint">说明:基于
|
| 566 |
-
<div id="
|
| 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
|
| 1443 |
-
const el = $('
|
| 1444 |
el.innerHTML = '<div class="muted">加载中...</div>';
|
| 1445 |
try {
|
| 1446 |
const p = new URLSearchParams();
|
| 1447 |
-
const q = $('
|
|
|
|
| 1448 |
if (q) p.set('q', q);
|
|
|
|
| 1449 |
p.set('limit', '200');
|
| 1450 |
-
const data = await api('/admin/api/
|
| 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="
|
| 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
|
| 1468 |
-
if (!confirm('确认删除
|
| 1469 |
try {
|
| 1470 |
-
await api('/admin/api/
|
| 1471 |
toast('已删除');
|
| 1472 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 <key></code> 或 <code>x-xtc-access-key: <key></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 |
-
|
| 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) ->
|
| 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
|
| 163 |
-
sub_key = f"{key[:2]}/{key}"
|
| 164 |
try:
|
| 165 |
-
|
|
|
|
|
|
|
| 166 |
except Exception as e:
|
| 167 |
-
logger.warning("[v6_blob]
|
| 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}
|