mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-30 06:16:37 +08:00
9550fa6ff3
Replace per-request GetByKey calls with a singleflight-backed RuntimeSettings snapshot loaded via ListSystemConfigsByKeys. Invalidate snapshot on admin cap_* writes and via Redis pub/sub. Simplify VerifyMiddleware and update PERFORMANCE.md status.
435 lines
14 KiB
Go
435 lines
14 KiB
Go
// Copyright 2025 linux.do
|
||
// Copyright 2026 Arctel.net
|
||
// SPDX-License-Identifier: Apache-2.0
|
||
|
||
package system_config
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"gorm.io/gorm"
|
||
|
||
"github.com/Rain-kl/Wavelet/internal/apps/cap"
|
||
"github.com/Rain-kl/Wavelet/internal/apps/upload"
|
||
"github.com/Rain-kl/Wavelet/internal/common/response"
|
||
"github.com/Rain-kl/Wavelet/internal/db"
|
||
"github.com/Rain-kl/Wavelet/internal/model"
|
||
"github.com/Rain-kl/Wavelet/internal/storage"
|
||
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||
mail "github.com/Rain-kl/Wavelet/pkg/mail"
|
||
)
|
||
|
||
const maskedConfigValue = "******"
|
||
|
||
// CreateSystemConfigRequest 创建系统配置请求
|
||
type CreateSystemConfigRequest struct {
|
||
Key string `json:"key" binding:"required,max=64"`
|
||
Value string `json:"value" binding:"required"`
|
||
Type string `json:"type" binding:"required,oneof=system business"`
|
||
Visibility int `json:"visibility" binding:"oneof=0 1"`
|
||
Description string `json:"description" binding:"max=255"`
|
||
}
|
||
|
||
// UpdateSystemConfigRequest 更新系统配置请求
|
||
type UpdateSystemConfigRequest struct {
|
||
Value string `json:"value" binding:"required"`
|
||
Visibility *int `json:"visibility" binding:"omitempty,oneof=0 1"`
|
||
Description string `json:"description" binding:"max=255"`
|
||
}
|
||
|
||
// CreateSystemConfig 创建系统配置
|
||
// @Summary 创建系统配置
|
||
// @Description 创建一条新的系统配置项,配置键不可重复,同时将新配置同步到 Redis,需要管理员权限
|
||
// @Tags admin
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Security SessionCookie
|
||
// @Param request body system_config.CreateSystemConfigRequest true "创建请求参数"
|
||
// @Success 200 {object} response.Any{data=string} "创建成功"
|
||
// @Failure 400 {object} response.Any "参数错误或配置键已存在"
|
||
// @Failure 401 {object} response.Any "未登录"
|
||
// @Failure 403 {object} response.Any "无管理员权限"
|
||
// @Failure 500 {object} response.Any "内部错误"
|
||
// @Router /api/v1/admin/system-configs [post]
|
||
func CreateSystemConfig(c *gin.Context) {
|
||
var req CreateSystemConfigRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, response.Err(err.Error()))
|
||
return
|
||
}
|
||
|
||
// 检查配置键是否已存在
|
||
var existing model.SystemConfig
|
||
if err := db.DB(c.Request.Context()).Where("key = ?", req.Key).First(&existing).Error; err == nil {
|
||
c.JSON(http.StatusBadRequest, response.Err(ConfigKeyExists))
|
||
return
|
||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||
c.JSON(http.StatusInternalServerError, response.Err(err.Error()))
|
||
return
|
||
}
|
||
|
||
config := model.SystemConfig{
|
||
Key: req.Key,
|
||
Value: req.Value,
|
||
Type: req.Type,
|
||
Visibility: req.Visibility,
|
||
Description: req.Description,
|
||
}
|
||
|
||
if err := db.DB(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
|
||
// 创建配置
|
||
if err := tx.Create(&config).Error; err != nil {
|
||
return err
|
||
}
|
||
|
||
return nil
|
||
}); err != nil {
|
||
c.JSON(http.StatusInternalServerError, response.Err(err.Error()))
|
||
return
|
||
}
|
||
|
||
invalidateSystemConfigCaches(c.Request.Context(), req.Key)
|
||
|
||
if err := model.InvalidateVisibleSystemConfigsCache(c.Request.Context()); err != nil {
|
||
logger.WarnF(c.Request.Context(), "清理公共配置列表缓存失败: %v", err)
|
||
}
|
||
|
||
c.JSON(http.StatusOK, response.OKNil())
|
||
}
|
||
|
||
// ListSystemConfigs 获取系统配置列表
|
||
// @Summary 获取系统配置列表
|
||
// @Description 返回所有系统配置列表,支持按配置类型(system/business)过滤,需要管理员权限
|
||
// @Tags admin
|
||
// @Produce json
|
||
// @Security SessionCookie
|
||
// @Param type query string false "配置类型(system/business)"
|
||
// @Success 200 {object} response.Any{data=[]model.SystemConfig} "系统配置列表"
|
||
// @Failure 401 {object} response.Any "未登录"
|
||
// @Failure 403 {object} response.Any "无管理员权限"
|
||
// @Failure 500 {object} response.Any "内部错误"
|
||
// @Router /api/v1/admin/system-configs [get]
|
||
func ListSystemConfigs(c *gin.Context) {
|
||
configType := c.Query("type")
|
||
query := db.DB(c.Request.Context()).Order("created_at DESC")
|
||
if configType != "" {
|
||
query = query.Where("type = ?", configType)
|
||
}
|
||
|
||
var configs []model.SystemConfig
|
||
if err := query.Find(&configs).Error; err != nil {
|
||
c.JSON(http.StatusInternalServerError, response.Err(err.Error()))
|
||
return
|
||
}
|
||
|
||
for i := range configs {
|
||
configs[i].Value = maskSensitiveConfig(configs[i].Key, configs[i].Value)
|
||
}
|
||
|
||
c.JSON(http.StatusOK, response.OK(configs))
|
||
}
|
||
|
||
// GetSystemConfig 获取单个系统配置
|
||
// @Summary 获取单个系统配置
|
||
// @Description 根据配置键获取对应的系统配置详情,需要管理员权限
|
||
// @Tags admin
|
||
// @Produce json
|
||
// @Security SessionCookie
|
||
// @Param key path string true "配置键"
|
||
// @Success 200 {object} response.Any{data=model.SystemConfig} "系统配置详情"
|
||
// @Failure 401 {object} response.Any "未登录"
|
||
// @Failure 403 {object} response.Any "无管理员权限"
|
||
// @Failure 404 {object} response.Any "配置不存在"
|
||
// @Failure 500 {object} response.Any "内部错误"
|
||
// @Router /api/v1/admin/system-configs/{key} [get]
|
||
func GetSystemConfig(c *gin.Context) {
|
||
var config model.SystemConfig
|
||
if err := db.DB(c.Request.Context()).Where("key = ?", c.Param("key")).First(&config).Error; err != nil {
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
c.JSON(http.StatusNotFound, response.Err(SystemConfigNotFound))
|
||
} else {
|
||
c.JSON(http.StatusInternalServerError, response.Err(err.Error()))
|
||
}
|
||
return
|
||
}
|
||
|
||
config.Value = maskSensitiveConfig(config.Key, config.Value)
|
||
|
||
c.JSON(http.StatusOK, response.OK(config))
|
||
}
|
||
|
||
// UpdateSystemConfig 更新系统配置
|
||
// @Summary 更新系统配置
|
||
// @Description 根据配置键更新对应的配置内容,同时将更新同步到 Redis,需要管理员权限
|
||
// @Tags admin
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Security SessionCookie
|
||
// @Param key path string true "配置键"
|
||
// @Param request body system_config.UpdateSystemConfigRequest true "更新请求参数"
|
||
// @Success 200 {object} response.Any{data=string} "更新成功"
|
||
// @Failure 400 {object} response.Any "参数错误"
|
||
// @Failure 401 {object} response.Any "未登录"
|
||
// @Failure 403 {object} response.Any "无管理员权限"
|
||
// @Failure 404 {object} response.Any "配置不存在"
|
||
// @Failure 500 {object} response.Any "内部错误"
|
||
// @Router /api/v1/admin/system-configs/{key} [put]
|
||
func UpdateSystemConfig(c *gin.Context) {
|
||
var req UpdateSystemConfigRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, response.Err(err.Error()))
|
||
return
|
||
}
|
||
|
||
key := c.Param("key")
|
||
|
||
// 检查配置是否存在
|
||
var config model.SystemConfig
|
||
if err := db.DB(c.Request.Context()).Where("key = ?", key).First(&config).Error; err != nil {
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
c.JSON(http.StatusNotFound, response.Err(SystemConfigNotFound))
|
||
} else {
|
||
c.JSON(http.StatusInternalServerError, response.Err(err.Error()))
|
||
}
|
||
return
|
||
}
|
||
|
||
var originalDriver storage.Driver
|
||
if key == model.ConfigKeyStorageConfig {
|
||
var currentCfg storage.Config
|
||
if err := json.Unmarshal([]byte(config.Value), ¤tCfg); err == nil {
|
||
originalDriver = currentCfg.Driver
|
||
}
|
||
|
||
validatedVal, err := validateAndMergeStorageConfig(c.Request.Context(), req.Value, config.Value)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, response.Err(err.Error()))
|
||
return
|
||
}
|
||
req.Value = validatedVal
|
||
}
|
||
|
||
if err := db.DB(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
|
||
// 更新配置
|
||
updates := map[string]interface{}{
|
||
"description": req.Description,
|
||
}
|
||
if req.Visibility != nil {
|
||
updates["visibility"] = *req.Visibility
|
||
config.Visibility = *req.Visibility
|
||
}
|
||
if key != model.ConfigKeySMTPPassword || req.Value != maskedConfigValue {
|
||
updates["value"] = req.Value
|
||
config.Value = req.Value
|
||
}
|
||
if err := tx.Model(&config).Updates(updates).Error; err != nil {
|
||
return err
|
||
}
|
||
|
||
resolveStorageMigrationTasksOnDirectDriverUpdate(
|
||
c.Request.Context(),
|
||
tx,
|
||
key,
|
||
originalDriver,
|
||
req.Value,
|
||
)
|
||
|
||
return nil
|
||
}); err != nil {
|
||
c.JSON(http.StatusInternalServerError, response.Err(err.Error()))
|
||
return
|
||
}
|
||
|
||
invalidateCachesAfterConfigUpdate(c.Request.Context(), key)
|
||
|
||
c.JSON(http.StatusOK, response.OKNil())
|
||
}
|
||
|
||
func resolveStorageMigrationTasksOnDirectDriverUpdate(
|
||
ctx context.Context,
|
||
tx *gorm.DB,
|
||
key string,
|
||
originalDriver storage.Driver,
|
||
newValue string,
|
||
) {
|
||
if key != model.ConfigKeyStorageConfig || originalDriver == "" {
|
||
return
|
||
}
|
||
|
||
var newCfg storage.Config
|
||
if err := json.Unmarshal([]byte(newValue), &newCfg); err != nil {
|
||
return
|
||
}
|
||
if newCfg.Driver != originalDriver {
|
||
return
|
||
}
|
||
|
||
if err := tx.Model(&model.TaskExecution{}).
|
||
Where("task_type = ? AND status = ?", "storage:migrate", model.TaskExecutionStatusFailed).
|
||
Updates(map[string]any{
|
||
"status": model.TaskExecutionStatusSucceeded,
|
||
"result": "存储配置直接更新,故障迁移任务自动标记为已解决",
|
||
"finished_at": time.Now(),
|
||
}).Error; err != nil {
|
||
logger.ErrorF(ctx, "自动更新迁移任务状态失败: %v", err)
|
||
}
|
||
}
|
||
|
||
func invalidateSystemConfigCaches(ctx context.Context, key string) {
|
||
if err := model.InvalidateSystemConfigCache(ctx, key); err != nil {
|
||
logger.WarnF(ctx, "清理系统配置缓存失败: %v", err)
|
||
}
|
||
if cap.IsRuntimeConfigKey(key) {
|
||
cap.InvalidateRuntimeSettings()
|
||
}
|
||
}
|
||
|
||
func invalidateCachesAfterConfigUpdate(ctx context.Context, key string) {
|
||
invalidateSystemConfigCaches(ctx, key)
|
||
|
||
if key == model.ConfigKeyStorageConfig {
|
||
upload.ResetAccessCaches()
|
||
upload.PublishAccessCacheInvalidation(ctx)
|
||
storage.ResetCache()
|
||
storage.PublishCacheInvalidation(ctx)
|
||
}
|
||
if key == model.ConfigKeyFileAccessWhitelist {
|
||
upload.ResetAccessCaches()
|
||
upload.PublishAccessCacheInvalidation(ctx)
|
||
}
|
||
|
||
if err := model.InvalidateVisibleSystemConfigsCache(ctx); err != nil {
|
||
logger.WarnF(ctx, "清理公共配置列表缓存失败: %v", err)
|
||
}
|
||
}
|
||
|
||
// TestSMTPRequest 测试 SMTP 配置请求
|
||
type TestSMTPRequest struct {
|
||
SMTPHost string `json:"smtp_host" binding:"required,max=255"`
|
||
SMTPPort int `json:"smtp_port" binding:"required"`
|
||
SMTPUsername string `json:"smtp_username" binding:"required,max=255"`
|
||
SMTPPassword string `json:"smtp_password" binding:"required,max=255"`
|
||
To string `json:"to" binding:"required,email"`
|
||
}
|
||
|
||
// TestSMTPResponse 测试 SMTP 配置响应
|
||
type TestSMTPResponse struct {
|
||
Success bool `json:"success"`
|
||
Log string `json:"log"`
|
||
Error string `json:"error"`
|
||
}
|
||
|
||
// TestSMTP 测试 SMTP 邮件发送
|
||
// @Summary 测试 SMTP 邮件发送
|
||
// @Description 使用传入的配置进行 SMTP 邮件发送测试,支持使用 ****** 占位符使用保存的数据库密码
|
||
// @Tags admin
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Security SessionCookie
|
||
// @Param request body system_config.TestSMTPRequest true "测试请求参数"
|
||
// @Success 200 {object} response.Any{data=system_config.TestSMTPResponse} "测试执行完毕"
|
||
// @Failure 400 {object} response.Any "参数错误"
|
||
// @Router /api/v1/admin/system-configs/smtp/test [post]
|
||
func TestSMTP(c *gin.Context) {
|
||
var req TestSMTPRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, response.Err(err.Error()))
|
||
return
|
||
}
|
||
|
||
password := req.SMTPPassword
|
||
if password == maskedConfigValue {
|
||
var sc model.SystemConfig
|
||
if err := sc.GetByKey(c.Request.Context(), model.ConfigKeySMTPPassword); err == nil {
|
||
password = sc.Value
|
||
}
|
||
}
|
||
|
||
cfg := mail.Config{
|
||
Host: req.SMTPHost,
|
||
Port: req.SMTPPort,
|
||
Username: req.SMTPUsername,
|
||
Password: password,
|
||
}
|
||
|
||
subject := "Wavelet SMTP Test Mail"
|
||
body := `<h3>SMTP Mail Connection Test</h3>
|
||
<p>If you received this message, your SMTP configuration is correct and mail sending is working properly.</p>
|
||
<p>Sent from Wavelet.</p>`
|
||
|
||
logs, err := mail.SendMailWithLog(c.Request.Context(), cfg, req.To, subject, body)
|
||
resp := TestSMTPResponse{
|
||
Success: err == nil,
|
||
Log: logs,
|
||
}
|
||
if err != nil {
|
||
resp.Error = err.Error()
|
||
}
|
||
|
||
c.JSON(http.StatusOK, response.OK(resp))
|
||
}
|
||
|
||
func maskSensitiveConfig(key, value string) string {
|
||
if value == "" {
|
||
return value
|
||
}
|
||
switch key {
|
||
case model.ConfigKeySMTPPassword:
|
||
return maskedConfigValue
|
||
case model.ConfigKeyStorageConfig:
|
||
var cfg storage.Config
|
||
if err := json.Unmarshal([]byte(value), &cfg); err == nil {
|
||
masked := storage.MaskSecrets(cfg)
|
||
if val, err := json.Marshal(masked); err == nil {
|
||
return string(val)
|
||
}
|
||
}
|
||
}
|
||
return value
|
||
}
|
||
|
||
// validateAndMergeStorageConfig parses, merges unmasked secrets, validates parameter values,
|
||
// and tests connectivity of the new storage configuration.
|
||
func validateAndMergeStorageConfig(ctx context.Context, value string, currentConfig string) (string, error) {
|
||
var currentCfg storage.Config
|
||
if err := json.Unmarshal([]byte(currentConfig), ¤tCfg); err != nil {
|
||
return "", fmt.Errorf("解析当前存储配置失败: %w", err)
|
||
}
|
||
|
||
var newCfg storage.Config
|
||
if err := json.Unmarshal([]byte(value), &newCfg); err != nil {
|
||
return "", fmt.Errorf("解析目标存储配置失败: %w", err)
|
||
}
|
||
|
||
// 合并被掩码屏蔽的敏感信息,获取完整的真实配置
|
||
targetCfg := storage.MergeMaskedSecrets(newCfg, currentCfg)
|
||
|
||
// 校验配置参数是否合法
|
||
if err := storage.ValidateConfig(targetCfg); err != nil {
|
||
return "", fmt.Errorf("验证存储配置参数失败: %w", err)
|
||
}
|
||
|
||
// 进行连通性测试验证,如果测试失败则拒绝保存
|
||
testBackend, err := storage.NewBackend(ctx, targetCfg, targetCfg.Driver)
|
||
if err != nil {
|
||
return "", fmt.Errorf("初始化测试存储实例失败: %w", err)
|
||
}
|
||
if err := testBackend.Test(ctx); err != nil {
|
||
return "", fmt.Errorf("存储连通性测试失败: %w", err)
|
||
}
|
||
|
||
// 序列化为最终保存的真实明文配置,防止保存屏蔽的 ****** 字符
|
||
unmaskedVal, err := json.Marshal(targetCfg)
|
||
if err != nil {
|
||
return "", fmt.Errorf("序列化存储配置失败: %w", err)
|
||
}
|
||
|
||
return string(unmaskedVal), nil
|
||
}
|