mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-05-11 09:59:56 +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 的误判
770 lines
30 KiB
Go
770 lines
30 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"net/http"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"backupx/server/internal/apperror"
|
||
"backupx/server/internal/model"
|
||
"backupx/server/internal/repository"
|
||
"backupx/server/internal/security"
|
||
"backupx/server/internal/storage"
|
||
"backupx/server/internal/storage/codec"
|
||
"golang.org/x/oauth2"
|
||
googleoauth "golang.org/x/oauth2/google"
|
||
goauth2api "google.golang.org/api/oauth2/v2"
|
||
"google.golang.org/api/option"
|
||
)
|
||
|
||
type StorageTargetUpsertInput struct {
|
||
Name string `json:"name" binding:"required,min=1,max=128"`
|
||
Type string `json:"type" binding:"required,min=1"`
|
||
Description string `json:"description" binding:"max=255"`
|
||
Enabled bool `json:"enabled"`
|
||
Config map[string]any `json:"config" binding:"required"`
|
||
// QuotaBytes 软限额(字节),0 = 不限制。
|
||
QuotaBytes int64 `json:"quotaBytes"`
|
||
}
|
||
|
||
type StorageTargetTestInput struct {
|
||
TargetID *uint `json:"targetId"`
|
||
Payload StorageTargetUpsertInput `json:"payload"`
|
||
}
|
||
|
||
type GoogleDriveAuthStartInput struct {
|
||
TargetID *uint `json:"targetId"`
|
||
Name string `json:"name" binding:"required,min=1,max=128"`
|
||
Description string `json:"description" binding:"max=255"`
|
||
Enabled bool `json:"enabled"`
|
||
ClientID string `json:"clientId" binding:"required"`
|
||
ClientSecret string `json:"clientSecret" binding:"required"`
|
||
FolderID string `json:"folderId"`
|
||
}
|
||
|
||
type GoogleDriveAuthCompleteInput struct {
|
||
State string `json:"state" binding:"required"`
|
||
Code string `json:"code" binding:"required"`
|
||
}
|
||
|
||
type StorageTargetSummary struct {
|
||
ID uint `json:"id"`
|
||
Name string `json:"name"`
|
||
Type string `json:"type"`
|
||
Description string `json:"description"`
|
||
Enabled bool `json:"enabled"`
|
||
Starred bool `json:"starred"`
|
||
ConfigVersion int `json:"configVersion"`
|
||
LastTestedAt *time.Time `json:"lastTestedAt"`
|
||
LastTestStatus string `json:"lastTestStatus"`
|
||
LastTestMessage string `json:"lastTestMessage"`
|
||
QuotaBytes int64 `json:"quotaBytes"`
|
||
UpdatedAt time.Time `json:"updatedAt"`
|
||
}
|
||
|
||
type StorageTargetDetail struct {
|
||
StorageTargetSummary
|
||
Config map[string]any `json:"config"`
|
||
MaskedFields []string `json:"maskedFields,omitempty"`
|
||
}
|
||
|
||
type GoogleDriveAuthStartResult struct {
|
||
AuthorizationURL string `json:"authorizationUrl"`
|
||
State string `json:"state"`
|
||
ExpiresAt time.Time `json:"expiresAt"`
|
||
}
|
||
|
||
type googleDriveOAuthDraft struct {
|
||
TargetID *uint `json:"targetId"`
|
||
Name string `json:"name"`
|
||
Description string `json:"description"`
|
||
Enabled bool `json:"enabled"`
|
||
ClientID string `json:"clientId"`
|
||
ClientSecret string `json:"clientSecret"`
|
||
FolderID string `json:"folderId"`
|
||
RedirectURI string `json:"redirectUri"`
|
||
}
|
||
|
||
type StorageTargetService struct {
|
||
targets repository.StorageTargetRepository
|
||
oauthSessions repository.OAuthSessionRepository
|
||
backupTasks repository.BackupTaskRepository
|
||
records repository.BackupRecordRepository
|
||
registry *storage.Registry
|
||
cipher *codec.ConfigCipher
|
||
}
|
||
|
||
func NewStorageTargetService(
|
||
targets repository.StorageTargetRepository,
|
||
oauthSessions repository.OAuthSessionRepository,
|
||
registry *storage.Registry,
|
||
cipher *codec.ConfigCipher,
|
||
) *StorageTargetService {
|
||
return &StorageTargetService{targets: targets, oauthSessions: oauthSessions, registry: registry, cipher: cipher}
|
||
}
|
||
|
||
func (s *StorageTargetService) SetBackupTaskRepository(tasks repository.BackupTaskRepository) {
|
||
s.backupTasks = tasks
|
||
}
|
||
|
||
func (s *StorageTargetService) SetBackupRecordRepository(records repository.BackupRecordRepository) {
|
||
s.records = records
|
||
}
|
||
|
||
func (s *StorageTargetService) List(ctx context.Context) ([]StorageTargetSummary, error) {
|
||
items, err := s.targets.List(ctx)
|
||
if err != nil {
|
||
return nil, apperror.Internal("STORAGE_TARGET_LIST_FAILED", "无法获取存储目标列表", err)
|
||
}
|
||
result := make([]StorageTargetSummary, 0, len(items))
|
||
for _, item := range items {
|
||
result = append(result, toStorageTargetSummary(&item))
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func (s *StorageTargetService) Get(ctx context.Context, id uint) (*StorageTargetDetail, error) {
|
||
item, err := s.targets.FindByID(ctx, id)
|
||
if err != nil {
|
||
return nil, apperror.Internal("STORAGE_TARGET_GET_FAILED", "无法获取存储目标详情", err)
|
||
}
|
||
if item == nil {
|
||
return nil, apperror.New(http.StatusNotFound, "STORAGE_TARGET_NOT_FOUND", "存储目标不存在", fmt.Errorf("storage target %d not found", id))
|
||
}
|
||
configMap, err := s.decryptTargetConfig(item)
|
||
if err != nil {
|
||
return nil, apperror.Internal("STORAGE_TARGET_DECRYPT_FAILED", "无法解密存储目标配置", err)
|
||
}
|
||
sensitiveFields := s.registry.SensitiveFields(storage.ParseProviderType(item.Type))
|
||
return &StorageTargetDetail{StorageTargetSummary: toStorageTargetSummary(item), Config: codec.MaskConfig(configMap, sensitiveFields), MaskedFields: sensitiveFields}, nil
|
||
}
|
||
|
||
func (s *StorageTargetService) Create(ctx context.Context, input StorageTargetUpsertInput) (*StorageTargetDetail, error) {
|
||
if err := s.validateType(input.Type); err != nil {
|
||
return nil, err
|
||
}
|
||
existing, err := s.targets.FindByName(ctx, strings.TrimSpace(input.Name))
|
||
if err != nil {
|
||
return nil, apperror.Internal("STORAGE_TARGET_LOOKUP_FAILED", "无法检查存储目标名称", err)
|
||
}
|
||
if existing != nil {
|
||
return nil, apperror.Conflict("STORAGE_TARGET_NAME_EXISTS", "存储目标名称已存在", nil)
|
||
}
|
||
item, err := s.buildStorageTarget(ctx, nil, input)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := s.targets.Create(ctx, item); err != nil {
|
||
return nil, apperror.Internal("STORAGE_TARGET_CREATE_FAILED", "无法创建存储目标", err)
|
||
}
|
||
return s.Get(ctx, item.ID)
|
||
}
|
||
|
||
func (s *StorageTargetService) Update(ctx context.Context, id uint, input StorageTargetUpsertInput) (*StorageTargetDetail, error) {
|
||
if err := s.validateType(input.Type); err != nil {
|
||
return nil, err
|
||
}
|
||
existing, err := s.targets.FindByID(ctx, id)
|
||
if err != nil {
|
||
return nil, apperror.Internal("STORAGE_TARGET_GET_FAILED", "无法获取存储目标详情", err)
|
||
}
|
||
if existing == nil {
|
||
return nil, apperror.New(http.StatusNotFound, "STORAGE_TARGET_NOT_FOUND", "存储目标不存在", fmt.Errorf("storage target %d not found", id))
|
||
}
|
||
if sameName, err := s.targets.FindByName(ctx, strings.TrimSpace(input.Name)); err != nil {
|
||
return nil, apperror.Internal("STORAGE_TARGET_LOOKUP_FAILED", "无法检查存储目标名称", err)
|
||
} else if sameName != nil && sameName.ID != existing.ID {
|
||
return nil, apperror.Conflict("STORAGE_TARGET_NAME_EXISTS", "存储目标名称已存在", nil)
|
||
}
|
||
item, err := s.buildStorageTarget(ctx, existing, input)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
item.ID = existing.ID
|
||
item.CreatedAt = existing.CreatedAt
|
||
if err := s.targets.Update(ctx, item); err != nil {
|
||
return nil, apperror.Internal("STORAGE_TARGET_UPDATE_FAILED", "无法更新存储目标", err)
|
||
}
|
||
return s.Get(ctx, item.ID)
|
||
}
|
||
|
||
func (s *StorageTargetService) Delete(ctx context.Context, id uint) error {
|
||
existing, err := s.targets.FindByID(ctx, id)
|
||
if err != nil {
|
||
return apperror.Internal("STORAGE_TARGET_GET_FAILED", "无法获取存储目标详情", err)
|
||
}
|
||
if existing == nil {
|
||
return apperror.New(http.StatusNotFound, "STORAGE_TARGET_NOT_FOUND", "存储目标不存在", fmt.Errorf("storage target %d not found", id))
|
||
}
|
||
if s.backupTasks != nil {
|
||
count, countErr := s.backupTasks.CountByStorageTargetID(ctx, id)
|
||
if countErr != nil {
|
||
return apperror.Internal("STORAGE_TARGET_REF_CHECK_FAILED", "无法检查存储目标引用关系", countErr)
|
||
}
|
||
if count > 0 {
|
||
return apperror.Conflict("STORAGE_TARGET_IN_USE", "当前存储目标已被备份任务引用,无法删除", nil)
|
||
}
|
||
}
|
||
if err := s.targets.Delete(ctx, id); err != nil {
|
||
return apperror.Internal("STORAGE_TARGET_DELETE_FAILED", "无法删除存储目标", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *StorageTargetService) ToggleStar(ctx context.Context, id uint) (*StorageTargetSummary, error) {
|
||
item, err := s.targets.FindByID(ctx, id)
|
||
if err != nil {
|
||
return nil, apperror.Internal("STORAGE_TARGET_GET_FAILED", "无法获取存储目标详情", err)
|
||
}
|
||
if item == nil {
|
||
return nil, apperror.New(http.StatusNotFound, "STORAGE_TARGET_NOT_FOUND", "存储目标不存在", fmt.Errorf("storage target %d not found", id))
|
||
}
|
||
item.Starred = !item.Starred
|
||
if err := s.targets.Update(ctx, item); err != nil {
|
||
return nil, apperror.Internal("STORAGE_TARGET_UPDATE_FAILED", "无法更新存储目标收藏状态", err)
|
||
}
|
||
summary := toStorageTargetSummary(item)
|
||
return &summary, nil
|
||
}
|
||
|
||
func (s *StorageTargetService) TestConnection(ctx context.Context, input StorageTargetTestInput) error {
|
||
item, err := s.buildStorageTargetForTest(ctx, input)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
configMap, err := s.decryptTargetConfig(item)
|
||
if err != nil {
|
||
return apperror.Internal("STORAGE_TARGET_DECRYPT_FAILED", "无法解密存储目标配置", err)
|
||
}
|
||
provider, err := s.registry.Create(ctx, storage.ParseProviderType(item.Type), configMap)
|
||
if err != nil {
|
||
return apperror.BadRequest("STORAGE_TARGET_INVALID_CONFIG", sanitizeMessage(err.Error()), err)
|
||
}
|
||
testErr := provider.TestConnection(ctx)
|
||
now := time.Now().UTC()
|
||
item.LastTestedAt = &now
|
||
if testErr != nil {
|
||
item.LastTestStatus = "failed"
|
||
item.LastTestMessage = sanitizeMessage(testErr.Error())
|
||
} else {
|
||
item.LastTestStatus = "success"
|
||
item.LastTestMessage = "连接成功"
|
||
}
|
||
if item.ID != 0 {
|
||
_ = s.targets.Update(ctx, item)
|
||
}
|
||
if testErr != nil {
|
||
return apperror.BadRequest("STORAGE_TARGET_TEST_FAILED", sanitizeMessage(testErr.Error()), testErr)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// StartHealthMonitor 启动后台存储目标健康扫描。
|
||
// 周期性对启用的存储目标跑 TestConnection(非阻塞),并在"从成功转失败"时派发 storage_unhealthy 事件。
|
||
// interval 建议 5m;dispatcher 为 nil 时仅更新 LastTestStatus 不告警。
|
||
func (s *StorageTargetService) StartHealthMonitor(ctx context.Context, dispatcher EventDispatcher, interval time.Duration) {
|
||
if interval <= 0 {
|
||
interval = 5 * time.Minute
|
||
}
|
||
ticker := time.NewTicker(interval)
|
||
// notified 跟踪已告警的目标,避免每轮重复
|
||
notified := map[uint]bool{}
|
||
capacityNotified := map[uint]bool{}
|
||
var mu sync.Mutex
|
||
go func() {
|
||
defer ticker.Stop()
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-ticker.C:
|
||
s.runHealthCheckOnce(ctx, dispatcher, &mu, notified)
|
||
s.runCapacityCheckOnce(ctx, dispatcher, &mu, capacityNotified)
|
||
}
|
||
}
|
||
}()
|
||
}
|
||
|
||
// StorageCapacityWarningThreshold 存储使用率告警阈值(85%)。
|
||
// 超过此值视为容量预警,派发 storage_capacity_warning 事件。
|
||
// 做成常量而非配置:企业运维场景下 85% 是业界通用预警线,无需用户调整。
|
||
const StorageCapacityWarningThreshold = 0.85
|
||
|
||
// runCapacityCheckOnce 扫描所有支持 StorageAbout 接口的启用存储目标,
|
||
// 使用率超过阈值时派发 storage_capacity_warning 事件(避免重复派发)。
|
||
// 降到阈值以下(例如清理/扩容后)自动清除记忆。
|
||
func (s *StorageTargetService) runCapacityCheckOnce(ctx context.Context, dispatcher EventDispatcher, mu *sync.Mutex, notified map[uint]bool) {
|
||
if dispatcher == nil {
|
||
return
|
||
}
|
||
targets, err := s.targets.List(ctx)
|
||
if err != nil {
|
||
return
|
||
}
|
||
for i := range targets {
|
||
target := targets[i]
|
||
if !target.Enabled {
|
||
continue
|
||
}
|
||
configMap := map[string]any{}
|
||
if err := s.cipher.DecryptJSON(target.ConfigCiphertext, &configMap); err != nil {
|
||
continue
|
||
}
|
||
provider, err := s.registry.Create(ctx, storage.ParseProviderType(target.Type), configMap)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
about, ok := provider.(storage.StorageAbout)
|
||
if !ok {
|
||
continue // 该后端不支持容量查询(如 S3 / FTP 等),跳过
|
||
}
|
||
info, err := about.About(ctx)
|
||
if err != nil || info == nil || info.Total == nil || info.Used == nil || *info.Total == 0 {
|
||
continue
|
||
}
|
||
usage := float64(*info.Used) / float64(*info.Total)
|
||
mu.Lock()
|
||
alreadyNotified := notified[target.ID]
|
||
if usage >= StorageCapacityWarningThreshold {
|
||
if !alreadyNotified {
|
||
notified[target.ID] = true
|
||
mu.Unlock()
|
||
s.dispatchCapacityWarning(ctx, dispatcher, &target, info, usage)
|
||
continue
|
||
}
|
||
} else {
|
||
delete(notified, target.ID) // 容量回落后允许下次再告警
|
||
}
|
||
mu.Unlock()
|
||
}
|
||
}
|
||
|
||
func (s *StorageTargetService) dispatchCapacityWarning(ctx context.Context, dispatcher EventDispatcher, target *model.StorageTarget, info *storage.StorageUsageInfo, usage float64) {
|
||
title := "BackupX 存储容量预警"
|
||
usedGB := float64(*info.Used) / (1 << 30)
|
||
totalGB := float64(*info.Total) / (1 << 30)
|
||
body := fmt.Sprintf("存储目标:%s (类型: %s)\n使用率:%.1f%%\n已用:%.2f GB / 总量:%.2f GB\n建议清理旧备份或扩容。",
|
||
target.Name, target.Type, usage*100, usedGB, totalGB)
|
||
fields := map[string]any{
|
||
"storageTargetId": target.ID,
|
||
"storageTargetName": target.Name,
|
||
"storageType": target.Type,
|
||
"usageRate": usage,
|
||
"usedBytes": *info.Used,
|
||
"totalBytes": *info.Total,
|
||
}
|
||
_ = dispatcher.DispatchEvent(ctx, model.NotificationEventStorageCapacity, title, body, fields)
|
||
}
|
||
|
||
// runHealthCheckOnce 对所有启用目标执行一次连接测试并按需派发事件。
|
||
// "健康→故障"边沿触发告警;"故障→健康"边沿清除 notified 记忆,允许下次故障再次告警。
|
||
func (s *StorageTargetService) runHealthCheckOnce(ctx context.Context, dispatcher EventDispatcher, mu *sync.Mutex, notified map[uint]bool) {
|
||
targets, err := s.targets.List(ctx)
|
||
if err != nil {
|
||
return
|
||
}
|
||
for i := range targets {
|
||
target := targets[i]
|
||
if !target.Enabled {
|
||
continue
|
||
}
|
||
previousStatus := target.LastTestStatus
|
||
configMap := map[string]any{}
|
||
if err := s.cipher.DecryptJSON(target.ConfigCiphertext, &configMap); err != nil {
|
||
continue
|
||
}
|
||
provider, err := s.registry.Create(ctx, storage.ParseProviderType(target.Type), configMap)
|
||
now := time.Now().UTC()
|
||
if err != nil {
|
||
s.applyHealthResult(ctx, &target, now, false, err.Error())
|
||
s.notifyUnhealthyTransition(ctx, dispatcher, mu, notified, &target, previousStatus, err.Error())
|
||
continue
|
||
}
|
||
testErr := provider.TestConnection(ctx)
|
||
if testErr != nil {
|
||
s.applyHealthResult(ctx, &target, now, false, testErr.Error())
|
||
s.notifyUnhealthyTransition(ctx, dispatcher, mu, notified, &target, previousStatus, testErr.Error())
|
||
continue
|
||
}
|
||
s.applyHealthResult(ctx, &target, now, true, "连接成功")
|
||
// 恢复健康:清除告警记忆
|
||
mu.Lock()
|
||
delete(notified, target.ID)
|
||
mu.Unlock()
|
||
}
|
||
}
|
||
|
||
func (s *StorageTargetService) applyHealthResult(ctx context.Context, target *model.StorageTarget, at time.Time, healthy bool, message string) {
|
||
target.LastTestedAt = &at
|
||
if healthy {
|
||
target.LastTestStatus = "success"
|
||
} else {
|
||
target.LastTestStatus = "failed"
|
||
}
|
||
target.LastTestMessage = sanitizeMessage(message)
|
||
_ = s.targets.Update(ctx, target)
|
||
}
|
||
|
||
func (s *StorageTargetService) notifyUnhealthyTransition(ctx context.Context, dispatcher EventDispatcher, mu *sync.Mutex, notified map[uint]bool, target *model.StorageTarget, previousStatus string, message string) {
|
||
if dispatcher == nil {
|
||
return
|
||
}
|
||
mu.Lock()
|
||
already := notified[target.ID]
|
||
if !already {
|
||
notified[target.ID] = true
|
||
}
|
||
mu.Unlock()
|
||
// 仅在上次状态是 success / unknown 且本次是 failed 时首次告警;
|
||
// 已告警过的持续故障不重复发送(等 resetInterval 或恢复后重新触发)。
|
||
if already {
|
||
return
|
||
}
|
||
_ = previousStatus // 保留参数便于未来扩展:区分"从未测试"与"从 success 掉线"
|
||
title := "BackupX 存储目标连接失败"
|
||
body := fmt.Sprintf("存储目标:%s (类型: %s)\n错误:%s", target.Name, target.Type, message)
|
||
fields := map[string]any{
|
||
"storageTargetId": target.ID,
|
||
"storageTargetName": target.Name,
|
||
"storageType": target.Type,
|
||
"error": message,
|
||
}
|
||
_ = dispatcher.DispatchEvent(ctx, model.NotificationEventStorageUnhealthy, title, body, fields)
|
||
}
|
||
|
||
func (s *StorageTargetService) StartGoogleDriveOAuth(ctx context.Context, input GoogleDriveAuthStartInput, origin string) (*GoogleDriveAuthStartResult, error) {
|
||
origin = normalizeOrigin(origin)
|
||
if origin == "" {
|
||
return nil, apperror.BadRequest("STORAGE_GOOGLE_OAUTH_ORIGIN_REQUIRED", "无法确定 Google Drive 回调地址", nil)
|
||
}
|
||
draft, err := s.buildGoogleDriveDraft(ctx, input, origin)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
payload, err := s.cipher.EncryptJSON(draft)
|
||
if err != nil {
|
||
return nil, apperror.Internal("STORAGE_GOOGLE_OAUTH_ENCRYPT_FAILED", "无法创建授权会话", err)
|
||
}
|
||
state, err := security.GenerateSecret(24)
|
||
if err != nil {
|
||
return nil, apperror.Internal("STORAGE_GOOGLE_OAUTH_STATE_FAILED", "无法生成授权状态", err)
|
||
}
|
||
expiresAt := time.Now().UTC().Add(10 * time.Minute)
|
||
session := &model.OAuthSession{ProviderType: storage.TypeGoogleDrive, State: state, PayloadCiphertext: payload, TargetID: input.TargetID, ExpiresAt: expiresAt}
|
||
if err := s.oauthSessions.Create(ctx, session); err != nil {
|
||
return nil, apperror.Internal("STORAGE_GOOGLE_OAUTH_SESSION_FAILED", "无法创建授权会话", err)
|
||
}
|
||
oauthCfg := &oauth2.Config{ClientID: draft.ClientID, ClientSecret: draft.ClientSecret, RedirectURL: draft.RedirectURI, Endpoint: googleoauth.Endpoint, Scopes: []string{"https://www.googleapis.com/auth/drive"}}
|
||
url := oauthCfg.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("prompt", "consent"))
|
||
return &GoogleDriveAuthStartResult{AuthorizationURL: url, State: state, ExpiresAt: expiresAt}, nil
|
||
}
|
||
|
||
func (s *StorageTargetService) CompleteGoogleDriveOAuth(ctx context.Context, input GoogleDriveAuthCompleteInput) (*StorageTargetDetail, error) {
|
||
session, err := s.oauthSessions.FindByState(ctx, strings.TrimSpace(input.State))
|
||
if err != nil {
|
||
return nil, apperror.Internal("STORAGE_GOOGLE_OAUTH_SESSION_FAILED", "无法读取授权会话", err)
|
||
}
|
||
if session == nil || session.UsedAt != nil || time.Now().UTC().After(session.ExpiresAt) {
|
||
return nil, apperror.BadRequest("STORAGE_GOOGLE_OAUTH_STATE_INVALID", "Google Drive 授权状态无效或已过期", nil)
|
||
}
|
||
// Mark used immediately to prevent duplicate requests (e.g. React StrictMode double invocation)
|
||
now := time.Now().UTC()
|
||
session.UsedAt = &now
|
||
_ = s.oauthSessions.Update(ctx, session)
|
||
|
||
var draft googleDriveOAuthDraft
|
||
if err := s.cipher.DecryptJSON(session.PayloadCiphertext, &draft); err != nil {
|
||
return nil, apperror.Internal("STORAGE_GOOGLE_OAUTH_DECRYPT_FAILED", "无法读取授权会话内容", err)
|
||
}
|
||
oauthCfg := &oauth2.Config{ClientID: draft.ClientID, ClientSecret: draft.ClientSecret, RedirectURL: draft.RedirectURI, Endpoint: googleoauth.Endpoint, Scopes: []string{"https://www.googleapis.com/auth/drive"}}
|
||
token, err := oauthCfg.Exchange(ctx, strings.TrimSpace(input.Code))
|
||
if err != nil {
|
||
return nil, apperror.BadRequest("STORAGE_GOOGLE_OAUTH_EXCHANGE_FAILED", "Google Drive 授权码换取失败", err)
|
||
}
|
||
if strings.TrimSpace(token.RefreshToken) == "" {
|
||
return nil, apperror.BadRequest("STORAGE_GOOGLE_OAUTH_REFRESH_TOKEN_MISSING", "未获取到 Google Drive refresh token,请重新授权", nil)
|
||
}
|
||
configMap := map[string]any{
|
||
"clientId": draft.ClientID,
|
||
"clientSecret": draft.ClientSecret,
|
||
"refreshToken": token.RefreshToken,
|
||
"folderId": draft.FolderID,
|
||
"redirectUri": draft.RedirectURI,
|
||
}
|
||
payload := StorageTargetUpsertInput{Name: draft.Name, Type: storage.TypeGoogleDrive, Description: draft.Description, Enabled: draft.Enabled, Config: configMap}
|
||
var detail *StorageTargetDetail
|
||
if session.TargetID != nil {
|
||
detail, err = s.Update(ctx, *session.TargetID, payload)
|
||
} else {
|
||
detail, err = s.Create(ctx, payload)
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return detail, nil
|
||
}
|
||
|
||
func (s *StorageTargetService) GoogleDriveProfile(ctx context.Context, id uint) (map[string]any, error) {
|
||
detail, err := s.Get(ctx, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if detail.Type != storage.TypeGoogleDrive {
|
||
return nil, apperror.BadRequest("STORAGE_GOOGLE_DRIVE_TYPE_MISMATCH", "目标不是 Google Drive 存储类型", nil)
|
||
}
|
||
stored, err := s.targets.FindByID(ctx, id)
|
||
if err != nil || stored == nil {
|
||
return nil, apperror.New(http.StatusNotFound, "STORAGE_TARGET_NOT_FOUND", "存储目标不存在", err)
|
||
}
|
||
var cfg storage.GoogleDriveConfig
|
||
if err := s.cipher.DecryptJSON(stored.ConfigCiphertext, &cfg); err != nil {
|
||
return nil, apperror.Internal("STORAGE_TARGET_DECRYPT_FAILED", "无法解密存储目标配置", err)
|
||
}
|
||
cfg = cfg.Normalize()
|
||
oauthCfg := &oauth2.Config{ClientID: cfg.ClientID, ClientSecret: cfg.ClientSecret, Endpoint: googleoauth.Endpoint, RedirectURL: cfg.RedirectURL, Scopes: []string{"https://www.googleapis.com/auth/drive"}}
|
||
tokenSource := oauthCfg.TokenSource(ctx, &oauth2.Token{RefreshToken: cfg.RefreshToken, Expiry: time.Now().Add(-time.Hour)})
|
||
client, err := goauth2api.NewService(ctx, option.WithTokenSource(tokenSource))
|
||
if err != nil {
|
||
return nil, apperror.BadRequest("STORAGE_GOOGLE_PROFILE_FAILED", "无法获取 Google Drive 用户信息", err)
|
||
}
|
||
userInfo, err := client.Userinfo.Get().Do()
|
||
if err != nil {
|
||
return nil, apperror.BadRequest("STORAGE_GOOGLE_PROFILE_FAILED", "无法获取 Google Drive 用户信息", err)
|
||
}
|
||
return map[string]any{"email": userInfo.Email, "name": userInfo.Name, "picture": userInfo.Picture}, nil
|
||
}
|
||
|
||
func (s *StorageTargetService) buildStorageTargetForTest(ctx context.Context, input StorageTargetTestInput) (*model.StorageTarget, error) {
|
||
if input.TargetID == nil {
|
||
return s.buildStorageTarget(ctx, nil, input.Payload)
|
||
}
|
||
existing, err := s.targets.FindByID(ctx, *input.TargetID)
|
||
if err != nil {
|
||
return nil, apperror.Internal("STORAGE_TARGET_GET_FAILED", "无法获取存储目标详情", err)
|
||
}
|
||
if existing == nil {
|
||
return nil, apperror.New(http.StatusNotFound, "STORAGE_TARGET_NOT_FOUND", "存储目标不存在", fmt.Errorf("storage target %d not found", *input.TargetID))
|
||
}
|
||
if strings.TrimSpace(input.Payload.Type) == "" && strings.TrimSpace(input.Payload.Name) == "" && len(input.Payload.Config) == 0 {
|
||
return existing, nil
|
||
}
|
||
item, err := s.buildStorageTarget(ctx, existing, input.Payload)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
item.ID = existing.ID
|
||
item.LastTestedAt = existing.LastTestedAt
|
||
item.LastTestStatus = existing.LastTestStatus
|
||
item.LastTestMessage = existing.LastTestMessage
|
||
return item, nil
|
||
}
|
||
|
||
func (s *StorageTargetService) buildStorageTarget(ctx context.Context, existing *model.StorageTarget, input StorageTargetUpsertInput) (*model.StorageTarget, error) {
|
||
configMap, err := s.prepareConfig(ctx, existing, input)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
ciphertext, err := s.cipher.EncryptJSON(configMap)
|
||
if err != nil {
|
||
return nil, apperror.Internal("STORAGE_TARGET_ENCRYPT_FAILED", "无法保存存储目标配置", err)
|
||
}
|
||
quota := input.QuotaBytes
|
||
if quota < 0 {
|
||
quota = 0
|
||
}
|
||
item := &model.StorageTarget{
|
||
Name: strings.TrimSpace(input.Name),
|
||
Type: input.Type,
|
||
Description: strings.TrimSpace(input.Description),
|
||
Enabled: input.Enabled,
|
||
ConfigCiphertext: ciphertext,
|
||
ConfigVersion: 1,
|
||
LastTestStatus: "unknown",
|
||
QuotaBytes: quota,
|
||
}
|
||
if existing != nil {
|
||
item.LastTestedAt = existing.LastTestedAt
|
||
item.LastTestStatus = existing.LastTestStatus
|
||
item.LastTestMessage = existing.LastTestMessage
|
||
if existing.Type == input.Type {
|
||
item.ConfigVersion = existing.ConfigVersion
|
||
}
|
||
}
|
||
return item, nil
|
||
}
|
||
|
||
func (s *StorageTargetService) prepareConfig(ctx context.Context, existing *model.StorageTarget, input StorageTargetUpsertInput) (map[string]any, error) {
|
||
if err := s.validateType(input.Type); err != nil {
|
||
return nil, err
|
||
}
|
||
configMap := cloneMap(input.Config)
|
||
if existing != nil {
|
||
if existing.Type != input.Type {
|
||
return nil, apperror.BadRequest("STORAGE_TARGET_TYPE_IMMUTABLE", "不支持直接修改存储目标类型", nil)
|
||
}
|
||
existingMap, err := s.decryptTargetConfig(existing)
|
||
if err != nil {
|
||
return nil, apperror.Internal("STORAGE_TARGET_DECRYPT_FAILED", "无法读取现有存储目标配置", err)
|
||
}
|
||
configMap = codec.MergeMaskedConfig(configMap, existingMap, s.registry.SensitiveFields(storage.ParseProviderType(input.Type)))
|
||
}
|
||
if _, err := s.registry.Create(ctx, storage.ParseProviderType(input.Type), configMap); err != nil {
|
||
return nil, apperror.BadRequest("STORAGE_TARGET_INVALID_CONFIG", sanitizeMessage(err.Error()), err)
|
||
}
|
||
return configMap, nil
|
||
}
|
||
|
||
func (s *StorageTargetService) decryptTargetConfig(item *model.StorageTarget) (map[string]any, error) {
|
||
var configMap map[string]any
|
||
if err := s.cipher.DecryptJSON(item.ConfigCiphertext, &configMap); err != nil {
|
||
return nil, err
|
||
}
|
||
return configMap, nil
|
||
}
|
||
|
||
func (s *StorageTargetService) buildGoogleDriveDraft(ctx context.Context, input GoogleDriveAuthStartInput, origin string) (*googleDriveOAuthDraft, error) {
|
||
draft := &googleDriveOAuthDraft{
|
||
TargetID: input.TargetID,
|
||
Name: strings.TrimSpace(input.Name),
|
||
Description: strings.TrimSpace(input.Description),
|
||
Enabled: input.Enabled,
|
||
ClientID: strings.TrimSpace(input.ClientID),
|
||
ClientSecret: strings.TrimSpace(input.ClientSecret),
|
||
FolderID: strings.TrimSpace(input.FolderID),
|
||
RedirectURI: strings.TrimRight(origin, "/") + "/storage-targets/google-drive/callback",
|
||
}
|
||
if input.TargetID == nil {
|
||
if draft.Name == "" || draft.ClientID == "" || draft.ClientSecret == "" {
|
||
return nil, apperror.BadRequest("STORAGE_GOOGLE_OAUTH_INVALID", "Google Drive 授权参数不完整", nil)
|
||
}
|
||
return draft, nil
|
||
}
|
||
existing, err := s.targets.FindByID(ctx, *input.TargetID)
|
||
if err != nil {
|
||
return nil, apperror.Internal("STORAGE_TARGET_GET_FAILED", "无法获取存储目标详情", err)
|
||
}
|
||
if existing == nil {
|
||
return nil, apperror.New(http.StatusNotFound, "STORAGE_TARGET_NOT_FOUND", "存储目标不存在", fmt.Errorf("storage target %d not found", *input.TargetID))
|
||
}
|
||
if existing.Type != storage.TypeGoogleDrive {
|
||
return nil, apperror.BadRequest("STORAGE_GOOGLE_DRIVE_TYPE_MISMATCH", "目标不是 Google Drive 存储类型", nil)
|
||
}
|
||
var cfg storage.GoogleDriveConfig
|
||
if err := s.cipher.DecryptJSON(existing.ConfigCiphertext, &cfg); err != nil {
|
||
return nil, apperror.Internal("STORAGE_TARGET_DECRYPT_FAILED", "无法解密存储目标配置", err)
|
||
}
|
||
cfg = cfg.Normalize()
|
||
if draft.Name == "" {
|
||
draft.Name = existing.Name
|
||
}
|
||
if draft.Description == "" {
|
||
draft.Description = existing.Description
|
||
}
|
||
if draft.ClientID == "" || codec.IsMaskedString(draft.ClientID) {
|
||
draft.ClientID = cfg.ClientID
|
||
}
|
||
if draft.ClientSecret == "" || codec.IsMaskedString(draft.ClientSecret) {
|
||
draft.ClientSecret = cfg.ClientSecret
|
||
}
|
||
if draft.FolderID == "" {
|
||
draft.FolderID = cfg.FolderID
|
||
}
|
||
if draft.Name == "" || draft.ClientID == "" || draft.ClientSecret == "" {
|
||
return nil, apperror.BadRequest("STORAGE_GOOGLE_OAUTH_INVALID", "Google Drive 授权参数不完整", nil)
|
||
}
|
||
return draft, nil
|
||
}
|
||
|
||
func (s *StorageTargetService) validateType(providerType string) error {
|
||
if _, ok := s.registry.Factory(storage.ParseProviderType(providerType)); !ok {
|
||
return apperror.BadRequest("STORAGE_PROVIDER_UNSUPPORTED", "不支持的存储类型", fmt.Errorf("provider %s not found", providerType))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func toStorageTargetSummary(item *model.StorageTarget) StorageTargetSummary {
|
||
return StorageTargetSummary{
|
||
ID: item.ID,
|
||
Name: item.Name,
|
||
Type: item.Type,
|
||
Description: item.Description,
|
||
Enabled: item.Enabled,
|
||
Starred: item.Starred,
|
||
ConfigVersion: item.ConfigVersion,
|
||
LastTestedAt: item.LastTestedAt,
|
||
LastTestStatus: item.LastTestStatus,
|
||
LastTestMessage: item.LastTestMessage,
|
||
QuotaBytes: item.QuotaBytes,
|
||
UpdatedAt: item.UpdatedAt,
|
||
}
|
||
}
|
||
|
||
func sanitizeMessage(message string) string {
|
||
message = strings.TrimSpace(message)
|
||
if message == "" {
|
||
return "操作失败"
|
||
}
|
||
if len(message) > 255 {
|
||
return message[:255]
|
||
}
|
||
return message
|
||
}
|
||
|
||
func normalizeOrigin(origin string) string {
|
||
origin = strings.TrimSpace(origin)
|
||
return strings.TrimRight(origin, "/")
|
||
}
|
||
|
||
func cloneMap(source map[string]any) map[string]any {
|
||
result := make(map[string]any, len(source))
|
||
for key, value := range source {
|
||
result[key] = value
|
||
}
|
||
return result
|
||
}
|
||
|
||
type StorageTargetUsage struct {
|
||
TargetID uint `json:"targetId"`
|
||
TargetName string `json:"targetName"`
|
||
RecordCount int64 `json:"recordCount"`
|
||
TotalSize int64 `json:"totalSize"`
|
||
DiskUsage *storage.StorageUsageInfo `json:"diskUsage,omitempty"`
|
||
}
|
||
|
||
func (s *StorageTargetService) GetUsage(ctx context.Context, id uint) (*StorageTargetUsage, error) {
|
||
target, err := s.targets.FindByID(ctx, id)
|
||
if err != nil {
|
||
return nil, apperror.Internal("STORAGE_TARGET_GET_FAILED", "无法获取存储目标详情", err)
|
||
}
|
||
if target == nil {
|
||
return nil, apperror.New(http.StatusNotFound, "STORAGE_TARGET_NOT_FOUND", "存储目标不存在", fmt.Errorf("storage target %d not found", id))
|
||
}
|
||
result := &StorageTargetUsage{TargetID: id, TargetName: target.Name}
|
||
if s.records != nil {
|
||
usageItems, usageErr := s.records.StorageUsage(ctx)
|
||
if usageErr == nil {
|
||
for _, item := range usageItems {
|
||
if item.StorageTargetID == id {
|
||
result.TotalSize = item.TotalSize
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// 尝试查询远端真实存储空间(部分后端如 local/Google Drive/WebDAV 支持)
|
||
configMap := map[string]any{}
|
||
if decryptErr := s.cipher.DecryptJSON(target.ConfigCiphertext, &configMap); decryptErr == nil {
|
||
if provider, createErr := s.registry.Create(ctx, target.Type, configMap); createErr == nil {
|
||
if abouter, ok := provider.(storage.StorageAbout); ok {
|
||
if diskUsage, aboutErr := abouter.About(ctx); aboutErr == nil {
|
||
result.DiskUsage = diskUsage
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return result, nil
|
||
}
|