From f67ee12d72f4f3e3b6595979890b044a2fbbe335 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 16 Aug 2026 23:53:50 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=A4=87=E4=BB=BD=E7=9B=91?= =?UTF-8?q?=E6=8E=A7=E4=B8=8E=E5=AE=9A=E6=97=B6=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.env.prod.example | 4 + backend/internal/config/config.go | 2 + .../internal/modules/backupmonitor/handler.go | 59 ++++++++ backend/internal/modules/systemconfig/dto.go | 8 + .../internal/modules/systemconfig/handler.go | 28 ++++ .../modules/systemconfig/repository.go | 4 + .../internal/modules/systemconfig/service.go | 29 ++++ .../modules/systemconfig/service_update.go | 26 ++++ backend/internal/router/router.go | 5 + .../000050_backup_monitor_permissions.sql | 35 +++++ deploy/README.md | 13 +- deploy/docker-compose.prod.yml | 2 + frontend/src/features/admin/api/backup.ts | 32 ++++ .../features/admin/views/AdminBackupView.vue | 141 ++++++++++++++++++ frontend/src/layouts/AdminLayout.vue | 6 + frontend/src/router/adminRoutes.ts | 6 + scripts/backup-scheduler.sh | 83 +++++++++++ scripts/backup-status.sh | 106 +++++++++++++ scripts/deploy-prod.sh | 26 +++- 19 files changed, 612 insertions(+), 3 deletions(-) create mode 100644 backend/internal/modules/backupmonitor/handler.go create mode 100644 backend/migrations/000050_backup_monitor_permissions.sql create mode 100644 frontend/src/features/admin/api/backup.ts create mode 100644 frontend/src/features/admin/views/AdminBackupView.vue create mode 100755 scripts/backup-scheduler.sh create mode 100755 scripts/backup-status.sh diff --git a/backend/.env.prod.example b/backend/.env.prod.example index a35306f..af1fe7c 100644 --- a/backend/.env.prod.example +++ b/backend/.env.prod.example @@ -103,6 +103,10 @@ EXTERNAL_UPLOAD_ALLOWED_IPS= # 在线备份(scripts/backup-online.sh / scripts/archive-binlog.sh)。所有项为生产必填。 # 本地备份根目录,必须是独立数据盘上的 /data 子目录;包含加密全量备份与 binlog 状态。 BACKUP_DIR=/data/backups +# 后台仅读取此目录中的状态摘要;不要将整个 BACKUP_DIR 挂入 backend。 +BACKUP_STATUS_DIR=/data/backups/status +# backend 容器内的只读状态文件路径,通常保持默认。 +BACKUP_STATUS_FILE=/var/run/hfb-backup-status/status.json # 隔离恢复演练的数据目录,必须与 BACKUP_DIR 不同;prepare 后的 MySQL 原始数据只放在这里。 BACKUP_RESTORE_DIR=/data/restore # 备份专用 OSS Bucket(与业务 Bucket 分离),ossutil 使用。 diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 1938142..ec59a67 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -29,6 +29,7 @@ type Config struct { Realname RealnameConfig Log LogConfig RateLimit RateLimitConfig + BackupStatusFile string } type StorageConfig struct { @@ -130,6 +131,7 @@ func Load() Config { Enabled: getEnvBool("RATE_LIMIT_ENABLED", true), RequestsPerMinute: getEnvInt("RATE_LIMIT_REQUESTS_PER_MINUTE", 300), }, + BackupStatusFile: getEnv("BACKUP_STATUS_FILE", "/var/run/hfb-backup-status/status.json"), } } diff --git a/backend/internal/modules/backupmonitor/handler.go b/backend/internal/modules/backupmonitor/handler.go new file mode 100644 index 0000000..b9517eb --- /dev/null +++ b/backend/internal/modules/backupmonitor/handler.go @@ -0,0 +1,59 @@ +package backupmonitor + +import ( + "encoding/json" + "errors" + "net/http" + "os" + + "hfb_sys/backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +type Status struct { + GeneratedAt string `json:"generated_at"` + Health string `json:"health"` + Schedule struct { + Enabled string `json:"enabled"` + FullHour string `json:"full_hour"` + FullMinute string `json:"full_minute"` + BinlogIntervalMinutes string `json:"binlog_interval_minutes"` + } `json:"schedule"` + LastFull struct { + LocalComplete string `json:"local_complete"` + RemoteComplete string `json:"remote_complete"` + } `json:"last_full"` + LastBinlog struct { + File string `json:"file"` + RemoteDir string `json:"remote_dir"` + } `json:"last_binlog"` + LastJob struct { + Type string `json:"type"` + Status string `json:"status"` + At string `json:"at"` + Message string `json:"message"` + } `json:"last_job"` +} + +type Handler struct{ path string } + +func NewHandler(path string) *Handler { return &Handler{path: path} } + +func (h *Handler) Status(c *gin.Context) { + content, err := os.ReadFile(h.path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + response.Error(c, http.StatusServiceUnavailable, "backup_status_unavailable", "备份状态尚未生成") + return + } + response.Error(c, http.StatusInternalServerError, "backup_status_unavailable", "无法读取备份状态") + return + } + var status Status + if err := json.Unmarshal(content, &status); err != nil { + response.Error(c, http.StatusServiceUnavailable, "backup_status_invalid", "备份状态文件格式无效") + return + } + response.OK(c, status) +} diff --git a/backend/internal/modules/systemconfig/dto.go b/backend/internal/modules/systemconfig/dto.go index 1523fcc..88534e4 100644 --- a/backend/internal/modules/systemconfig/dto.go +++ b/backend/internal/modules/systemconfig/dto.go @@ -17,6 +17,14 @@ type UpdateRequest struct { Description string `json:"description"` } +// BackupScheduleDTO 仅描述调度时间,不包含备份目录、OSS 地址或任何密钥。 +type BackupScheduleDTO struct { + Enabled bool `json:"enabled"` + FullHour int `json:"full_hour"` + FullMinute int `json:"full_minute"` + BinlogIntervalMinutes int `json:"binlog_interval_minutes"` +} + type HomeAnnouncementsDTO struct { Items []string `json:"items"` } diff --git a/backend/internal/modules/systemconfig/handler.go b/backend/internal/modules/systemconfig/handler.go index fb476ee..c4829fa 100644 --- a/backend/internal/modules/systemconfig/handler.go +++ b/backend/internal/modules/systemconfig/handler.go @@ -110,6 +110,34 @@ func (h *Handler) Update(c *gin.Context) { response.OK(c, item) } +func (h *Handler) BackupSchedule(c *gin.Context) { + schedule, err := h.service.BackupSchedule(c.Request.Context()) + if err != nil { + writeConfigError(c, err) + return + } + response.OK(c, schedule) +} + +func (h *Handler) UpdateBackupSchedule(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + var req BackupScheduleDTO + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "备份定时配置格式无效") + return + } + schedule, err := h.service.UpdateBackupSchedule(c.Request.Context(), adminID, req, auditMeta(c)) + if err != nil { + writeConfigError(c, err) + return + } + response.OK(c, schedule) +} + func auditMeta(c *gin.Context) AuditMeta { return AuditMeta{ IP: c.ClientIP(), diff --git a/backend/internal/modules/systemconfig/repository.go b/backend/internal/modules/systemconfig/repository.go index a9e29ba..fa0ad13 100644 --- a/backend/internal/modules/systemconfig/repository.go +++ b/backend/internal/modules/systemconfig/repository.go @@ -51,6 +51,10 @@ var defaultConfigs = []defaultConfig{ {Key: cooperationFeedbackConfigKey, Value: defaultCooperationFeedbackConfigValue(), Description: "首页 合作与反馈入口配置 JSON(标题/副标题/展示图片)"}, {Key: publishOptionsConfigKey, Value: defaultPublishOptionsConfigValue(), Description: "发布页选项配置 JSON"}, {Key: salePriceConfigKey, Value: defaultSalePriceConfigValue(), Description: "内部出售定价规则 JSON"}, + {Key: "backup.schedule_enabled", Value: "true", Description: "在线备份定时任务是否启用"}, + {Key: "backup.full_hour", Value: "4", Description: "每日全量备份小时(Asia/Shanghai)"}, + {Key: "backup.full_minute", Value: "30", Description: "每日全量备份分钟(Asia/Shanghai)"}, + {Key: "backup.binlog_interval_minutes", Value: "1", Description: "binlog 归档间隔分钟(1-5)"}, } var adminVisibleConfigKeys = []string{ diff --git a/backend/internal/modules/systemconfig/service.go b/backend/internal/modules/systemconfig/service.go index 5e2758a..30297bd 100644 --- a/backend/internal/modules/systemconfig/service.go +++ b/backend/internal/modules/systemconfig/service.go @@ -3,6 +3,7 @@ package systemconfig import ( "context" "errors" + "strconv" ) var ( @@ -24,3 +25,31 @@ func (s *Service) List(ctx context.Context) ([]ConfigDTO, error) { } return s.repo.List(ctx) } + +func (s *Service) BackupSchedule(ctx context.Context) (*BackupScheduleDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + values := map[string]string{} + for _, item := range defaultConfigs { + if item.Key == "backup.schedule_enabled" || item.Key == "backup.full_hour" || item.Key == "backup.full_minute" || item.Key == "backup.binlog_interval_minutes" { + values[item.Key] = item.Value + } + } + for key := range values { + value, err := s.repo.FindValue(ctx, key) + if err != nil { + return nil, err + } + values[key] = value + } + hour, _ := strconv.Atoi(values["backup.full_hour"]) + minute, _ := strconv.Atoi(values["backup.full_minute"]) + interval, _ := strconv.Atoi(values["backup.binlog_interval_minutes"]) + return &BackupScheduleDTO{ + Enabled: values["backup.schedule_enabled"] == "true", + FullHour: hour, + FullMinute: minute, + BinlogIntervalMinutes: interval, + }, nil +} diff --git a/backend/internal/modules/systemconfig/service_update.go b/backend/internal/modules/systemconfig/service_update.go index 6f401f5..d6549f8 100644 --- a/backend/internal/modules/systemconfig/service_update.go +++ b/backend/internal/modules/systemconfig/service_update.go @@ -12,6 +12,10 @@ func (s *Service) Update(ctx context.Context, actorID uint64, key string, req Up if s.repo == nil { return nil, ErrDependencyUnavailable } + // 备份定时只能通过独立接口修改,避免拥有普通系统配置权限的管理员改变备份策略。 + if strings.HasPrefix(key, "backup.") { + return nil, ErrInvalidConfig + } if key == "" || (req.Value == "" && key != "integration.paddle_ocr_token") { return nil, ErrInvalidConfig } @@ -34,3 +38,25 @@ func (s *Service) Update(ctx context.Context, actorID uint64, key string, req Up } return s.repo.Update(ctx, actorID, key, req, meta) } + +func (s *Service) UpdateBackupSchedule(ctx context.Context, actorID uint64, req BackupScheduleDTO, meta AuditMeta) (*BackupScheduleDTO, error) { + if s.repo == nil || req.FullHour < 0 || req.FullHour > 23 || req.FullMinute < 0 || req.FullMinute > 59 || req.BinlogIntervalMinutes < 1 || req.BinlogIntervalMinutes > 5 { + return nil, ErrInvalidConfig + } + updates := []struct { + key string + value string + description string + }{ + {"backup.schedule_enabled", strconv.FormatBool(req.Enabled), "在线备份定时任务是否启用"}, + {"backup.full_hour", strconv.Itoa(req.FullHour), "每日全量备份小时(Asia/Shanghai)"}, + {"backup.full_minute", strconv.Itoa(req.FullMinute), "每日全量备份分钟(Asia/Shanghai)"}, + {"backup.binlog_interval_minutes", strconv.Itoa(req.BinlogIntervalMinutes), "binlog 归档间隔分钟(1-5)"}, + } + for _, item := range updates { + if _, err := s.repo.Update(ctx, actorID, item.key, UpdateRequest{Value: item.value, Description: item.description}, meta); err != nil { + return nil, err + } + } + return &req, nil +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 4504235..99c74a7 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -19,6 +19,7 @@ import ( "hfb_sys/backend/internal/modules/adminuser" "hfb_sys/backend/internal/modules/announcement" "hfb_sys/backend/internal/modules/auth" + "hfb_sys/backend/internal/modules/backupmonitor" "hfb_sys/backend/internal/modules/chat" "hfb_sys/backend/internal/modules/chathub" "hfb_sys/backend/internal/modules/dispute" @@ -308,6 +309,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { } systemConfigService := systemconfig.NewService(systemConfigRepo) systemConfigHandler := systemconfig.NewHandler(systemConfigService) + backupMonitorHandler := backupmonitor.NewHandler(cfg.BackupStatusFile) var adminRoleRepo *adminrole.Repository if deps.DB != nil { adminRoleRepo = adminrole.NewRepository(deps.DB, deps.Redis) @@ -694,6 +696,9 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.GET("/system-configs", requirePerm("system_config:view"), systemConfigHandler.List) adminRoutes.PUT("/system-configs/:key", requirePerm("system_config:update"), systemConfigHandler.Update) + adminRoutes.GET("/backups/status", requirePerm("backup:view"), backupMonitorHandler.Status) + adminRoutes.GET("/backups/schedule", requirePerm("backup:view"), systemConfigHandler.BackupSchedule) + adminRoutes.PUT("/backups/schedule", requirePerm("backup:schedule:update"), systemConfigHandler.UpdateBackupSchedule) adminRoutes.GET("/notifications", requirePerm("notification:view"), adminNotificationHandler.List) adminRoutes.GET("/notifications/unread-count", requirePerm("notification:view"), adminNotificationHandler.UnreadCount) adminRoutes.PUT("/notifications/read-all", requirePerm("notification:view"), adminNotificationHandler.MarkAllRead) diff --git a/backend/migrations/000050_backup_monitor_permissions.sql b/backend/migrations/000050_backup_monitor_permissions.sql new file mode 100644 index 0000000..fe6c166 --- /dev/null +++ b/backend/migrations/000050_backup_monitor_permissions.sql @@ -0,0 +1,35 @@ +-- +goose Up + +INSERT INTO permissions (code, name, resource, action) VALUES +('backup:view', '查看备份状态', 'backup', 'view'), +('backup:schedule:update', '修改备份定时', 'backup', 'schedule_update') +ON DUPLICATE KEY UPDATE + name = VALUES(name), + resource = VALUES(resource), + action = VALUES(action); + +INSERT IGNORE INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id FROM roles r, permissions p +WHERE r.code = 'super_admin' AND p.code IN ('backup:view', 'backup:schedule:update'); + +INSERT INTO system_configs (`key`, `value`, description) VALUES +('backup.schedule_enabled', 'true', '在线备份定时任务是否启用'), +('backup.full_hour', '4', '每日全量备份小时(Asia/Shanghai)'), +('backup.full_minute', '30', '每日全量备份分钟(Asia/Shanghai)'), +('backup.binlog_interval_minutes', '1', 'binlog 归档间隔分钟(1-5)') +ON DUPLICATE KEY UPDATE description = VALUES(description); + +-- +goose Down + +DELETE rp FROM role_permissions rp +JOIN permissions p ON p.id = rp.permission_id +WHERE p.code IN ('backup:view', 'backup:schedule:update'); + +DELETE FROM permissions WHERE code IN ('backup:view', 'backup:schedule:update'); + +DELETE FROM system_configs WHERE `key` IN ( + 'backup.schedule_enabled', + 'backup.full_hour', + 'backup.full_minute', + 'backup.binlog_interval_minutes' +); diff --git a/deploy/README.md b/deploy/README.md index 6aabea9..bc3e37d 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -37,6 +37,7 @@ STORAGE_SECRET_ACCESS_KEY BACKEND_UID BACKEND_GID BACKUP_DIR +BACKUP_STATUS_DIR BACKUP_RESTORE_DIR BACKUP_OSS_URI BACKUP_PASSPHRASE @@ -92,15 +93,23 @@ RAM 凭证只授予该 Bucket 的列举、读取、写入对象权限,不要 ./scripts/backup-online.sh prune ``` +后台“数据备份”页面只读取 `${BACKUP_STATUS_DIR}/status.json`;该目录以只读方式挂载给 backend,不包含备份载荷。首次部署后先生成状态摘要: + +```bash +./scripts/backup-status.sh write +``` + 推荐 cron(以部署用户运行,并把日志写到受限目录): ```cron -* * * * * cd /srv/hfb_sys && ./scripts/archive-binlog.sh archive >> /data/backups/archive-binlog.log 2>&1 -30 4 * * * cd /srv/hfb_sys && ./scripts/backup-online.sh full >> /data/backups/full-backup.log 2>&1 +# 每分钟读取后台定时配置,按需归档 binlog 和执行全量热备。 +* * * * * cd /srv/hfb_sys && ./scripts/backup-scheduler.sh >> /data/backups/backup-scheduler.log 2>&1 15 5 * * * cd /srv/hfb_sys && ./scripts/backup-online.sh prune >> /data/backups/prune.log 2>&1 10 5 * * 0 cd /srv/hfb_sys && ./scripts/archive-binlog.sh verify >> /data/backups/archive-verify.log 2>&1 ``` +全量备份时间、是否启用定时任务和 binlog 间隔在后台“数据备份”页面配置;宿主机 cron 必须保留每分钟的调度器入口。不要将 Docker socket、备份目录或恢复操作暴露给 backend。 + OSS 生命周期建议:`mysql/full/daily/` 30 天、`weekly/` 60 天、`monthly/` 365 天、`mysql/binlog/` 至少 45 天。时间点恢复窗口受最早全量备份和 binlog 保留期共同限制。 每周必须在隔离目录做一次恢复演练,绝不向生产容器回放: diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index 4553324..cfa5af2 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -87,6 +87,8 @@ services: TZ: Asia/Shanghai volumes: - ../backend/logs:/app/logs + # 仅挂载备份状态摘要,后端无法读取备份文件、Docker socket 或 OSS 凭证文件。 + - ${BACKUP_STATUS_DIR:-/data/backups/status}:/var/run/hfb-backup-status:ro deploy: resources: limits: diff --git a/frontend/src/features/admin/api/backup.ts b/frontend/src/features/admin/api/backup.ts new file mode 100644 index 0000000..96aa19d --- /dev/null +++ b/frontend/src/features/admin/api/backup.ts @@ -0,0 +1,32 @@ +import { apiClient } from '@/shared/api/client' +import type { ApiResponse } from '@/shared/types/types' + +export interface BackupSchedule { + enabled: boolean + full_hour: number + full_minute: number + binlog_interval_minutes: number +} + +export interface BackupStatus { + generated_at: string + health: 'healthy' | 'warning' + last_full: { local_complete: string; remote_complete: string } + last_binlog: { file: string; remote_dir: string } + last_job: { type: string; status: string; at: string; message: string } +} + +export async function fetchBackupStatus() { + const { data } = await apiClient.get>('/admin/backups/status') + return data.data +} + +export async function fetchBackupSchedule() { + const { data } = await apiClient.get>('/admin/backups/schedule') + return data.data +} + +export async function updateBackupSchedule(payload: BackupSchedule) { + const { data } = await apiClient.put>('/admin/backups/schedule', payload) + return data.data +} diff --git a/frontend/src/features/admin/views/AdminBackupView.vue b/frontend/src/features/admin/views/AdminBackupView.vue new file mode 100644 index 0000000..13f0d9a --- /dev/null +++ b/frontend/src/features/admin/views/AdminBackupView.vue @@ -0,0 +1,141 @@ + + + + + diff --git a/frontend/src/layouts/AdminLayout.vue b/frontend/src/layouts/AdminLayout.vue index d659b8d..08e7a4a 100644 --- a/frontend/src/layouts/AdminLayout.vue +++ b/frontend/src/layouts/AdminLayout.vue @@ -219,6 +219,12 @@ const allNavGroups: NavGroup[] = [ icon: Operation, permission: 'system_config:view', }, + { + label: '数据备份', + to: adminPath('backups'), + icon: DocumentChecked, + permission: 'backup:view', + }, ], }, { diff --git a/frontend/src/router/adminRoutes.ts b/frontend/src/router/adminRoutes.ts index 99da303..0d3b7d1 100644 --- a/frontend/src/router/adminRoutes.ts +++ b/frontend/src/router/adminRoutes.ts @@ -162,6 +162,12 @@ export const adminRoutes: RouteRecordRaw[] = [ component: () => import('@/features/admin/views/AdminSystemConfigsView.vue'), meta: adminMeta, }, + { + path: adminPath('backups'), + name: 'admin-backups', + component: () => import('@/features/admin/views/AdminBackupView.vue'), + meta: adminMeta, + }, { path: adminPath('payment-configs'), name: 'admin-payment-configs', diff --git a/scripts/backup-scheduler.sh b/scripts/backup-scheduler.sh new file mode 100755 index 0000000..bdb168d --- /dev/null +++ b/scripts/backup-scheduler.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +# 由宿主机 cron 每分钟调用。调度配置来自后台 system_configs,实际执行仍在宿主机, +# Web 后端不接触 Docker socket、备份载荷或客户端加密密码。 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=backup-common.sh +source "${SCRIPT_DIR}/backup-common.sh" + +config_value() { + local key="$1" fallback="$2" value + value="$(backup_mysql_root_query "SELECT value FROM system_configs WHERE \`key\` = '${key}' LIMIT 1;" 2>/dev/null || true)" + value="${value//$'\n'/}" + printf '%s' "${value:-${fallback}}" +} + +write_job_state() { + local status_dir="$1" type="$2" status="$3" message="$4" + mkdir -p "${status_dir}" + { + printf 'job_type=%q\n' "${type}" + printf 'job_status=%q\n' "${status}" + printf 'job_at=%q\n' "$(date -Iseconds)" + printf 'job_message=%q\n' "${message}" + } > "${status_dir}/last-job.env" + chmod 600 "${status_dir}/last-job.env" +} + +run_job() { + local status_dir="$1" type="$2" script="$3" + shift 3 + if "${script}" "$@"; then + write_job_state "${status_dir}" "${type}" success '执行成功' + "${SCRIPT_DIR}/backup-status.sh" write || true + return + fi + local code=$? + write_job_state "${status_dir}" "${type}" failed "执行失败(退出码 ${code})" + "${SCRIPT_DIR}/backup-status.sh" write || true + return "${code}" +} + +main() { + backup_require_cmd docker + backup_require_cmd flock + local backup_dir status_dir enabled hour minute interval now_hour now_minute + backup_dir="$(backup_require_env BACKUP_DIR)" + status_dir="${BACKUP_STATUS_DIR:-${backup_dir}/status}" + backup_validate_dir "${status_dir}" + backup_lock_or_exit "${backup_dir}/.scheduler.lock" + + enabled="$(config_value 'backup.schedule_enabled' 'true')" + hour="$(config_value 'backup.full_hour' '4')" + minute="$(config_value 'backup.full_minute' '30')" + interval="$(config_value 'backup.binlog_interval_minutes' '1')" + [[ "${enabled}" == true || "${enabled}" == false ]] || { backup_error 'backup.schedule_enabled 必须为 true 或 false'; exit 1; } + [[ "${hour}" =~ ^[0-9]+$ && "${hour}" -le 23 && "${minute}" =~ ^[0-9]+$ && "${minute}" -le 59 ]] || { + backup_error '备份全量时间配置无效' + exit 1 + } + [[ "${interval}" =~ ^[0-9]+$ && "${interval}" -ge 1 && "${interval}" -le 5 ]] || { + backup_error 'binlog 归档间隔必须为 1-5 分钟' + exit 1 + } + if [[ "${enabled}" == false ]]; then + "${SCRIPT_DIR}/backup-status.sh" write || true + exit 0 + fi + + now_hour="$((10#$(date +%H)))" + now_minute="$((10#$(date +%M)))" + if ((now_minute % interval == 0)); then + run_job "${status_dir}" binlog "${SCRIPT_DIR}/archive-binlog.sh" archive + fi + if ((now_hour == hour && now_minute == minute)); then + run_job "${status_dir}" full "${SCRIPT_DIR}/backup-online.sh" full + fi + "${SCRIPT_DIR}/backup-status.sh" write || true +} + +main "$@" diff --git a/scripts/backup-status.sh b/scripts/backup-status.sh new file mode 100755 index 0000000..24e6917 --- /dev/null +++ b/scripts/backup-status.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +# 生成供后台只读展示的备份状态摘要。内容不包含密钥、备份载荷或下载地址。 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=backup-common.sh +source "${SCRIPT_DIR}/backup-common.sh" + +json_escape() { + local value="$1" + value="${value//\\/\\\\}" + value="${value//\"/\\\"}" + value="${value//$'\n'/ }" + value="${value//$'\r'/ }" + printf '%s' "${value}" +} + +config_value() { + local key="$1" fallback="$2" value + value="$(backup_mysql_root_query "SELECT value FROM system_configs WHERE \`key\` = '${key}' LIMIT 1;" 2>/dev/null || true)" + value="${value//$'\n'/}" + printf '%s' "${value:-${fallback}}" +} + +write_status() { + backup_require_cmd docker + backup_require_cmd ossutil + + local backup_dir oss_uri status_dir status_file temp_file enabled hour minute interval + local local_complete remote_complete binlog_state binlog_file binlog_remote job_type job_status job_at job_message health + backup_dir="$(backup_require_env BACKUP_DIR)" + oss_uri="$(backup_require_env BACKUP_OSS_URI)" + status_dir="${BACKUP_STATUS_DIR:-${backup_dir}/status}" + backup_validate_dir "${status_dir}" + mkdir -p "${status_dir}" + chmod 700 "${status_dir}" + status_file="${status_dir}/status.json" + temp_file="${status_dir}/.status-$$.json" + + enabled="$(config_value 'backup.schedule_enabled' 'true')" + hour="$(config_value 'backup.full_hour' '4')" + minute="$(config_value 'backup.full_minute' '30')" + interval="$(config_value 'backup.binlog_interval_minutes' '1')" + local_complete="$(find "${backup_dir}/full/daily" -name complete.env -type f -print 2>/dev/null | sort | tail -1 || true)" + remote_complete="$(ossutil ls "${oss_uri}/mysql/full/daily/" -r 2>/dev/null | awk '$NF ~ /\/complete\.env$/ { latest = $NF } END { print latest }')" + binlog_state="$(find "${backup_dir}/binlog-state" -name last-archived.env -type f -print 2>/dev/null | sort | tail -1 || true)" + binlog_file='' + binlog_remote='' + if [[ -n "${binlog_state}" ]]; then + binlog_file="$(awk -F= '$1 == "filename" { print substr($0, index($0, "=") + 1); exit }' "${binlog_state}")" + binlog_remote="$(awk -F= '$1 == "remote_dir" { print substr($0, index($0, "=") + 1); exit }' "${binlog_state}")" + fi + + job_type='' + job_status='' + job_at='' + job_message='' + if [[ -f "${status_dir}/last-job.env" ]]; then + # shellcheck disable=SC1090 + source "${status_dir}/last-job.env" + fi + if [[ -n "${local_complete}" && -n "${remote_complete}" && -n "${binlog_file}" ]]; then + health='healthy' + else + health='warning' + fi + + cat > "${temp_file}" <&2 + exit 1 + ;; +esac diff --git a/scripts/deploy-prod.sh b/scripts/deploy-prod.sh index 51dd780..249eaa9 100755 --- a/scripts/deploy-prod.sh +++ b/scripts/deploy-prod.sh @@ -146,7 +146,7 @@ validate_env() { exit 1 fi - local app_env caddy_domain caddy_email mysql_dsn redis_addr storage_endpoint backend_uid backend_gid backup_dir backup_restore_dir backup_oss_uri backup_user + local app_env caddy_domain caddy_email mysql_dsn redis_addr storage_endpoint backend_uid backend_gid backup_dir backup_status_dir backup_restore_dir backup_oss_uri backup_user app_env="$(require_env APP_ENV)" caddy_domain="$(require_env CADDY_DOMAIN)" caddy_email="$(require_env CADDY_EMAIL)" @@ -162,6 +162,7 @@ validate_env() { require_env STORAGE_ACCESS_KEY_ID >/dev/null require_env STORAGE_SECRET_ACCESS_KEY >/dev/null backup_dir="$(require_env BACKUP_DIR)" + backup_status_dir="$(require_env BACKUP_STATUS_DIR)" backup_restore_dir="$(require_env BACKUP_RESTORE_DIR)" backup_oss_uri="$(require_env BACKUP_OSS_URI)" require_env BACKUP_PASSPHRASE >/dev/null @@ -216,6 +217,10 @@ validate_env() { log_error "BACKUP_DIR 必须是 /data 之下的具体绝对路径,当前值:${backup_dir}" exit 1 fi + if [[ "${backup_status_dir}" != "${backup_dir}/"* ]]; then + log_error "BACKUP_STATUS_DIR 必须位于 BACKUP_DIR 之下,当前值:${backup_status_dir}" + exit 1 + fi if [[ "${backup_restore_dir}" != /data/* || "${backup_restore_dir}" == "/data" || "${backup_restore_dir}" == "${backup_dir}" ]]; then log_error "BACKUP_RESTORE_DIR 必须是与 BACKUP_DIR 不同的 /data 子目录,当前值:${backup_restore_dir}" exit 1 @@ -403,6 +408,24 @@ prepare_log_dir() { chmod 700 "${BACKEND_LOG_DIR}" } +prepare_backup_status_dir() { + local backend_uid backend_gid status_dir owner_mismatch + backend_uid="$(require_env BACKEND_UID)" + backend_gid="$(require_env BACKEND_GID)" + status_dir="$(require_env BACKUP_STATUS_DIR)" + mkdir -p "${status_dir}" + owner_mismatch="$(find "${status_dir}" \( ! -uid "${backend_uid}" -o ! -gid "${backend_gid}" \) -print -quit)" + if [[ -n "${owner_mismatch}" ]]; then + if [[ "${EUID}" -ne 0 ]]; then + log_error "备份状态目录存在属主不匹配的文件:${owner_mismatch}" + log_error "请先执行:sudo chown -R ${backend_uid}:${backend_gid} ${status_dir}" + exit 1 + fi + chown -R "${backend_uid}:${backend_gid}" "${status_dir}" + fi + chmod 700 "${status_dir}" +} + wait_service_healthy() { local service="$1" local label="$2" @@ -557,6 +580,7 @@ main() { validate_env export_caddy_env prepare_log_dir + prepare_backup_status_dir # 选号网需在 build caddy 之前准备 show-dist 与 conf.d prepare_show_site