Files
team-api/internal/logic/common/config.go
T

579 lines
18 KiB
Go

package common
import (
"context"
"encoding/json"
"fmt"
"github.com/qianfree/team-api/internal/consts"
"github.com/qianfree/team-api/internal/dao"
do "github.com/qianfree/team-api/internal/model/do"
"strconv"
"strings"
"sync"
"time"
"github.com/gogf/gf/v2/database/gredis"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/gconv"
"golang.org/x/sync/singleflight"
)
// ConfigService provides configuration management with a registry-driven schema,
// typed accessors, validation, and Redis Pub/Sub propagation.
type ConfigService struct {
cache *Cache
mu sync.RWMutex
sf singleflight.Group // 合并同 key 的并发 DB 回源(缓存过期瞬间/未落库 key 防穿透风暴)
}
var (
configService *ConfigService
configServiceOnce sync.Once
)
// Config returns the singleton ConfigService instance.
func Config() *ConfigService {
configServiceOnce.Do(func() {
configService = &ConfigService{
cache: NewCache("opt", 10*time.Minute),
}
})
return configService
}
// Option represents a system configuration entry.
type Option struct {
ID int64 `json:"id"`
Key string `json:"key"`
Value string `json:"value"`
Description string `json:"description"`
Category string `json:"category"`
IsPublic bool `json:"is_public"`
}
// ──────────────────────────────────────────
// Core accessors (backward compatible)
// ──────────────────────────────────────────
// GetOption retrieves a configuration value by key.
// Falls back to registry default if not in DB/cache.
func (s *ConfigService) GetOption(ctx context.Context, key string) string {
val, ok := s.cache.Get(ctx, key)
if ok {
return gconv.String(val)
}
// 配置是进程级全局状态,回源必须与单个请求的生死解耦:
// 1) WithoutCancel——客户端断开不得中断共享缓存回填,否则高并发断开场景会刷出大量
// "context canceled" 误报,且缓存始终填不上、后续请求继续穿透 DB;
// 2) singleflight——缓存过期瞬间/未落库 key 的并发回源合并为单次 DB 查询。
v, _, _ := s.sf.Do(key, func() (any, error) {
bgCtx := context.WithoutCancel(ctx)
// 双重检查:排队等待期间可能已被回填;请求 ctx 已取消导致的 L2(Redis) 读失败
// 也在此用不可取消 ctx 重读一次,避免无谓打 DB
if val, ok := s.cache.Get(bgCtx, key); ok {
return val, nil
}
return s.loadOption(bgCtx, key), nil
})
return gconv.String(v)
}
// loadOption 从 DB 读取配置并回填缓存(含负缓存)。
// key 未落库时缓存注册表默认值:LoadRateLimitConfig 等热路径每请求读多个 key,
// 未配置的 key 若不缓存会导致每次调用都穿透到 DB,高并发下形成 sys_options 读风暴。
// 缓存默认值不影响后台改配置的生效:SetOption 写库后会 Delete 缓存并 Pub/Sub 广播失效。
func (s *ConfigService) loadOption(ctx context.Context, key string) string {
var option *Option
err := dao.SysOptions.Ctx(ctx).
Where("key", key).
Scan(&option)
if err != nil {
// DB 真实故障:返回注册表默认值兜底,不写缓存(保留下次重试机会)
g.Log().Warningf(ctx, "[Config] DB error reading option %s: %v", key, err)
if def := GetSettingDef(key); def != nil {
return def.Default
}
return ""
}
value := ""
if option != nil {
value = option.Value
} else if def := GetSettingDef(key); def != nil {
value = def.Default
}
s.cache.Set(ctx, key, value)
return value
}
// GetOptionJSON retrieves a configuration value and unmarshals it as JSON.
func (s *ConfigService) GetOptionJSON(ctx context.Context, key string, target any) error {
val := s.GetOption(ctx, key)
if val == "" {
return nil
}
return json.Unmarshal([]byte(val), target)
}
// ──────────────────────────────────────────
// Typed accessors
// ──────────────────────────────────────────
// GetString returns the setting value as a string.
func (s *ConfigService) GetString(ctx context.Context, key string) string {
return s.GetOption(ctx, key)
}
// GetInt returns the setting value as an int. Falls back to registry default.
func (s *ConfigService) GetInt(ctx context.Context, key string) int {
val := s.GetOption(ctx, key)
if val == "" {
return 0
}
return gconv.Int(val)
}
// GetFloat returns the setting value as a float64.
func (s *ConfigService) GetFloat(ctx context.Context, key string) float64 {
val := s.GetOption(ctx, key)
if val == "" {
return 0
}
return gconv.Float64(val)
}
// GetBool returns the setting value as a bool.
func (s *ConfigService) GetBool(ctx context.Context, key string) bool {
val := s.GetOption(ctx, key)
return val == "true" || val == "1"
}
// ──────────────────────────────────────────
// Category loading
// ──────────────────────────────────────────
// LoadCategoryMap loads all settings in a category and returns them as a key→value map.
func (s *ConfigService) LoadCategoryMap(ctx context.Context, category string) map[string]string {
defs := GetCategorySettings(category)
result := make(map[string]string, len(defs))
for _, def := range defs {
result[def.Key] = s.GetOption(ctx, def.Key)
}
return result
}
// ──────────────────────────────────────────
// Schema-aware read for API
// ──────────────────────────────────────────
// SettingItem represents a single setting with its schema and current value (for API response).
type SettingItem struct {
Key string `json:"key"`
Value string `json:"value"`
Type SettingType `json:"type"`
Label string `json:"label"`
Description string `json:"description,omitempty"`
Sensitive bool `json:"sensitive,omitempty"`
Validation string `json:"validation,omitempty"`
Default string `json:"default"`
}
// GetCategoryWithValues returns schema + current values for a category.
func (s *ConfigService) GetCategoryWithValues(ctx context.Context, category string) []SettingItem {
defs := GetCategorySettings(category)
items := make([]SettingItem, 0, len(defs))
for _, def := range defs {
val := s.GetOption(ctx, def.Key)
if def.Sensitive && val != "" {
val = "******"
}
items = append(items, SettingItem{
Key: def.Key,
Value: val,
Type: def.Type,
Label: def.Label,
Description: def.Description,
Sensitive: def.Sensitive,
Validation: def.Validation,
Default: def.Default,
})
}
return items
}
// ──────────────────────────────────────────
// Write with validation
// ──────────────────────────────────────────
// SetOption updates a configuration value with validation, then invalidates cache.
func (s *ConfigService) SetOption(ctx context.Context, key, value string) error {
if def := GetSettingDef(key); def != nil {
// Sanitize: strip surrounding quotes that may have been double-encoded
value = sanitizeSettingValue(def.Type, value)
if err := validateSettingValue(def, value); err != nil {
return err
}
}
count, err := dao.SysOptions.Ctx(ctx).Where("key", key).Count()
if err != nil {
return err
}
// 本位币只在首次写入(系统初始化向导)时允许设置,此后只读;
// 相同值放行,保证设置页保存整个 payment 分类时不被误拒。
if key == consts.OptionKeyBillingCurrency && count > 0 {
// 必须显式 Fields("value"):不带字段的 Value() 返回结果集第一列(即 id),
// 会导致"相同值放行"判断永远失败,支付分类保存必报"本位币不可更改"
existing, err := dao.SysOptions.Ctx(ctx).Where("key", key).Fields("value").Value()
if err != nil {
return err
}
if old := gconv.String(existing); old != "" && old != value {
return gerror.New("本位币在系统初始化后不可更改")
}
}
if count > 0 {
data := do.SysOptions{Value: value}
// Sync metadata from registry so that changes to IsPublic/Category/Description
// are reflected in the DB row on the next write (e.g. a setting newly marked
// as IsPublic will become visible to GetPublicOptions).
if def := GetSettingDef(key); def != nil {
data.Category = def.Category
data.Description = def.Label
data.IsPublic = def.IsPublic
}
_, err = dao.SysOptions.Ctx(ctx).
Where("key", key).
Data(data).
Update()
} else {
def := GetSettingDef(key)
category := ""
description := ""
isPublic := false
if def != nil {
category = def.Category
description = def.Label
isPublic = def.IsPublic
}
_, err = dao.SysOptions.Ctx(ctx).
Data(do.SysOptions{
Key: key,
Value: value,
Category: category,
Description: description,
IsPublic: isPublic,
}).Insert()
}
if err != nil {
return err
}
s.cache.Delete(ctx, key)
s.cache.Delete(ctx, "public_options")
s.cache.Delete(ctx, "category:"+getCategoryForKey(key))
s.publishChange(ctx, key)
return nil
}
// UpdateCategory batch-updates all settings in a category.
// Skips masked sensitive values ("******").
func (s *ConfigService) UpdateCategory(ctx context.Context, category string, values map[string]string) error {
defs := GetCategorySettings(category)
defMap := make(map[string]*SettingDef, len(defs))
for i := range defs {
defMap[defs[i].Key] = &defs[i]
}
for key, value := range values {
def, ok := defMap[key]
if !ok {
// 注册表中已移除的配置项,跳过(数据库中可能残留旧行)
continue
}
if def.Sensitive && value == "******" {
continue
}
if err := s.SetOption(ctx, key, value); err != nil {
return fmt.Errorf("配置项 %s: %w", key, err)
}
}
return nil
}
// ──────────────────────────────────────────
// Query helpers (backward compatible)
// ──────────────────────────────────────────
// GetPublicOptions retrieves all public configuration options.
func (s *ConfigService) GetPublicOptions(ctx context.Context) ([]Option, error) {
cacheKey := "public_options"
val, ok := s.cache.Get(ctx, cacheKey)
if ok {
if options, ok := val.([]Option); ok {
return options, nil
}
}
var options []Option
err := dao.SysOptions.Ctx(ctx).
Where("is_public", true).
Scan(&options)
if err != nil {
return nil, err
}
s.cache.Set(ctx, cacheKey, options)
return options, nil
}
// GetAllOptionsByCategory retrieves all options in a given category.
func (s *ConfigService) GetAllOptionsByCategory(ctx context.Context, category string) ([]Option, error) {
cacheKey := "category:" + category
val, ok := s.cache.Get(ctx, cacheKey)
if ok {
if options, ok := val.([]Option); ok {
return options, nil
}
}
var options []Option
err := dao.SysOptions.Ctx(ctx).
Where("category", category).
Scan(&options)
if err != nil {
return nil, err
}
s.cache.Set(ctx, cacheKey, options)
return options, nil
}
// ──────────────────────────────────────────
// Warmup & Pub/Sub
// ──────────────────────────────────────────
// Warmup loads all public options into cache on startup.
func (s *ConfigService) Warmup(ctx context.Context) {
_, _ = s.GetPublicOptions(ctx)
g.Log().Info(ctx, "config service warmup completed")
}
// StartSubscriber subscribes to Redis Pub/Sub for cross-instance cache invalidation.
// Called once at startup in cmd.go.
func (s *ConfigService) StartSubscriber(ctx context.Context) {
go func() {
var reconnectCount int
for {
conn, _, err := g.Redis().Subscribe(ctx, "settings:changed")
if err != nil {
reconnectCount++
g.Log().Warningf(ctx, "[PubSub:settings] 连接失败 (第%d次): %v", reconnectCount, err)
time.Sleep(5 * time.Second)
continue
}
if reconnectCount > 0 {
g.Log().Infof(ctx, "[PubSub:settings] 重连成功 (此前失败%d次)", reconnectCount)
reconnectCount = 0
} else {
g.Log().Info(ctx, "[PubSub:settings] 订阅已启动")
}
for {
v, err := conn.Receive(ctx)
if err != nil {
reconnectCount++
g.Log().Warningf(ctx, "[PubSub:settings] 接收错误 (第%d次): %v", reconnectCount, err)
time.Sleep(5 * time.Second)
break // reconnect
}
msg, ok := v.Val().(*gredis.Message)
if !ok {
continue // skip Subscription/Pong etc.
}
key := msg.Payload
s.cache.Delete(ctx, key)
s.cache.Delete(ctx, "public_options")
s.cache.Delete(ctx, "category:"+getCategoryForKey(key))
g.Log().Debugf(ctx, "[PubSub:settings] 缓存已失效: %s", key)
// 缓存失效后再触发回调:回调内部大概率要读该配置的新值
fireSettingsChanged(ctx, key)
}
conn.Close(ctx)
}
}()
}
// ──────────────────────────────────────────
// 配置变更回调
// ──────────────────────────────────────────
var (
settingsHookMu sync.RWMutex
settingsHooks []func(ctx context.Context, key string)
)
// OnSettingsChanged 注册配置变更回调,用于"改了配置要立刻做点什么"的场景
// (如按新的探测间隔重排 cron),而不只是让缓存失效。
//
// 回调经 Redis pub/sub 触发,因此本实例自己的写入同样会走一遍(发布者也订阅了该频道),
// 无需在写入路径上重复调用。Redis 不可用时回调不触发——与缓存失效同等降级。
// 回调在订阅 goroutine 内串行执行,实现必须快速返回、自行兜底 panic。
func OnSettingsChanged(fn func(ctx context.Context, key string)) {
settingsHookMu.Lock()
defer settingsHookMu.Unlock()
settingsHooks = append(settingsHooks, fn)
}
// fireSettingsChanged 依次触发已注册回调,单个回调 panic 不影响其余回调与订阅循环。
func fireSettingsChanged(ctx context.Context, key string) {
settingsHookMu.RLock()
hooks := make([]func(ctx context.Context, key string), len(settingsHooks))
copy(hooks, settingsHooks)
settingsHookMu.RUnlock()
for _, fn := range hooks {
func() {
defer func() {
if r := recover(); r != nil {
g.Log().Errorf(ctx, "[PubSub:settings] 变更回调 panic (key=%s): %v", key, r)
}
}()
fn(ctx, key)
}()
}
}
// ──────────────────────────────────────────
// Validation
// ──────────────────────────────────────────
func sanitizeSettingValue(_ SettingType, value string) string {
if value == "" {
return value
}
// Strip surrounding quotes caused by double-encoding (e.g. "\"10\"" → "10")
if (strings.HasPrefix(value, `"`) && strings.HasSuffix(value, `"`)) ||
(strings.HasPrefix(value, "'") && strings.HasSuffix(value, "'")) {
value = value[1 : len(value)-1]
}
return value
}
func validateSettingValue(def *SettingDef, value string) error {
if def.Validation == "" {
return nil
}
// Empty value with a non-empty default → use default
if value == "" && def.Default != "" {
return nil
}
// Enum validation: "enum:a,b,c"
if strings.HasPrefix(def.Validation, "enum:") {
allowed := strings.Split(strings.TrimPrefix(def.Validation, "enum:"), ",")
for _, a := range allowed {
if value == a {
return nil
}
}
return NewBadRequestError(fmt.Sprintf("值必须是 %s 之一", strings.Join(allowed, "/")))
}
// Range validation: "min:1,max:100"
if strings.Contains(def.Validation, "min:") || strings.Contains(def.Validation, "max:") {
numVal, err := strconv.ParseFloat(value, 64)
if err != nil {
return NewBadRequestError("必须是数字")
}
parts := strings.Split(def.Validation, ",")
for _, p := range parts {
p = strings.TrimSpace(p)
if strings.HasPrefix(p, "min:") {
minVal, _ := strconv.ParseFloat(strings.TrimPrefix(p, "min:"), 64)
if numVal < minVal {
return NewBadRequestError(fmt.Sprintf("不能小于 %v", minVal))
}
}
if strings.HasPrefix(p, "max:") {
maxVal, _ := strconv.ParseFloat(strings.TrimPrefix(p, "max:"), 64)
if numVal > maxVal {
return NewBadRequestError(fmt.Sprintf("不能大于 %v", maxVal))
}
}
}
}
return nil
}
func getCategoryForKey(key string) string {
if def := GetSettingDef(key); def != nil {
return def.Category
}
return ""
}
// publishChange notifies other instances via Redis Pub/Sub to invalidate their local cache.
func (s *ConfigService) publishChange(ctx context.Context, key string) {
_, _ = g.Redis().Publish(ctx, "settings:changed", key)
}
// TypedValue converts a string value to its proper Go type based on SettingType.
// Returns the typed value for JSON serialization (numbers as float64, bools as bool).
func TypedValue(typ SettingType, value string, defaultVal string) any {
str := value
if str == "" {
str = defaultVal
}
switch typ {
case SettingTypeInt:
v, _ := strconv.ParseInt(str, 10, 64)
return float64(v)
case SettingTypeFloat:
v, _ := strconv.ParseFloat(str, 64)
return v
case SettingTypeBool:
return str == "true" || str == "1"
default:
return str
}
}
// NormalizeSettingValue converts an interface{} value (from JSON request) to a string for storage.
func NormalizeSettingValue(val any) string {
if val == nil {
return ""
}
switch v := val.(type) {
case float64:
// JSON numbers arrive as float64; convert to clean integer string when possible
if v == float64(int64(v)) {
return strconv.FormatInt(int64(v), 10)
}
return strconv.FormatFloat(v, 'f', -1, 64)
case int:
return strconv.Itoa(v)
case int64:
return strconv.FormatInt(v, 10)
case bool:
if v {
return "true"
}
return "false"
case string:
return v
default:
return gconv.String(v)
}
}