实现七月迭代公共技术基础
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:
@@ -131,4 +131,7 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd
|
||||
if handlers.SuperAdmin != nil {
|
||||
registerSuperAdminRoutes(authGroup, handlers.SuperAdmin, doc, basePath)
|
||||
}
|
||||
if handlers.SystemConfig != nil {
|
||||
registerSystemConfigRoutes(authGroup, handlers.SystemConfig, doc, basePath)
|
||||
}
|
||||
}
|
||||
|
||||
31
internal/routes/system_config.go
Normal file
31
internal/routes/system_config.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
||||
)
|
||||
|
||||
// registerSystemConfigRoutes 注册受控系统配置路由。
|
||||
func registerSystemConfigRoutes(router fiber.Router, handler *admin.SystemConfigHandler, doc *openapi.Generator, basePath string) {
|
||||
configs := router.Group("/system-configs")
|
||||
groupPath := basePath + "/system-configs"
|
||||
|
||||
Register(configs, doc, groupPath, "GET", "", handler.List, RouteSpec{
|
||||
Summary: "查询受控系统配置",
|
||||
Tags: []string{"系统配置"},
|
||||
Input: new(dto.SystemConfigListRequest),
|
||||
Output: new(dto.SystemConfigListResponse),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(configs, doc, groupPath, "PUT", "/:key", handler.Update, RouteSpec{
|
||||
Summary: "更新受控系统配置",
|
||||
Tags: []string{"系统配置"},
|
||||
Input: new(dto.UpdateSystemConfigParams),
|
||||
Output: new(dto.SystemConfigItem),
|
||||
Auth: true,
|
||||
})
|
||||
}
|
||||
236
internal/routes/system_config_integration_test.go
Normal file
236
internal/routes/system_config_integration_test.go
Normal file
@@ -0,0 +1,236 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
systemConfigApp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
|
||||
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
||||
systemConfigInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
|
||||
internalMiddleware "github.com/break/junhong_cmp_fiber/internal/middleware"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
systemConfigQuery "github.com/break/junhong_cmp_fiber/internal/query/systemconfig"
|
||||
"github.com/break/junhong_cmp_fiber/internal/testutil"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
type configAuditWriter struct {
|
||||
fail bool
|
||||
}
|
||||
|
||||
func (w *configAuditWriter) WriteConfigChange(_ context.Context, tx *gorm.DB, audit systemConfigApp.ChangeAudit) error {
|
||||
if w.fail {
|
||||
return stderrors.New("测试审计写入失败")
|
||||
}
|
||||
before, _ := sonic.Marshal(audit.BeforeData)
|
||||
after, _ := sonic.Marshal(audit.AfterData)
|
||||
return tx.Exec(`INSERT INTO test_system_config_audit
|
||||
(config_key, operator_id, request_id, before_data, after_data)
|
||||
VALUES (?, ?, ?, ?::jsonb, ?::jsonb)`, audit.ConfigKey, audit.OperatorID, audit.RequestID, string(before), string(after)).Error
|
||||
}
|
||||
|
||||
type recordingAlerts struct {
|
||||
mu sync.Mutex
|
||||
codes []string
|
||||
}
|
||||
|
||||
func (a *recordingAlerts) Warn(_ context.Context, code, _, _, _ string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.codes = append(a.codes, code)
|
||||
}
|
||||
|
||||
type failingCache struct{}
|
||||
|
||||
func (failingCache) Get(context.Context, string) (string, error) {
|
||||
return "", stderrors.New("缓存不可用")
|
||||
}
|
||||
func (failingCache) Set(context.Context, string, string, time.Duration) error {
|
||||
return stderrors.New("缓存不可用")
|
||||
}
|
||||
func (failingCache) Delete(context.Context, string) error { return stderrors.New("缓存不可用") }
|
||||
|
||||
func TestSystemConfigFiberListAndUpdateUseRealPostgresAndRedis(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
redisClient := testutil.NewRedisClient(t)
|
||||
testutil.CreateTemporarySystemConfigTable(t, db)
|
||||
createTemporarySystemConfigAuditTable(t, db)
|
||||
registry := newSystemConfigTestRegistry(t)
|
||||
cache := systemConfigInfra.NewRedisCache(redisClient)
|
||||
alerts := &recordingAlerts{}
|
||||
reader := systemConfigInfra.NewReader(db, registry, cache, alerts)
|
||||
handler := admin.NewSystemConfigHandler(
|
||||
systemConfigQuery.NewListQuery(reader),
|
||||
systemConfigApp.NewUpdateService(db, registry, cache, &configAuditWriter{}, alerts, nil),
|
||||
)
|
||||
|
||||
if err := db.Create(&model.SystemConfig{
|
||||
ConfigKey: "legacy.unknown.value", ConfigValue: "legacy", ValueType: constants.SystemConfigTypeString,
|
||||
Module: "legacy", Description: "未注册遗留配置", IsSensitive: false,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("准备未注册配置失败:%v", err)
|
||||
}
|
||||
if err := redisClient.Set(context.Background(), constants.RedisSystemConfigKey("foundation.demo.int"), "7", constants.SystemConfigCacheTTL).Err(); err != nil {
|
||||
t.Fatalf("准备 Redis 配置缓存失败:%v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
for _, definition := range registry.List() {
|
||||
_ = redisClient.Del(context.Background(), constants.RedisSystemConfigKey(definition.Key)).Err()
|
||||
}
|
||||
})
|
||||
|
||||
app := newSystemConfigTestApp(handler, constants.UserTypeSuperAdmin)
|
||||
listResponse, err := app.Test(httptest.NewRequest("GET", "/api/admin/system-configs?module=foundation&page=1&page_size=20", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("查询系统配置接口失败:%v", err)
|
||||
}
|
||||
if listResponse.StatusCode != fiber.StatusOK {
|
||||
t.Fatalf("查询系统配置状态码错误:%d", listResponse.StatusCode)
|
||||
}
|
||||
var listBody struct {
|
||||
Code int `json:"code"`
|
||||
Data dto.SystemConfigListResponse `json:"data"`
|
||||
}
|
||||
if err := sonic.ConfigDefault.NewDecoder(listResponse.Body).Decode(&listBody); err != nil {
|
||||
t.Fatalf("解析系统配置列表响应失败:%v", err)
|
||||
}
|
||||
_ = listResponse.Body.Close()
|
||||
if listBody.Code != 0 || listBody.Data.Total != 4 || len(listBody.Data.List) != 4 {
|
||||
t.Fatalf("四种注册类型未完整返回:%+v", listBody)
|
||||
}
|
||||
for _, item := range listBody.Data.List {
|
||||
if item.Sensitive && item.Value != "[已配置]" {
|
||||
t.Fatalf("敏感配置未脱敏:%+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
updateRequest := httptest.NewRequest("PUT", "/api/admin/system-configs/foundation.demo.int", strings.NewReader(`{"value":"8"}`))
|
||||
updateRequest.Header.Set("Content-Type", "application/json")
|
||||
updateResponse, err := app.Test(updateRequest)
|
||||
if err != nil {
|
||||
t.Fatalf("更新系统配置接口失败:%v", err)
|
||||
}
|
||||
if updateResponse.StatusCode != fiber.StatusOK {
|
||||
t.Fatalf("更新系统配置状态码错误:%d", updateResponse.StatusCode)
|
||||
}
|
||||
_ = updateResponse.Body.Close()
|
||||
var stored model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "foundation.demo.int").First(&stored).Error; err != nil || stored.ConfigValue != "8" {
|
||||
t.Fatalf("系统配置数据库事实错误:%v,记录:%+v", err, stored)
|
||||
}
|
||||
var auditCount int64
|
||||
if err := db.Table("test_system_config_audit").Where("config_key = ? AND operator_id = ?", stored.ConfigKey, 7).Count(&auditCount).Error; err != nil || auditCount != 1 {
|
||||
t.Fatalf("系统配置审计事实错误:%v,数量:%d", err, auditCount)
|
||||
}
|
||||
if exists, err := redisClient.Exists(context.Background(), constants.RedisSystemConfigKey(stored.ConfigKey)).Result(); err != nil || exists != 0 {
|
||||
t.Fatalf("更新后缓存未失效:%v,存在:%d", err, exists)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemConfigPermissionsValidationRollbackAndCacheFailure(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
testutil.CreateTemporarySystemConfigTable(t, db)
|
||||
createTemporarySystemConfigAuditTable(t, db)
|
||||
registry := newSystemConfigTestRegistry(t)
|
||||
alerts := &recordingAlerts{}
|
||||
reader := systemConfigInfra.NewReader(db, registry, failingCache{}, alerts)
|
||||
service := systemConfigApp.NewUpdateService(db, registry, failingCache{}, &configAuditWriter{}, alerts, nil)
|
||||
handler := admin.NewSystemConfigHandler(systemConfigQuery.NewListQuery(reader), service)
|
||||
|
||||
forbiddenApp := newSystemConfigTestApp(handler, constants.UserTypePlatform)
|
||||
response, err := forbiddenApp.Test(httptest.NewRequest("GET", "/api/admin/system-configs", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("执行无权限查询失败:%v", err)
|
||||
}
|
||||
if response.StatusCode != fiber.StatusForbidden {
|
||||
t.Fatalf("权限不足必须返回 403,得到:%d", response.StatusCode)
|
||||
}
|
||||
_ = response.Body.Close()
|
||||
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{UserID: 7, UserType: constants.UserTypeSuperAdmin})
|
||||
invalidCases := []struct {
|
||||
key string
|
||||
value string
|
||||
}{
|
||||
{key: "foundation.demo.int", value: "not-int"},
|
||||
{key: "foundation.demo.bool", value: "yes"},
|
||||
{key: "foundation.demo.json", value: "{"},
|
||||
{key: "foundation.unknown.value", value: "x"},
|
||||
{key: "foundation.demo.readonly", value: "x"},
|
||||
}
|
||||
for _, item := range invalidCases {
|
||||
if _, err := service.Execute(ctx, item.key, dto.UpdateSystemConfigRequest{Value: item.value}); err == nil {
|
||||
t.Fatalf("非法配置更新应失败:%s=%s", item.key, item.value)
|
||||
}
|
||||
}
|
||||
|
||||
failClosed := systemConfigApp.NewUpdateService(db, registry, failingCache{}, &configAuditWriter{fail: true}, alerts, nil)
|
||||
if _, err := failClosed.Execute(ctx, "foundation.demo.string", dto.UpdateSystemConfigRequest{Value: "must-rollback"}); err == nil {
|
||||
t.Fatal("审计写入失败时配置事务必须回滚")
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&model.SystemConfig{}).Where("config_key = ?", "foundation.demo.string").Count(&count).Error; err != nil || count != 0 {
|
||||
t.Fatalf("审计失败后配置事实未回滚:%v,数量:%d", err, count)
|
||||
}
|
||||
|
||||
if _, err := service.Execute(ctx, "foundation.demo.string", dto.UpdateSystemConfigRequest{Value: "committed"}); err != nil {
|
||||
t.Fatalf("缓存失效失败不应回滚数据库事实:%v", err)
|
||||
}
|
||||
if len(alerts.codes) == 0 {
|
||||
t.Fatal("缓存故障必须产生可观察告警")
|
||||
}
|
||||
}
|
||||
|
||||
func newSystemConfigTestRegistry(t *testing.T) *systemConfigInfra.Registry {
|
||||
t.Helper()
|
||||
registry := systemConfigInfra.NewRegistry()
|
||||
minimum, maximum := int64(1), int64(10)
|
||||
definitions := []systemConfigInfra.Definition{
|
||||
{Key: "foundation.demo.string", Module: "foundation", ValueType: constants.SystemConfigTypeString, DefaultValue: "default", Description: "字符串示例", Control: "input"},
|
||||
{Key: "foundation.demo.int", Module: "foundation", ValueType: constants.SystemConfigTypeInt, DefaultValue: "5", Description: "整数示例", Control: "number", Min: &minimum, Max: &maximum},
|
||||
{Key: "foundation.demo.bool", Module: "foundation", ValueType: constants.SystemConfigTypeBool, DefaultValue: "true", Description: "布尔示例", Control: "switch"},
|
||||
{Key: "foundation.demo.json", Module: "foundation", ValueType: constants.SystemConfigTypeJSON, DefaultValue: `{"enabled":true}`, Description: "JSON 示例", Control: "structured", Sensitive: true},
|
||||
{Key: "foundation.demo.readonly", Module: "foundation-readonly", ValueType: constants.SystemConfigTypeString, DefaultValue: "fixed", Description: "只读示例", Control: "readonly", Readonly: true},
|
||||
}
|
||||
for _, definition := range definitions {
|
||||
if err := registry.Register(definition); err != nil {
|
||||
t.Fatalf("注册测试配置失败:%v", err)
|
||||
}
|
||||
}
|
||||
return registry
|
||||
}
|
||||
|
||||
func newSystemConfigTestApp(handler *admin.SystemConfigHandler, userType int) *fiber.App {
|
||||
app := fiber.New(fiber.Config{JSONEncoder: sonic.Marshal, JSONDecoder: sonic.Unmarshal, ErrorHandler: internalMiddleware.ErrorHandler(zap.NewNop())})
|
||||
api := app.Group("/api/admin", func(c *fiber.Ctx) error {
|
||||
ctx := middleware.SetUserContext(c.UserContext(), &middleware.UserContextInfo{UserID: 7, UserType: userType})
|
||||
ctx = context.WithValue(ctx, constants.ContextKeyRequestID, "request-system-config")
|
||||
c.SetUserContext(ctx)
|
||||
return c.Next()
|
||||
})
|
||||
registerSystemConfigRoutes(api, handler, nil, "/api/admin")
|
||||
return app
|
||||
}
|
||||
|
||||
func createTemporarySystemConfigAuditTable(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
if err := db.Exec(`CREATE TEMP TABLE test_system_config_audit (
|
||||
id bigserial PRIMARY KEY, config_key varchar(150) NOT NULL,
|
||||
operator_id bigint NOT NULL, request_id varchar(100) NOT NULL,
|
||||
before_data jsonb NOT NULL, after_data jsonb NOT NULL
|
||||
) ON COMMIT DROP`).Error; err != nil {
|
||||
t.Fatalf("创建系统配置审计测试表失败:%v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user