新增备份监控与定时配置
This commit is contained in:
@@ -103,6 +103,10 @@ EXTERNAL_UPLOAD_ALLOWED_IPS=
|
|||||||
# 在线备份(scripts/backup-online.sh / scripts/archive-binlog.sh)。所有项为生产必填。
|
# 在线备份(scripts/backup-online.sh / scripts/archive-binlog.sh)。所有项为生产必填。
|
||||||
# 本地备份根目录,必须是独立数据盘上的 /data 子目录;包含加密全量备份与 binlog 状态。
|
# 本地备份根目录,必须是独立数据盘上的 /data 子目录;包含加密全量备份与 binlog 状态。
|
||||||
BACKUP_DIR=/data/backups
|
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_DIR 不同;prepare 后的 MySQL 原始数据只放在这里。
|
||||||
BACKUP_RESTORE_DIR=/data/restore
|
BACKUP_RESTORE_DIR=/data/restore
|
||||||
# 备份专用 OSS Bucket(与业务 Bucket 分离),ossutil 使用。
|
# 备份专用 OSS Bucket(与业务 Bucket 分离),ossutil 使用。
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ type Config struct {
|
|||||||
Realname RealnameConfig
|
Realname RealnameConfig
|
||||||
Log LogConfig
|
Log LogConfig
|
||||||
RateLimit RateLimitConfig
|
RateLimit RateLimitConfig
|
||||||
|
BackupStatusFile string
|
||||||
}
|
}
|
||||||
|
|
||||||
type StorageConfig struct {
|
type StorageConfig struct {
|
||||||
@@ -130,6 +131,7 @@ func Load() Config {
|
|||||||
Enabled: getEnvBool("RATE_LIMIT_ENABLED", true),
|
Enabled: getEnvBool("RATE_LIMIT_ENABLED", true),
|
||||||
RequestsPerMinute: getEnvInt("RATE_LIMIT_REQUESTS_PER_MINUTE", 300),
|
RequestsPerMinute: getEnvInt("RATE_LIMIT_REQUESTS_PER_MINUTE", 300),
|
||||||
},
|
},
|
||||||
|
BackupStatusFile: getEnv("BACKUP_STATUS_FILE", "/var/run/hfb-backup-status/status.json"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -17,6 +17,14 @@ type UpdateRequest struct {
|
|||||||
Description string `json:"description"`
|
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 {
|
type HomeAnnouncementsDTO struct {
|
||||||
Items []string `json:"items"`
|
Items []string `json:"items"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,6 +110,34 @@ func (h *Handler) Update(c *gin.Context) {
|
|||||||
response.OK(c, item)
|
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 {
|
func auditMeta(c *gin.Context) AuditMeta {
|
||||||
return AuditMeta{
|
return AuditMeta{
|
||||||
IP: c.ClientIP(),
|
IP: c.ClientIP(),
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ var defaultConfigs = []defaultConfig{
|
|||||||
{Key: cooperationFeedbackConfigKey, Value: defaultCooperationFeedbackConfigValue(), Description: "首页 合作与反馈入口配置 JSON(标题/副标题/展示图片)"},
|
{Key: cooperationFeedbackConfigKey, Value: defaultCooperationFeedbackConfigValue(), Description: "首页 合作与反馈入口配置 JSON(标题/副标题/展示图片)"},
|
||||||
{Key: publishOptionsConfigKey, Value: defaultPublishOptionsConfigValue(), Description: "发布页选项配置 JSON"},
|
{Key: publishOptionsConfigKey, Value: defaultPublishOptionsConfigValue(), Description: "发布页选项配置 JSON"},
|
||||||
{Key: salePriceConfigKey, Value: defaultSalePriceConfigValue(), 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{
|
var adminVisibleConfigKeys = []string{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package systemconfig
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -24,3 +25,31 @@ func (s *Service) List(ctx context.Context) ([]ConfigDTO, error) {
|
|||||||
}
|
}
|
||||||
return s.repo.List(ctx)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ func (s *Service) Update(ctx context.Context, actorID uint64, key string, req Up
|
|||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
}
|
}
|
||||||
|
// 备份定时只能通过独立接口修改,避免拥有普通系统配置权限的管理员改变备份策略。
|
||||||
|
if strings.HasPrefix(key, "backup.") {
|
||||||
|
return nil, ErrInvalidConfig
|
||||||
|
}
|
||||||
if key == "" || (req.Value == "" && key != "integration.paddle_ocr_token") {
|
if key == "" || (req.Value == "" && key != "integration.paddle_ocr_token") {
|
||||||
return nil, ErrInvalidConfig
|
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)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"hfb_sys/backend/internal/modules/adminuser"
|
"hfb_sys/backend/internal/modules/adminuser"
|
||||||
"hfb_sys/backend/internal/modules/announcement"
|
"hfb_sys/backend/internal/modules/announcement"
|
||||||
"hfb_sys/backend/internal/modules/auth"
|
"hfb_sys/backend/internal/modules/auth"
|
||||||
|
"hfb_sys/backend/internal/modules/backupmonitor"
|
||||||
"hfb_sys/backend/internal/modules/chat"
|
"hfb_sys/backend/internal/modules/chat"
|
||||||
"hfb_sys/backend/internal/modules/chathub"
|
"hfb_sys/backend/internal/modules/chathub"
|
||||||
"hfb_sys/backend/internal/modules/dispute"
|
"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)
|
systemConfigService := systemconfig.NewService(systemConfigRepo)
|
||||||
systemConfigHandler := systemconfig.NewHandler(systemConfigService)
|
systemConfigHandler := systemconfig.NewHandler(systemConfigService)
|
||||||
|
backupMonitorHandler := backupmonitor.NewHandler(cfg.BackupStatusFile)
|
||||||
var adminRoleRepo *adminrole.Repository
|
var adminRoleRepo *adminrole.Repository
|
||||||
if deps.DB != nil {
|
if deps.DB != nil {
|
||||||
adminRoleRepo = adminrole.NewRepository(deps.DB, deps.Redis)
|
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.GET("/system-configs", requirePerm("system_config:view"), systemConfigHandler.List)
|
||||||
adminRoutes.PUT("/system-configs/:key", requirePerm("system_config:update"), systemConfigHandler.Update)
|
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", requirePerm("notification:view"), adminNotificationHandler.List)
|
||||||
adminRoutes.GET("/notifications/unread-count", requirePerm("notification:view"), adminNotificationHandler.UnreadCount)
|
adminRoutes.GET("/notifications/unread-count", requirePerm("notification:view"), adminNotificationHandler.UnreadCount)
|
||||||
adminRoutes.PUT("/notifications/read-all", requirePerm("notification:view"), adminNotificationHandler.MarkAllRead)
|
adminRoutes.PUT("/notifications/read-all", requirePerm("notification:view"), adminNotificationHandler.MarkAllRead)
|
||||||
|
|||||||
@@ -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'
|
||||||
|
);
|
||||||
+11
-2
@@ -37,6 +37,7 @@ STORAGE_SECRET_ACCESS_KEY
|
|||||||
BACKEND_UID
|
BACKEND_UID
|
||||||
BACKEND_GID
|
BACKEND_GID
|
||||||
BACKUP_DIR
|
BACKUP_DIR
|
||||||
|
BACKUP_STATUS_DIR
|
||||||
BACKUP_RESTORE_DIR
|
BACKUP_RESTORE_DIR
|
||||||
BACKUP_OSS_URI
|
BACKUP_OSS_URI
|
||||||
BACKUP_PASSPHRASE
|
BACKUP_PASSPHRASE
|
||||||
@@ -92,15 +93,23 @@ RAM 凭证只授予该 Bucket 的列举、读取、写入对象权限,不要
|
|||||||
./scripts/backup-online.sh prune
|
./scripts/backup-online.sh prune
|
||||||
```
|
```
|
||||||
|
|
||||||
|
后台“数据备份”页面只读取 `${BACKUP_STATUS_DIR}/status.json`;该目录以只读方式挂载给 backend,不包含备份载荷。首次部署后先生成状态摘要:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/backup-status.sh write
|
||||||
|
```
|
||||||
|
|
||||||
推荐 cron(以部署用户运行,并把日志写到受限目录):
|
推荐 cron(以部署用户运行,并把日志写到受限目录):
|
||||||
|
|
||||||
```cron
|
```cron
|
||||||
* * * * * cd /srv/hfb_sys && ./scripts/archive-binlog.sh archive >> /data/backups/archive-binlog.log 2>&1
|
# 每分钟读取后台定时配置,按需归档 binlog 和执行全量热备。
|
||||||
30 4 * * * cd /srv/hfb_sys && ./scripts/backup-online.sh full >> /data/backups/full-backup.log 2>&1
|
* * * * * 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
|
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
|
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 保留期共同限制。
|
OSS 生命周期建议:`mysql/full/daily/` 30 天、`weekly/` 60 天、`monthly/` 365 天、`mysql/binlog/` 至少 45 天。时间点恢复窗口受最早全量备份和 binlog 保留期共同限制。
|
||||||
|
|
||||||
每周必须在隔离目录做一次恢复演练,绝不向生产容器回放:
|
每周必须在隔离目录做一次恢复演练,绝不向生产容器回放:
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ services:
|
|||||||
TZ: Asia/Shanghai
|
TZ: Asia/Shanghai
|
||||||
volumes:
|
volumes:
|
||||||
- ../backend/logs:/app/logs
|
- ../backend/logs:/app/logs
|
||||||
|
# 仅挂载备份状态摘要,后端无法读取备份文件、Docker socket 或 OSS 凭证文件。
|
||||||
|
- ${BACKUP_STATUS_DIR:-/data/backups/status}:/var/run/hfb-backup-status:ro
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
|
|||||||
@@ -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<ApiResponse<BackupStatus>>('/admin/backups/status')
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBackupSchedule() {
|
||||||
|
const { data } = await apiClient.get<ApiResponse<BackupSchedule>>('/admin/backups/schedule')
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateBackupSchedule(payload: BackupSchedule) {
|
||||||
|
const { data } = await apiClient.put<ApiResponse<BackupSchedule>>('/admin/backups/schedule', payload)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { RefreshRight } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
|
||||||
|
import {
|
||||||
|
fetchBackupSchedule,
|
||||||
|
fetchBackupStatus,
|
||||||
|
updateBackupSchedule,
|
||||||
|
type BackupStatus,
|
||||||
|
} from '@/features/admin/api/backup'
|
||||||
|
import { useAdminSessionStore } from '@/stores/adminSession'
|
||||||
|
import { formatDateTime } from '@/shared/utils/time'
|
||||||
|
|
||||||
|
const adminSession = useAdminSessionStore()
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const status = ref<BackupStatus | null>(null)
|
||||||
|
const statusError = ref('')
|
||||||
|
const canUpdateSchedule = computed(() => adminSession.isSuperAdmin || adminSession.hasPermission('backup:schedule:update'))
|
||||||
|
const schedule = reactive({ enabled: true, full_hour: 4, full_minute: 30, binlog_interval_minutes: 1 })
|
||||||
|
|
||||||
|
const healthLabel = computed(() => (status.value?.health === 'healthy' ? '链路正常' : '需要检查'))
|
||||||
|
const healthType = computed(() => (status.value?.health === 'healthy' ? 'success' : 'warning'))
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
statusError.value = ''
|
||||||
|
try {
|
||||||
|
const [nextStatus, nextSchedule] = await Promise.all([fetchBackupStatus(), fetchBackupSchedule()])
|
||||||
|
status.value = nextStatus
|
||||||
|
Object.assign(schedule, nextSchedule)
|
||||||
|
} catch (error) {
|
||||||
|
statusError.value = error instanceof Error ? error.message : '无法读取备份状态'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSchedule() {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
Object.assign(schedule, await updateBackupSchedule({ ...schedule }))
|
||||||
|
ElMessage.success('定时配置已保存,宿主机调度器将在下一分钟读取')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section v-loading="loading" class="backup-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1>数据备份</h1>
|
||||||
|
</div>
|
||||||
|
<el-button :loading="loading" circle title="刷新状态" @click="load">
|
||||||
|
<el-icon><RefreshRight /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-alert v-if="statusError" type="warning" :title="statusError" :closable="false" show-icon />
|
||||||
|
|
||||||
|
<div v-else class="status-grid">
|
||||||
|
<el-card shadow="never">
|
||||||
|
<template #header><span>备份健康状态</span></template>
|
||||||
|
<el-tag :type="healthType" effect="light">{{ healthLabel }}</el-tag>
|
||||||
|
<dl>
|
||||||
|
<dt>状态更新时间</dt>
|
||||||
|
<dd>{{ status?.generated_at ? formatDateTime(status.generated_at) : '-' }}</dd>
|
||||||
|
<dt>最近任务</dt>
|
||||||
|
<dd>{{ status?.last_job.type || '-' }} {{ status?.last_job.status || '' }}</dd>
|
||||||
|
<dt>任务时间</dt>
|
||||||
|
<dd>{{ status?.last_job.at ? formatDateTime(status.last_job.at) : '-' }}</dd>
|
||||||
|
</dl>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card shadow="never">
|
||||||
|
<template #header><span>最新全量备份</span></template>
|
||||||
|
<dl>
|
||||||
|
<dt>本地完成标识</dt>
|
||||||
|
<dd class="path">{{ status?.last_full.local_complete || '-' }}</dd>
|
||||||
|
<dt>OSS 完成标识</dt>
|
||||||
|
<dd class="path">{{ status?.last_full.remote_complete || '-' }}</dd>
|
||||||
|
</dl>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card shadow="never">
|
||||||
|
<template #header><span>最新 Binlog 归档</span></template>
|
||||||
|
<dl>
|
||||||
|
<dt>Binlog 文件</dt>
|
||||||
|
<dd>{{ status?.last_binlog.file || '-' }}</dd>
|
||||||
|
<dt>归档对象</dt>
|
||||||
|
<dd class="path">{{ status?.last_binlog.remote_dir || '-' }}</dd>
|
||||||
|
</dl>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-card shadow="never" class="schedule-card">
|
||||||
|
<template #header><span>定时配置</span></template>
|
||||||
|
<el-form label-position="top" class="schedule-form">
|
||||||
|
<el-form-item label="启用服务器定时备份">
|
||||||
|
<el-switch v-model="schedule.enabled" :disabled="!canUpdateSchedule" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="每日全量备份时间(Asia/Shanghai)">
|
||||||
|
<div class="time-inputs">
|
||||||
|
<el-input-number v-model="schedule.full_hour" :min="0" :max="23" :disabled="!canUpdateSchedule" controls-position="right" />
|
||||||
|
<span>:</span>
|
||||||
|
<el-input-number v-model="schedule.full_minute" :min="0" :max="59" :disabled="!canUpdateSchedule" controls-position="right" />
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="Binlog 归档间隔">
|
||||||
|
<el-select v-model="schedule.binlog_interval_minutes" :disabled="!canUpdateSchedule">
|
||||||
|
<el-option :value="1" label="每 1 分钟" />
|
||||||
|
<el-option :value="2" label="每 2 分钟" />
|
||||||
|
<el-option :value="3" label="每 3 分钟" />
|
||||||
|
<el-option :value="5" label="每 5 分钟" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-button v-if="canUpdateSchedule" type="primary" :loading="saving" @click="saveSchedule">保存定时配置</el-button>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.backup-page { padding: 20px; }
|
||||||
|
.page-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 18px; }
|
||||||
|
.page-header h1 { margin: 0; font-size: 22px; font-weight: 600; }
|
||||||
|
.status-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
|
||||||
|
dl { margin: 16px 0 0; display: grid; gap: 8px; }
|
||||||
|
dt { color: var(--el-text-color-secondary); font-size: 13px; }
|
||||||
|
dd { margin: 0; overflow-wrap: anywhere; }
|
||||||
|
.path { color: var(--el-text-color-regular); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
|
||||||
|
.schedule-card { margin-top: 16px; }
|
||||||
|
.schedule-form { display: grid; grid-template-columns: repeat(3, minmax(180px, 260px)); gap: 0 18px; align-items: end; }
|
||||||
|
.time-inputs { display: flex; align-items: center; gap: 8px; }
|
||||||
|
@media (max-width: 900px) { .status-grid, .schedule-form { grid-template-columns: 1fr; } }
|
||||||
|
</style>
|
||||||
@@ -219,6 +219,12 @@ const allNavGroups: NavGroup[] = [
|
|||||||
icon: Operation,
|
icon: Operation,
|
||||||
permission: 'system_config:view',
|
permission: 'system_config:view',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: '数据备份',
|
||||||
|
to: adminPath('backups'),
|
||||||
|
icon: DocumentChecked,
|
||||||
|
permission: 'backup:view',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -162,6 +162,12 @@ export const adminRoutes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/features/admin/views/AdminSystemConfigsView.vue'),
|
component: () => import('@/features/admin/views/AdminSystemConfigsView.vue'),
|
||||||
meta: adminMeta,
|
meta: adminMeta,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: adminPath('backups'),
|
||||||
|
name: 'admin-backups',
|
||||||
|
component: () => import('@/features/admin/views/AdminBackupView.vue'),
|
||||||
|
meta: adminMeta,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: adminPath('payment-configs'),
|
path: adminPath('payment-configs'),
|
||||||
name: 'admin-payment-configs',
|
name: 'admin-payment-configs',
|
||||||
|
|||||||
Executable
+83
@@ -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 "$@"
|
||||||
Executable
+106
@@ -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}" <<EOF
|
||||||
|
{
|
||||||
|
"generated_at": "$(date -Iseconds)",
|
||||||
|
"health": "$(json_escape "${health}")",
|
||||||
|
"schedule": {
|
||||||
|
"enabled": "$(json_escape "${enabled}")",
|
||||||
|
"full_hour": "$(json_escape "${hour}")",
|
||||||
|
"full_minute": "$(json_escape "${minute}")",
|
||||||
|
"binlog_interval_minutes": "$(json_escape "${interval}")"
|
||||||
|
},
|
||||||
|
"last_full": {
|
||||||
|
"local_complete": "$(json_escape "${local_complete}")",
|
||||||
|
"remote_complete": "$(json_escape "${remote_complete}")"
|
||||||
|
},
|
||||||
|
"last_binlog": {
|
||||||
|
"file": "$(json_escape "${binlog_file}")",
|
||||||
|
"remote_dir": "$(json_escape "${binlog_remote}")"
|
||||||
|
},
|
||||||
|
"last_job": {
|
||||||
|
"type": "$(json_escape "${job_type}")",
|
||||||
|
"status": "$(json_escape "${job_status}")",
|
||||||
|
"at": "$(json_escape "${job_at}")",
|
||||||
|
"message": "$(json_escape "${job_message}")"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
chmod 640 "${temp_file}"
|
||||||
|
mv "${temp_file}" "${status_file}"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-write}" in
|
||||||
|
write) write_status ;;
|
||||||
|
*)
|
||||||
|
printf '用法: %s {write}\n' "$0" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
+25
-1
@@ -146,7 +146,7 @@ validate_env() {
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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)"
|
app_env="$(require_env APP_ENV)"
|
||||||
caddy_domain="$(require_env CADDY_DOMAIN)"
|
caddy_domain="$(require_env CADDY_DOMAIN)"
|
||||||
caddy_email="$(require_env CADDY_EMAIL)"
|
caddy_email="$(require_env CADDY_EMAIL)"
|
||||||
@@ -162,6 +162,7 @@ validate_env() {
|
|||||||
require_env STORAGE_ACCESS_KEY_ID >/dev/null
|
require_env STORAGE_ACCESS_KEY_ID >/dev/null
|
||||||
require_env STORAGE_SECRET_ACCESS_KEY >/dev/null
|
require_env STORAGE_SECRET_ACCESS_KEY >/dev/null
|
||||||
backup_dir="$(require_env BACKUP_DIR)"
|
backup_dir="$(require_env BACKUP_DIR)"
|
||||||
|
backup_status_dir="$(require_env BACKUP_STATUS_DIR)"
|
||||||
backup_restore_dir="$(require_env BACKUP_RESTORE_DIR)"
|
backup_restore_dir="$(require_env BACKUP_RESTORE_DIR)"
|
||||||
backup_oss_uri="$(require_env BACKUP_OSS_URI)"
|
backup_oss_uri="$(require_env BACKUP_OSS_URI)"
|
||||||
require_env BACKUP_PASSPHRASE >/dev/null
|
require_env BACKUP_PASSPHRASE >/dev/null
|
||||||
@@ -216,6 +217,10 @@ validate_env() {
|
|||||||
log_error "BACKUP_DIR 必须是 /data 之下的具体绝对路径,当前值:${backup_dir}"
|
log_error "BACKUP_DIR 必须是 /data 之下的具体绝对路径,当前值:${backup_dir}"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
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}"
|
log_error "BACKUP_RESTORE_DIR 必须是与 BACKUP_DIR 不同的 /data 子目录,当前值:${backup_restore_dir}"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -403,6 +408,24 @@ prepare_log_dir() {
|
|||||||
chmod 700 "${BACKEND_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() {
|
wait_service_healthy() {
|
||||||
local service="$1"
|
local service="$1"
|
||||||
local label="$2"
|
local label="$2"
|
||||||
@@ -557,6 +580,7 @@ main() {
|
|||||||
validate_env
|
validate_env
|
||||||
export_caddy_env
|
export_caddy_env
|
||||||
prepare_log_dir
|
prepare_log_dir
|
||||||
|
prepare_backup_status_dir
|
||||||
# 选号网需在 build caddy 之前准备 show-dist 与 conf.d
|
# 选号网需在 build caddy 之前准备 show-dist 与 conf.d
|
||||||
prepare_show_site
|
prepare_show_site
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user