实现七月迭代公共技术基础
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m20s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m20s
This commit is contained in:
149
internal/infrastructure/systemconfig/reader.go
Normal file
149
internal/infrastructure/systemconfig/reader.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package systemconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"sort"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// AlertSink 接收配置读取和缓存故障的中文安全告警。
|
||||
type AlertSink interface {
|
||||
Warn(ctx context.Context, code, component, safeID, summary string)
|
||||
}
|
||||
|
||||
// LogAlertSink 使用应用日志承接安全告警。
|
||||
type LogAlertSink struct {
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewLogAlertSink 创建系统配置日志告警 Adapter。
|
||||
func NewLogAlertSink(logger *zap.Logger) *LogAlertSink {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &LogAlertSink{logger: logger}
|
||||
}
|
||||
|
||||
// Warn 记录不包含配置值的中文安全告警。
|
||||
func (s *LogAlertSink) Warn(_ context.Context, code, component, safeID, summary string) {
|
||||
s.logger.Warn(summary, zap.String("error_code", code), zap.String("component", component), zap.String("safe_id", safeID))
|
||||
}
|
||||
|
||||
// Reader 提供以 PostgreSQL 为唯一事实来源的缓存读取能力。
|
||||
type Reader struct {
|
||||
db *gorm.DB
|
||||
registry *Registry
|
||||
cache Cache
|
||||
alerts AlertSink
|
||||
}
|
||||
|
||||
func (r *Reader) warn(ctx context.Context, code, key, summary string) {
|
||||
if r.alerts != nil {
|
||||
r.alerts.Warn(ctx, code, "system_config", key, summary)
|
||||
}
|
||||
}
|
||||
|
||||
// NewReader 创建系统配置读取器。
|
||||
func NewReader(db *gorm.DB, registry *Registry, cache Cache, alerts AlertSink) *Reader {
|
||||
return &Reader{db: db, registry: registry, cache: cache, alerts: alerts}
|
||||
}
|
||||
|
||||
// Get 读取单个已注册配置;Redis 故障时回退 PostgreSQL。
|
||||
func (r *Reader) Get(ctx context.Context, key string) (string, error) {
|
||||
definition, registered := r.registry.Get(key)
|
||||
if !registered {
|
||||
return "", stderrors.New("系统配置 Key 未注册")
|
||||
}
|
||||
cacheKey := constants.RedisSystemConfigKey(key)
|
||||
if r.cache != nil {
|
||||
if value, err := r.cache.Get(ctx, cacheKey); err == nil {
|
||||
if ValidateValue(definition, value) == nil {
|
||||
r.registry.Remember(key, value)
|
||||
return value, nil
|
||||
}
|
||||
r.warn(ctx, "SYSTEM_CONFIG_CACHE_INVALID", key, "系统配置缓存值非法,已回退 PostgreSQL")
|
||||
} else if err != redis.Nil {
|
||||
r.warn(ctx, "SYSTEM_CONFIG_CACHE_READ_FAILED", key, "系统配置缓存读取失败,已回退 PostgreSQL")
|
||||
}
|
||||
}
|
||||
var record model.SystemConfig
|
||||
err := r.db.WithContext(ctx).Where("config_key = ?", key).First(&record).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return definition.DefaultValue, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
value := record.ConfigValue
|
||||
if ValidateValue(definition, value) != nil {
|
||||
value = r.registry.LastValidatedOrDefault(definition)
|
||||
r.warn(ctx, "SYSTEM_CONFIG_DATABASE_VALUE_INVALID", key, "数据库配置值非法,已使用最近验证值或安全默认值")
|
||||
} else {
|
||||
r.registry.Remember(key, value)
|
||||
}
|
||||
if r.cache != nil {
|
||||
if err := r.cache.Set(ctx, cacheKey, value, constants.SystemConfigCacheTTL); err != nil {
|
||||
r.warn(ctx, "SYSTEM_CONFIG_CACHE_WRITE_FAILED", key, "系统配置缓存回填失败")
|
||||
}
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// ListItem 是查询层组装 DTO 所需的稳定投影。
|
||||
type ListItem struct {
|
||||
Definition Definition
|
||||
Record *model.SystemConfig
|
||||
Value string
|
||||
Registered bool
|
||||
}
|
||||
|
||||
// List 合并代码注册表和数据库遗留记录;未注册记录强制只读。
|
||||
func (r *Reader) List(ctx context.Context, module string) ([]ListItem, error) {
|
||||
var records []model.SystemConfig
|
||||
query := r.db.WithContext(ctx).Order("config_key ASC")
|
||||
if module != "" {
|
||||
query = query.Where("module = ?", module)
|
||||
}
|
||||
if err := query.Find(&records).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byKey := make(map[string]*model.SystemConfig, len(records))
|
||||
for index := range records {
|
||||
byKey[records[index].ConfigKey] = &records[index]
|
||||
}
|
||||
items := make([]ListItem, 0, len(records)+len(r.registry.List()))
|
||||
for _, definition := range r.registry.List() {
|
||||
if module != "" && definition.Module != module {
|
||||
continue
|
||||
}
|
||||
record := byKey[definition.Key]
|
||||
value := definition.DefaultValue
|
||||
if record != nil {
|
||||
value = record.ConfigValue
|
||||
if ValidateValue(definition, value) != nil {
|
||||
value = r.registry.LastValidatedOrDefault(definition)
|
||||
r.warn(ctx, "SYSTEM_CONFIG_DATABASE_VALUE_INVALID", definition.Key, "数据库配置值非法,列表已使用安全值")
|
||||
} else {
|
||||
r.registry.Remember(definition.Key, value)
|
||||
}
|
||||
delete(byKey, definition.Key)
|
||||
}
|
||||
items = append(items, ListItem{Definition: definition, Record: record, Value: value, Registered: true})
|
||||
}
|
||||
for _, record := range byKey {
|
||||
definition := Definition{
|
||||
Key: record.ConfigKey, Module: record.Module, ValueType: record.ValueType,
|
||||
Description: record.Description, Readonly: true, Sensitive: record.IsSensitive, Control: "readonly",
|
||||
}
|
||||
items = append(items, ListItem{Definition: definition, Record: record, Value: record.ConfigValue, Registered: false})
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].Definition.Key < items[j].Definition.Key })
|
||||
return items, nil
|
||||
}
|
||||
115
internal/infrastructure/systemconfig/reader_integration_test.go
Normal file
115
internal/infrastructure/systemconfig/reader_integration_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package systemconfig_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/testutil"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
type unavailableCache struct{}
|
||||
|
||||
func (unavailableCache) Get(context.Context, string) (string, error) {
|
||||
return "", stderrors.New("测试缓存不可用")
|
||||
}
|
||||
|
||||
func (unavailableCache) Set(context.Context, string, string, time.Duration) error {
|
||||
return stderrors.New("测试缓存不可用")
|
||||
}
|
||||
|
||||
func (unavailableCache) Delete(context.Context, string) error {
|
||||
return stderrors.New("测试缓存不可用")
|
||||
}
|
||||
|
||||
type alertRecorder struct {
|
||||
mu sync.Mutex
|
||||
codes []string
|
||||
}
|
||||
|
||||
func (r *alertRecorder) Warn(_ context.Context, code, _, _, _ string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.codes = append(r.codes, code)
|
||||
}
|
||||
|
||||
func TestReaderUsesRedisHitAndFallsBackToPostgresOnMiss(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
redisClient := testutil.NewRedisClient(t)
|
||||
testutil.CreateTemporarySystemConfigTable(t, db)
|
||||
registry := systemconfig.NewRegistry()
|
||||
definition := systemconfig.Definition{
|
||||
Key: "foundation.reader.limit", Module: "foundation", ValueType: constants.SystemConfigTypeInt,
|
||||
DefaultValue: "10", Description: "读取器测试上限",
|
||||
}
|
||||
if err := registry.Register(definition); err != nil {
|
||||
t.Fatalf("注册系统配置失败:%v", err)
|
||||
}
|
||||
if err := db.Create(&model.SystemConfig{
|
||||
ConfigKey: definition.Key, ConfigValue: "20", ValueType: definition.ValueType,
|
||||
Module: definition.Module, Description: definition.Description,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("准备数据库配置失败:%v", err)
|
||||
}
|
||||
cacheKey := constants.RedisSystemConfigKey(definition.Key)
|
||||
t.Cleanup(func() { _ = redisClient.Del(context.Background(), cacheKey).Err() })
|
||||
if err := redisClient.Set(context.Background(), cacheKey, "30", constants.SystemConfigCacheTTL).Err(); err != nil {
|
||||
t.Fatalf("准备 Redis 缓存失败:%v", err)
|
||||
}
|
||||
reader := systemconfig.NewReader(db, registry, systemconfig.NewRedisCache(redisClient), nil)
|
||||
|
||||
value, err := reader.Get(context.Background(), definition.Key)
|
||||
if err != nil || value != "30" {
|
||||
t.Fatalf("Redis 命中结果错误:值=%q,错误=%v", value, err)
|
||||
}
|
||||
if err := redisClient.Del(context.Background(), cacheKey).Err(); err != nil {
|
||||
t.Fatalf("清理 Redis 缓存失败:%v", err)
|
||||
}
|
||||
value, err = reader.Get(context.Background(), definition.Key)
|
||||
if err != nil || value != "20" {
|
||||
t.Fatalf("Redis 未命中时未回退 PostgreSQL:值=%q,错误=%v", value, err)
|
||||
}
|
||||
cached, err := redisClient.Get(context.Background(), cacheKey).Result()
|
||||
if err != nil || cached != "20" {
|
||||
t.Fatalf("PostgreSQL 结果未回填 Redis:值=%q,错误=%v", cached, err)
|
||||
}
|
||||
ttl, err := redisClient.TTL(context.Background(), cacheKey).Result()
|
||||
if err != nil || ttl <= 0 || ttl > constants.SystemConfigCacheTTL {
|
||||
t.Fatalf("Redis 回填 TTL 不符合公共常量:TTL=%s,错误=%v", ttl, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReaderFallsBackToPostgresWhenCacheIsUnavailable(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
testutil.CreateTemporarySystemConfigTable(t, db)
|
||||
registry := systemconfig.NewRegistry()
|
||||
definition := systemconfig.Definition{
|
||||
Key: "foundation.reader.fallback", Module: "foundation", ValueType: constants.SystemConfigTypeString,
|
||||
DefaultValue: "default", Description: "缓存故障回退测试值",
|
||||
}
|
||||
if err := registry.Register(definition); err != nil {
|
||||
t.Fatalf("注册系统配置失败:%v", err)
|
||||
}
|
||||
if err := db.Create(&model.SystemConfig{
|
||||
ConfigKey: definition.Key, ConfigValue: "database", ValueType: definition.ValueType,
|
||||
Module: definition.Module, Description: definition.Description,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("准备数据库配置失败:%v", err)
|
||||
}
|
||||
alerts := &alertRecorder{}
|
||||
reader := systemconfig.NewReader(db, registry, unavailableCache{}, alerts)
|
||||
value, err := reader.Get(context.Background(), definition.Key)
|
||||
if err != nil || value != "database" {
|
||||
t.Fatalf("缓存不可用时未回退 PostgreSQL:值=%q,错误=%v", value, err)
|
||||
}
|
||||
alerts.mu.Lock()
|
||||
defer alerts.mu.Unlock()
|
||||
if len(alerts.codes) < 2 || alerts.codes[0] != "SYSTEM_CONFIG_CACHE_READ_FAILED" {
|
||||
t.Fatalf("缓存读写故障未产生安全告警:%v", alerts.codes)
|
||||
}
|
||||
}
|
||||
183
internal/infrastructure/systemconfig/registry.go
Normal file
183
internal/infrastructure/systemconfig/registry.go
Normal file
@@ -0,0 +1,183 @@
|
||||
// Package systemconfig 实现受控系统配置注册、缓存和持久化 Adapter。
|
||||
package systemconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// Definition 是业务模块拥有的配置 Key 注册定义。
|
||||
type Definition struct {
|
||||
Key string
|
||||
Module string
|
||||
ValueType string
|
||||
DefaultValue string
|
||||
Description string
|
||||
Readonly bool
|
||||
Sensitive bool
|
||||
Control string
|
||||
EnumValues []string
|
||||
Min *int64
|
||||
Max *int64
|
||||
}
|
||||
|
||||
// Registry 保存可写配置的代码权威定义。
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
definitions map[string]Definition
|
||||
lastValidated map[string]string
|
||||
}
|
||||
|
||||
// NewRegistry 创建空注册表;具体业务 Key 由所属模块注册。
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{definitions: map[string]Definition{}, lastValidated: map[string]string{}}
|
||||
}
|
||||
|
||||
// Register 注册一个业务模块拥有的配置 Key。
|
||||
func (r *Registry) Register(definition Definition) error {
|
||||
if !validKey(definition.Key) || strings.TrimSpace(definition.Module) == "" || strings.TrimSpace(definition.Description) == "" {
|
||||
return stderrors.New("系统配置注册信息不完整")
|
||||
}
|
||||
if err := ValidateValue(definition, definition.DefaultValue); err != nil {
|
||||
return err
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if existing, exists := r.definitions[definition.Key]; exists {
|
||||
if existing.ValueType != definition.ValueType {
|
||||
return stderrors.New("系统配置 Key 存在类型冲突")
|
||||
}
|
||||
return stderrors.New("系统配置 Key 重复注册")
|
||||
}
|
||||
r.definitions[definition.Key] = definition
|
||||
r.lastValidated[definition.Key] = definition.DefaultValue
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get 查询已注册定义。
|
||||
func (r *Registry) Get(key string) (Definition, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
definition, exists := r.definitions[key]
|
||||
return definition, exists
|
||||
}
|
||||
|
||||
// List 返回注册定义快照。
|
||||
func (r *Registry) List() []Definition {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
items := make([]Definition, 0, len(r.definitions))
|
||||
for _, definition := range r.definitions {
|
||||
items = append(items, definition)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// Remember 保存最近一次通过注册校验的值。
|
||||
func (r *Registry) Remember(key, value string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.lastValidated[key] = value
|
||||
}
|
||||
|
||||
// LastValidatedOrDefault 返回最近验证值或代码安全默认值。
|
||||
func (r *Registry) LastValidatedOrDefault(definition Definition) string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
if value, exists := r.lastValidated[definition.Key]; exists {
|
||||
return value
|
||||
}
|
||||
return definition.DefaultValue
|
||||
}
|
||||
|
||||
// ValidateValue 按注册类型、枚举和值域校验字符串化配置值。
|
||||
func ValidateValue(definition Definition, value string) error {
|
||||
switch definition.ValueType {
|
||||
case constants.SystemConfigTypeString:
|
||||
case constants.SystemConfigTypeInt:
|
||||
parsed, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil {
|
||||
return stderrors.New("系统配置值必须是整数")
|
||||
}
|
||||
if definition.Min != nil && parsed < *definition.Min {
|
||||
return stderrors.New("系统配置值小于允许范围")
|
||||
}
|
||||
if definition.Max != nil && parsed > *definition.Max {
|
||||
return stderrors.New("系统配置值大于允许范围")
|
||||
}
|
||||
case constants.SystemConfigTypeBool:
|
||||
if value != "true" && value != "false" {
|
||||
return stderrors.New("系统配置值必须是 true 或 false")
|
||||
}
|
||||
case constants.SystemConfigTypeJSON:
|
||||
var parsed any
|
||||
if sonic.Unmarshal([]byte(value), &parsed) != nil {
|
||||
return stderrors.New("系统配置值必须是合法 JSON")
|
||||
}
|
||||
default:
|
||||
return stderrors.New("系统配置类型不受支持")
|
||||
}
|
||||
if len(definition.EnumValues) > 0 {
|
||||
for _, allowed := range definition.EnumValues {
|
||||
if value == allowed {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return stderrors.New("系统配置值不在允许枚举中")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validKey(key string) bool {
|
||||
parts := strings.Split(key, ".")
|
||||
if len(parts) < 3 {
|
||||
return false
|
||||
}
|
||||
for _, part := range parts {
|
||||
if strings.TrimSpace(part) == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Cache 是系统配置使用的最小缓存边界。
|
||||
type Cache interface {
|
||||
Get(ctx context.Context, key string) (string, error)
|
||||
Set(ctx context.Context, key, value string, ttl time.Duration) error
|
||||
Delete(ctx context.Context, key string) error
|
||||
}
|
||||
|
||||
// RedisCache 使用 Redis 实现系统配置短期缓存。
|
||||
type RedisCache struct {
|
||||
client *redis.Client
|
||||
}
|
||||
|
||||
// NewRedisCache 创建系统配置 Redis Adapter。
|
||||
func NewRedisCache(client *redis.Client) *RedisCache {
|
||||
return &RedisCache{client: client}
|
||||
}
|
||||
|
||||
// Get 读取缓存值。
|
||||
func (c *RedisCache) Get(ctx context.Context, key string) (string, error) {
|
||||
return c.client.Get(ctx, key).Result()
|
||||
}
|
||||
|
||||
// Set 回填缓存值。
|
||||
func (c *RedisCache) Set(ctx context.Context, key, value string, ttl time.Duration) error {
|
||||
return c.client.Set(ctx, key, value, ttl).Err()
|
||||
}
|
||||
|
||||
// Delete 失效单 Key 缓存。
|
||||
func (c *RedisCache) Delete(ctx context.Context, key string) error {
|
||||
return c.client.Del(ctx, key).Err()
|
||||
}
|
||||
61
internal/infrastructure/systemconfig/registry_test.go
Normal file
61
internal/infrastructure/systemconfig/registry_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package systemconfig_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
func TestRegistryRejectsDuplicateTypeConflictAndInvalidDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
registry := systemconfig.NewRegistry()
|
||||
definition := systemconfig.Definition{
|
||||
Key: "foundation.example.limit", Module: "foundation", ValueType: constants.SystemConfigTypeInt,
|
||||
DefaultValue: "10", Description: "示例限制",
|
||||
}
|
||||
if err := registry.Register(definition); err != nil {
|
||||
t.Fatalf("注册合法配置失败:%v", err)
|
||||
}
|
||||
if err := registry.Register(definition); err == nil {
|
||||
t.Fatal("重复 Key 必须被拒绝")
|
||||
}
|
||||
conflict := definition
|
||||
conflict.ValueType = constants.SystemConfigTypeString
|
||||
if err := registry.Register(conflict); err == nil {
|
||||
t.Fatal("同 Key 类型冲突必须被拒绝")
|
||||
}
|
||||
invalid := definition
|
||||
invalid.Key = "foundation.example.invalid"
|
||||
invalid.DefaultValue = "not-int"
|
||||
if err := registry.Register(invalid); err == nil {
|
||||
t.Fatal("非法默认值必须在注册阶段失败")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateValueSupportsFourControlledTypesAndBounds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
minimum, maximum := int64(1), int64(10)
|
||||
cases := []systemconfig.Definition{
|
||||
{Key: "a.b.string", Module: "a", ValueType: constants.SystemConfigTypeString, DefaultValue: "x", Description: "字符串"},
|
||||
{Key: "a.b.int", Module: "a", ValueType: constants.SystemConfigTypeInt, DefaultValue: "5", Description: "整数", Min: &minimum, Max: &maximum},
|
||||
{Key: "a.b.bool", Module: "a", ValueType: constants.SystemConfigTypeBool, DefaultValue: "true", Description: "布尔"},
|
||||
{Key: "a.b.json", Module: "a", ValueType: constants.SystemConfigTypeJSON, DefaultValue: `{"enabled":true}`, Description: "JSON"},
|
||||
}
|
||||
for _, definition := range cases {
|
||||
if err := systemconfig.ValidateValue(definition, definition.DefaultValue); err != nil {
|
||||
t.Fatalf("合法 %s 值未通过:%v", definition.ValueType, err)
|
||||
}
|
||||
}
|
||||
if err := systemconfig.ValidateValue(cases[1], "11"); err == nil {
|
||||
t.Fatal("越界整数必须被拒绝")
|
||||
}
|
||||
if err := systemconfig.ValidateValue(cases[2], "yes"); err == nil {
|
||||
t.Fatal("非法布尔值必须被拒绝")
|
||||
}
|
||||
if err := systemconfig.ValidateValue(cases[3], "{"); err == nil {
|
||||
t.Fatal("非法 JSON 必须被拒绝")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user