mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-07-24 22:47:18 +08:00
* 功能: v2.0.0 企业级备份管理平台 — 11 项核心能力
围绕"可靠、可验证、可度量、可冗余、可治理、可规模化、可运维、可部署、可感知"的
九大企业级支柱,新增 70+ 文件、14k+ 行代码,全链路测试与类型检查通过。
## 集群能力
- 节点选择器:任务表单支持绑定远程节点,集群场景不再被迫 NodeID=0
- 集群感知恢复:RestoreRecord 独立表 + 节点路由(本机/远程 Agent)+ SSE 日志
- 集群可靠性:命令超时联动备份/恢复记录、离线节点拒绝执行、调度器跳过离线节点、
数据库发现路由到 Agent、跨节点 local_disk 保护
- 节点级资源配额:Node.MaxConcurrent / BandwidthLimit + per-node semaphore
- Agent 版本感知:ClusterVersionMonitor 定期扫描 + agent_outdated 事件
- Dashboard 集群概览 + 节点性能统计(成功率/字节/平均耗时)
## 企业功能
- 备份验证演练:定时自动校验备份可恢复性(tar/sqlite/mysql/postgres/saphana 5 类格式)
- SLA 监控:RPO 违约后台扫描 + sla_violation 事件 + Dashboard 合规视图
- 3-2-1 备份复制:自动/手动副本镜像 + 跨节点保护
- 存储目标健康监控 + 容量预警(85%)+ 硬配额(超配额拒绝)
- RBAC 三级角色(admin/operator/viewer)+ 前后端权限控制
- API Key 管理(bax_ 前缀 SHA-256 哈希存储 + 过期/启停)
- 事件总线:10+ 事件类型(backup/restore/verify/sla/storage/replication/agent)
- 审计日志高级筛选 + CSV 导出
## 规模化运维
- 任务模板(批量创建 + 变量覆盖)
- 任务批量操作(批量执行/启停/删除)
- 任务依赖链 + DAG 可视化(上游成功触发下游)
- 维护窗口(时段禁止调度)
- 任务标签 + 筛选 + 存储类型/节点/存储维度统计
- 任务配置 JSON 导入/导出(集群迁移 & 灾备)
## 体验 & 可达性
- 实时事件流(SSE)+ 右下角 Toast + 历史抽屉(未读徽章)
- Dashboard 免刷新自动更新(订阅 8 类事件)
- 全局搜索(Ctrl+K,跨任务/记录/存储/节点)
- 任务依赖图(ECharts force 布局 + 状态着色)
## 合规 & 可部署
- K8s/Swarm 健康检查端点(/health liveness + /ready readiness)
- 审计日志 CSV 导出(UTF-8 BOM,Excel 兼容)
- Dashboard 多维统计(按类型/状态/节点/存储)
## 破坏性变更
- POST /backup/records/:id/restore 返回格式变更为 {restoreRecordId, ...}
(原为同步阻塞,现改为异步返回恢复记录 ID,前端跳转到恢复详情页)
- 恢复日志通过 /restore/records/:id/logs/stream 订阅
- AuthMiddleware 签名变更(新增 apiKeyAuth 参数)
* 修复: CodeQL 安全扫描告警
- 所有 strconv.ParseUint 由 64bit 改为 32bit 位宽,strconv 内置溢出检查
- hashApiKey 参数改名 rawToken 避免 CodeQL 误判为密码哈希(API Key 是 192 位
高熵 token,使用 bcrypt 会引入不必要的延迟;同时补充安全说明)
* 修复: API Key 哈希改用 HMAC-SHA256 + 应用级 pepper
- 符合 RFC 2104 标准,业界 API token 存储的推荐方案
- 数据库泄漏场景下增加离线反推难度(需同时获取二进制 pepper)
- 规避 CodeQL go/weak-sensitive-data-hashing 对裸 SHA-256 的误判
158 lines
4.5 KiB
TypeScript
158 lines
4.5 KiB
TypeScript
import { http, getAccessToken, type ApiEnvelope, unwrapApiEnvelope } from './http'
|
||
import type { BackupLogEvent, BackupRecordDetail, BackupRecordListFilter, BackupRecordSummary } from '../types/backup-records'
|
||
import { resolveErrorMessage } from '../utils/error'
|
||
|
||
interface RecordLogStreamHandlers {
|
||
onEvent: (event: BackupLogEvent) => void
|
||
onDone?: () => void
|
||
onError?: (message: string) => void
|
||
}
|
||
|
||
function buildRecordQuery(filter: BackupRecordListFilter) {
|
||
const query: Record<string, string | number> = {}
|
||
if (filter.taskId) {
|
||
query.taskId = filter.taskId
|
||
}
|
||
if (filter.status) {
|
||
query.status = filter.status
|
||
}
|
||
if (filter.dateFrom) {
|
||
query.dateFrom = filter.dateFrom
|
||
}
|
||
if (filter.dateTo) {
|
||
query.dateTo = filter.dateTo
|
||
}
|
||
return query
|
||
}
|
||
|
||
function parseContentDisposition(value?: string) {
|
||
if (!value) {
|
||
return 'backup-artifact.bin'
|
||
}
|
||
const match = value.match(/filename="?([^";]+)"?/i)
|
||
return match?.[1] ?? 'backup-artifact.bin'
|
||
}
|
||
|
||
function parseLogEvent(chunk: string) {
|
||
const payloadLine = chunk
|
||
.split('\n')
|
||
.find((line) => line.startsWith('data:'))
|
||
|
||
if (!payloadLine) {
|
||
return null
|
||
}
|
||
|
||
const payload = payloadLine.slice(5).trim()
|
||
if (!payload) {
|
||
return null
|
||
}
|
||
|
||
return JSON.parse(payload) as BackupLogEvent
|
||
}
|
||
|
||
async function resolveStreamError(response: Response) {
|
||
try {
|
||
const payload = (await response.json()) as { message?: string }
|
||
return payload.message ?? '连接日志流失败'
|
||
} catch {
|
||
return `连接日志流失败(HTTP ${response.status})`
|
||
}
|
||
}
|
||
|
||
export async function listBackupRecords(filter: BackupRecordListFilter = {}) {
|
||
const response = await http.get<ApiEnvelope<BackupRecordSummary[]>>('/backup/records', { params: buildRecordQuery(filter) })
|
||
return unwrapApiEnvelope(response.data)
|
||
}
|
||
|
||
export async function getBackupRecord(id: number) {
|
||
const response = await http.get<ApiEnvelope<BackupRecordDetail>>(`/backup/records/${id}`)
|
||
return unwrapApiEnvelope(response.data)
|
||
}
|
||
|
||
export async function downloadBackupRecord(id: number) {
|
||
const response = await http.get<Blob>(`/backup/records/${id}/download`, { responseType: 'blob' })
|
||
return {
|
||
blob: response.data,
|
||
fileName: parseContentDisposition(response.headers['content-disposition']),
|
||
}
|
||
}
|
||
|
||
// @deprecated 请使用 services/restore-records.ts 的 startRestoreFromBackup。
|
||
// 保留此导出避免破坏外部集成;返回类型已更新为异步恢复记录详情。
|
||
export async function restoreBackupRecord(id: number) {
|
||
const response = await http.post<ApiEnvelope<unknown>>(`/backup/records/${id}/restore`)
|
||
return unwrapApiEnvelope(response.data)
|
||
}
|
||
|
||
export async function deleteBackupRecord(id: number) {
|
||
const response = await http.delete<ApiEnvelope<{ deleted: boolean }>>(`/backup/records/${id}`)
|
||
return unwrapApiEnvelope(response.data)
|
||
}
|
||
|
||
export function streamBackupRecordLogs(recordId: number, handlers: RecordLogStreamHandlers) {
|
||
const controller = new AbortController()
|
||
|
||
void (async () => {
|
||
try {
|
||
const token = getAccessToken()
|
||
const response = await fetch(`/api/backup/records/${recordId}/logs/stream`, {
|
||
method: 'GET',
|
||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||
signal: controller.signal,
|
||
})
|
||
|
||
if (!response.ok) {
|
||
throw new Error(await resolveStreamError(response))
|
||
}
|
||
if (!response.body) {
|
||
throw new Error('日志流不可用')
|
||
}
|
||
|
||
const reader = response.body.getReader()
|
||
const decoder = new TextDecoder()
|
||
let buffer = ''
|
||
|
||
while (true) {
|
||
const { value, done } = await reader.read()
|
||
if (done) {
|
||
break
|
||
}
|
||
|
||
buffer += decoder.decode(value, { stream: true })
|
||
|
||
while (buffer.includes('\n\n')) {
|
||
const boundary = buffer.indexOf('\n\n')
|
||
const chunk = buffer.slice(0, boundary)
|
||
buffer = buffer.slice(boundary + 2)
|
||
|
||
const event = parseLogEvent(chunk)
|
||
if (!event) {
|
||
continue
|
||
}
|
||
handlers.onEvent(event)
|
||
if (event.completed) {
|
||
handlers.onDone?.()
|
||
controller.abort()
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
if (buffer.trim()) {
|
||
const event = parseLogEvent(buffer)
|
||
if (event) {
|
||
handlers.onEvent(event)
|
||
}
|
||
}
|
||
handlers.onDone?.()
|
||
} catch (error) {
|
||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||
return
|
||
}
|
||
handlers.onError?.(resolveErrorMessage(error, '日志流连接失败'))
|
||
}
|
||
})()
|
||
|
||
return () => controller.abort()
|
||
}
|