Files
team-api/internal/logic/admin/settings.go
T
Qian 2ef5a40c26 新的调度方式 (#14)
重构模型渠道调度系统
2026-08-01 22:55:08 +08:00

246 lines
8.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package admin
import (
"bytes"
"context"
"fmt"
"image"
"image/color"
"image/png"
"io"
"strings"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/google/uuid"
v1 "github.com/qianfree/team-api/api/admin/v1"
"github.com/qianfree/team-api/internal/dispatchadapter"
"github.com/qianfree/team-api/internal/logic/common"
)
// GetSettingsCategories returns all available setting categories.
func (s *sAdmin) GetSettingsCategories(ctx context.Context, _ *v1.AdminSettingsCategoriesReq) (*v1.AdminSettingsCategoriesRes, error) {
categories := common.Categories
list := make([]v1.SettingCategoryItem, len(categories))
for i, c := range categories {
list[i] = v1.SettingCategoryItem{
Key: c.Key,
Label: c.Label,
Icon: c.Icon,
Order: c.Order,
}
}
return &v1.AdminSettingsCategoriesRes{List: list}, nil
}
// GetSettings retrieves settings with schema for a given category.
func (s *sAdmin) GetSettings(ctx context.Context, req *v1.AdminSettingsGetReq) (*v1.AdminSettingsGetRes, error) {
if !isValidCategory(req.Category) {
return nil, common.NewBadRequestError("无效的设置分类")
}
items := common.Config().GetCategoryWithValues(ctx, req.Category)
list := make([]v1.AdminSettingItem, len(items))
for i, item := range items {
list[i] = v1.AdminSettingItem{
Key: item.Key,
Value: common.TypedValue(item.Type, item.Value, item.Default),
Type: string(item.Type),
Label: item.Label,
Description: item.Description,
Sensitive: item.Sensitive,
Validation: item.Validation,
Default: common.TypedValue(item.Type, "", item.Default),
}
}
return &v1.AdminSettingsGetRes{List: list}, nil
}
// UpdateSettings batch-updates settings for a given category.
func (s *sAdmin) UpdateSettings(ctx context.Context, req *v1.AdminSettingsUpdateReq) (*v1.AdminSettingsUpdateRes, error) {
if !isValidCategory(req.Category) {
return nil, common.NewBadRequestError("无效的设置分类")
}
// Normalize interface{} values to strings for storage
strValues := make(map[string]string, len(req.Settings))
for key, val := range req.Settings {
strValues[key] = common.NormalizeSettingValue(val)
}
if err := s.validateCrossFieldSettings(ctx, req.Category, strValues); err != nil {
return nil, err
}
if err := common.Config().UpdateCategory(ctx, req.Category, strValues); err != nil {
return nil, err
}
return nil, nil
}
// validateCrossFieldSettings validates interdependent settings within a category.
func (s *sAdmin) validateCrossFieldSettings(ctx context.Context, category string, values map[string]string) error {
switch category {
case "security":
return s.validateSecuritySettings(ctx, values)
case "channel":
return s.validateChannelSettings(ctx, values)
default:
return nil
}
}
// validateSecuritySettings 校验安全配置项之间的依赖:启用 Turnstile 前必须已配置密钥。
func (s *sAdmin) validateSecuritySettings(ctx context.Context, values map[string]string) error {
enabled, ok := values["turnstile_enabled"]
if !ok || (enabled != "true" && enabled != "1") {
return nil
}
siteKey := values["turnstile_site_key"]
secretKey := values["turnstile_secret_key"]
// If keys not in the current request, read existing values from DB
if siteKey == "" {
siteKey = common.Config().GetString(ctx, "turnstile_site_key")
}
if secretKey == "" || secretKey == "******" {
secretKey = common.Config().GetString(ctx, "turnstile_secret_key")
}
if strings.TrimSpace(siteKey) == "" || strings.TrimSpace(secretKey) == "" {
return common.NewBadRequestError("启用 Turnstile 前必须先配置 Site Key 和 Secret Key")
}
return nil
}
// validateChannelSettings 校验渠道配置项之间的依赖:同步图片厂商异步化依赖对象存储保存生成
// 结果,开启前必须已在「存储配置」中配置对象存储(OSS/S3/COS),否则生成的图片无处保存。
func (s *sAdmin) validateChannelSettings(ctx context.Context, values map[string]string) error {
// 路由策略覆盖(JSON):保存前做 Schema 校验,拦截非法配置入库
//(调度侧加载时仍会二次校验并沿用上一份,此处是给运营的即时反馈)。
if raw, ok := values["channel_routing_policy"]; ok && raw != "" {
if err := dispatchadapter.ValidateRoutingPolicyJSON(raw); err != nil {
return common.NewBadRequestError(fmt.Sprintf("路由策略配置非法:%v", err))
}
}
enabled, ok := values["sync_image_async_enabled"]
if !ok || (enabled != "true" && enabled != "1") {
return nil
}
// 存储配置属于 storage 分类、独立持久化,此处直接读已保存的存储配置判断是否可用。
if !common.IsStorageConfigured(ctx) {
return common.NewBadRequestError("开启同步图片厂商异步化前,必须先在「存储配置」中配置对象存储(OSS/S3/COS)")
}
return nil
}
func isValidCategory(category string) bool {
for _, c := range common.Categories {
if c.Key == category {
return true
}
}
return false
}
// TestStorageConfig 测试对象存储配置连通性:用生成的测试图片走一次「上传 → 下载校验 → 删除」
// 完整往返,验证凭证 / 桶 / 端点 / 读写删权限是否正常。
//
// access_key_id / secret 为空或掩码("******")时回落到已保存的配置值——与 GET 接口对敏感字段
// 的掩码逻辑(config.GetCategoryWithValues)以及 Turnstile 校验的 "******" 处理保持一致,
// 从而支持管理员在「未点保存」的状态下直接测试表单中新填/改动的配置。
func (s *sAdmin) TestStorageConfig(ctx context.Context, req *v1.AdminStorageTestReq) (*v1.AdminStorageTestRes, error) {
cfg := &common.StorageConfig{
Provider: strings.TrimSpace(req.Provider),
Endpoint: strings.TrimSpace(req.Endpoint),
Region: strings.TrimSpace(req.Region),
Bucket: strings.TrimSpace(req.Bucket),
AccessKeyID: strings.TrimSpace(req.AccessKeyID),
SecretKey: req.SecretKey,
UseSSL: req.UseSSL,
PathPrefix: strings.TrimSpace(req.PathPrefix),
}
// 敏感字段掩码 / 留空 → 回落已保存值
if cfg.AccessKeyID == "" || cfg.AccessKeyID == "******" {
cfg.AccessKeyID = common.Config().GetString(ctx, "storage_access_key_id")
}
if cfg.SecretKey == "" || cfg.SecretKey == "******" {
cfg.SecretKey = common.Config().GetString(ctx, "storage_access_key_secret")
}
if cfg.PathPrefix == "" {
cfg.PathPrefix = "team-api" // 与 GetStorageConfig 的默认前缀保持一致
}
if cfg.Provider == "" || cfg.Bucket == "" {
return nil, common.NewBadRequestError("请先填写存储供应商和存储桶名称")
}
provider, err := common.NewStorageProvider(cfg)
if err != nil {
return nil, common.NewBadRequestError("创建存储客户端失败:" + err.Error())
}
// 生成一张 1x1 PNG 作为测试图片(真实图片字节,验证二进制内容往返无损)
img := image.NewRGBA(image.Rect(0, 0, 1, 1))
img.Set(0, 0, color.RGBA{R: 0x33, G: 0x99, B: 0xff, A: 0xff})
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return nil, common.NewBadRequestError("生成测试图片失败:" + err.Error())
}
payload := buf.Bytes()
// 独立 _healthcheck 目录 + uuid,避免与业务对象冲突;provider 内部会自动加路径前缀
key := fmt.Sprintf("_healthcheck/%s.png", uuid.New().String())
// 整个往返设 20s 超时,端点不可达时不至于长时间挂起
testCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
res := &v1.AdminStorageTestRes{}
start := time.Now()
// 1. 上传
if _, err := provider.Upload(testCtx, bytes.NewReader(payload), key, "image/png"); err != nil {
return nil, common.NewBadRequestError("上传测试图片失败:" + err.Error())
}
res.Uploaded = true
// 兜底清理:覆盖下载失败等提前返回路径,避免遗留测试对象(Delete 幂等,成功路径重复删除无副作用)
defer func() {
if delErr := provider.Delete(context.WithoutCancel(testCtx), key); delErr != nil {
g.Log().Warningf(ctx, "storage test: cleanup test object %s failed: %v", key, delErr)
}
}()
// 2. 下载并校验内容一致
rc, err := provider.Download(testCtx, key)
if err != nil {
return nil, common.NewBadRequestError("下载测试图片失败:" + err.Error())
}
got, readErr := io.ReadAll(rc)
_ = rc.Close()
if readErr != nil {
return nil, common.NewBadRequestError("读取测试图片失败:" + readErr.Error())
}
if !bytes.Equal(got, payload) {
return nil, common.NewBadRequestError("下载内容与上传不一致,存储未正确保存对象")
}
res.Downloaded = true
// 3. 删除(校验删除权限;失败不致命,仅告警,兜底 defer 会再尝试一次)
if err := provider.Delete(testCtx, key); err != nil {
g.Log().Warningf(ctx, "storage test: delete test object %s failed: %v", key, err)
} else {
res.Deleted = true
}
res.ElapsedMs = time.Since(start).Milliseconds()
res.Message = "对象存储配置正常:测试图片上传、下载校验通过"
return res, nil
}