新增备份监控与定时配置
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'
|
||||
);
|
||||
Reference in New Issue
Block a user