新增备份监控与定时配置

This commit is contained in:
yml2213
2026-08-16 23:53:50 +08:00
parent 66cb03ebce
commit f67ee12d72
19 changed files with 612 additions and 3 deletions
+4
View File
@@ -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 使用。
+2
View File
@@ -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
}
+5
View File
@@ -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
View File
@@ -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 保留期共同限制。
每周必须在隔离目录做一次恢复演练,绝不向生产容器回放: 每周必须在隔离目录做一次恢复演练,绝不向生产容器回放:
+2
View File
@@ -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:
+32
View File
@@ -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>
+6
View File
@@ -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',
},
], ],
}, },
{ {
+6
View File
@@ -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',
+83
View File
@@ -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 "$@"
+106
View File
@@ -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
View File
@@ -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