新增备份监控与定时配置
This commit is contained in:
@@ -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 使用。
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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_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 保留期共同限制。
|
||||
|
||||
每周必须在隔离目录做一次恢复演练,绝不向生产容器回放:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
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'),
|
||||
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',
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user