实现七月迭代公共技术基础
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:
154
internal/application/outbox/recovery.go
Normal file
154
internal/application/outbox/recovery.go
Normal file
@@ -0,0 +1,154 @@
|
||||
// Package outbox 提供公共 Outbox 的受控人工恢复用例。
|
||||
package outbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// Operator 表示人工恢复操作者的授权快照。
|
||||
type Operator struct {
|
||||
ID uint
|
||||
SuperAdmin bool
|
||||
RequestID string
|
||||
CorrelationID string
|
||||
}
|
||||
|
||||
// RecoveryAudit 是交给统一 Audit Port 的安全恢复事实。
|
||||
type RecoveryAudit struct {
|
||||
OperatorID uint
|
||||
OperationType string
|
||||
Description string
|
||||
EventIDs []string
|
||||
Reason string
|
||||
BatchID string
|
||||
RequestID string
|
||||
CorrelationID string
|
||||
}
|
||||
|
||||
// AuditWriter 是 tech-global-audit 提供实现的统一审计接缝。
|
||||
type AuditWriter interface {
|
||||
WriteRecovery(ctx context.Context, tx *gorm.DB, audit RecoveryAudit) error
|
||||
}
|
||||
|
||||
// RecoveryService 执行选择性重放和过期租约释放。
|
||||
type RecoveryService struct {
|
||||
db *gorm.DB
|
||||
audit AuditWriter
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewRecoveryService 创建受控恢复用例;审计接缝不可缺失。
|
||||
func NewRecoveryService(db *gorm.DB, audit AuditWriter, now func() time.Time) (*RecoveryService, error) {
|
||||
if db == nil || audit == nil {
|
||||
return nil, stderrors.New("Outbox 恢复必须配置数据库和统一审计接缝")
|
||||
}
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &RecoveryService{db: db, audit: audit, now: now}, nil
|
||||
}
|
||||
|
||||
// Replay 只重放明确选择的最终失败或租约过期事件,并保留原始内容和身份。
|
||||
func (s *RecoveryService) Replay(ctx context.Context, operator Operator, ids []uint, reason string) (string, error) {
|
||||
if err := validateCommand(operator, ids, reason); err != nil {
|
||||
return "", err
|
||||
}
|
||||
batchID := uuid.NewString()
|
||||
now := s.now().UTC()
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
events, err := loadSelectedForUpdate(tx, ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(events) != len(ids) {
|
||||
return pkgerrors.New(pkgerrors.CodeInvalidStatus, "选择的事件不存在或状态不允许重放")
|
||||
}
|
||||
eventIDs := make([]string, 0, len(events))
|
||||
for _, event := range events {
|
||||
allowed := event.Status == constants.OutboxStatusFailed ||
|
||||
(event.Status == constants.OutboxStatusDelivering && event.LeaseExpiresAt != nil && !event.LeaseExpiresAt.After(now))
|
||||
if !allowed {
|
||||
return pkgerrors.New(pkgerrors.CodeInvalidStatus, "选择的事件不存在或状态不允许重放")
|
||||
}
|
||||
eventIDs = append(eventIDs, event.EventID)
|
||||
}
|
||||
result := tx.Model(&model.OutboxEvent{}).Where("id IN ?", ids).Updates(map[string]any{
|
||||
"status": constants.OutboxStatusPending, "next_attempt_at": now,
|
||||
"lease_owner": nil, "lease_expires_at": nil, "updated_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
return s.audit.WriteRecovery(ctx, tx, RecoveryAudit{
|
||||
OperatorID: operator.ID, OperationType: "outbox_replay", Description: "人工重放 Outbox 事件",
|
||||
EventIDs: eventIDs, Reason: reason, BatchID: batchID,
|
||||
RequestID: operator.RequestID, CorrelationID: operator.CorrelationID,
|
||||
})
|
||||
})
|
||||
return batchID, err
|
||||
}
|
||||
|
||||
// ReleaseExpiredLeases 只释放明确选择且已经过期的投递租约。
|
||||
func (s *RecoveryService) ReleaseExpiredLeases(ctx context.Context, operator Operator, ids []uint, reason string) (string, error) {
|
||||
if err := validateCommand(operator, ids, reason); err != nil {
|
||||
return "", err
|
||||
}
|
||||
batchID := uuid.NewString()
|
||||
now := s.now().UTC()
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
events, err := loadSelectedForUpdate(tx, ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(events) != len(ids) {
|
||||
return pkgerrors.New(pkgerrors.CodeInvalidStatus, "选择的租约不存在或仍然有效")
|
||||
}
|
||||
eventIDs := make([]string, 0, len(events))
|
||||
for _, event := range events {
|
||||
if event.Status != constants.OutboxStatusDelivering || event.LeaseExpiresAt == nil || event.LeaseExpiresAt.After(now) {
|
||||
return pkgerrors.New(pkgerrors.CodeInvalidStatus, "选择的租约不存在或仍然有效")
|
||||
}
|
||||
eventIDs = append(eventIDs, event.EventID)
|
||||
}
|
||||
result := tx.Model(&model.OutboxEvent{}).Where("id IN ? AND status = ? AND lease_expires_at <= ?", ids, constants.OutboxStatusDelivering, now).
|
||||
Updates(map[string]any{
|
||||
"status": constants.OutboxStatusPending, "next_attempt_at": now,
|
||||
"lease_owner": nil, "lease_expires_at": nil, "updated_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
return s.audit.WriteRecovery(ctx, tx, RecoveryAudit{
|
||||
OperatorID: operator.ID, OperationType: "outbox_release_expired_lease", Description: "人工释放 Outbox 过期租约",
|
||||
EventIDs: eventIDs, Reason: reason, BatchID: batchID,
|
||||
RequestID: operator.RequestID, CorrelationID: operator.CorrelationID,
|
||||
})
|
||||
})
|
||||
return batchID, err
|
||||
}
|
||||
|
||||
func validateCommand(operator Operator, ids []uint, reason string) error {
|
||||
if !operator.SuperAdmin {
|
||||
return pkgerrors.New(pkgerrors.CodeForbidden)
|
||||
}
|
||||
if operator.ID == 0 || len(ids) == 0 || reason == "" {
|
||||
return pkgerrors.New(pkgerrors.CodeInvalidParam)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadSelectedForUpdate(tx *gorm.DB, ids []uint) ([]model.OutboxEvent, error) {
|
||||
var events []model.OutboxEvent
|
||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id IN ?", ids).Order("id ASC").Find(&events).Error
|
||||
return events, err
|
||||
}
|
||||
129
internal/application/outbox/recovery_integration_test.go
Normal file
129
internal/application/outbox/recovery_integration_test.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package outbox_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
outboxapp "github.com/break/junhong_cmp_fiber/internal/application/outbox"
|
||||
infraoutbox "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"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 databaseAuditWriter struct {
|
||||
fail bool
|
||||
}
|
||||
|
||||
func (w *databaseAuditWriter) WriteRecovery(_ context.Context, tx *gorm.DB, audit outboxapp.RecoveryAudit) error {
|
||||
if w.fail {
|
||||
return stderrors.New("测试审计不可用")
|
||||
}
|
||||
return tx.Exec(`INSERT INTO test_outbox_recovery_audit (batch_id, operation_type, event_count)
|
||||
VALUES (?, ?, ?)`, audit.BatchID, audit.OperationType, len(audit.EventIDs)).Error
|
||||
}
|
||||
|
||||
func TestSelectiveReplayPreservesIdentityAndWritesAudit(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
testutil.CreateTemporaryOutboxTable(t, db)
|
||||
createTemporaryRecoveryAuditTable(t, db)
|
||||
now := time.Date(2026, 7, 23, 10, 0, 0, 0, time.UTC)
|
||||
repository := infraoutbox.NewRepository()
|
||||
event, err := repository.Append(context.Background(), db, infraoutbox.Envelope{
|
||||
EventID: "recovery-stable", EventType: "example.recovery", AggregateType: "example", AggregateID: "1",
|
||||
ResourceType: "example", ResourceID: "1", CorrelationID: "correlation-stable",
|
||||
Payload: struct {
|
||||
Secret string `json:"secret"`
|
||||
}{Secret: "preserved-in-database-only"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("准备恢复事件失败:%v", err)
|
||||
}
|
||||
if err := db.Model(&model.OutboxEvent{}).Where("id = ?", event.ID).Updates(map[string]any{
|
||||
"status": constants.OutboxStatusFailed, "retry_count": 10, "last_error_code": "FINAL",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("设置最终失败状态失败:%v", err)
|
||||
}
|
||||
var storedBefore model.OutboxEvent
|
||||
if err := db.First(&storedBefore, event.ID).Error; err != nil {
|
||||
t.Fatalf("读取重放前事件失败:%v", err)
|
||||
}
|
||||
|
||||
service, err := outboxapp.NewRecoveryService(db, &databaseAuditWriter{}, func() time.Time { return now })
|
||||
if err != nil {
|
||||
t.Fatalf("创建恢复服务失败:%v", err)
|
||||
}
|
||||
batchID, err := service.Replay(context.Background(), outboxapp.Operator{
|
||||
ID: 7, SuperAdmin: true, RequestID: "request-recovery", CorrelationID: "correlation-recovery",
|
||||
}, []uint{event.ID}, "确认消费者已具备幂等保护")
|
||||
if err != nil || batchID == "" {
|
||||
t.Fatalf("选择性重放失败:%v", err)
|
||||
}
|
||||
var recovered model.OutboxEvent
|
||||
if err := db.First(&recovered, event.ID).Error; err != nil {
|
||||
t.Fatalf("读取重放事件失败:%v", err)
|
||||
}
|
||||
if recovered.Status != constants.OutboxStatusPending || recovered.EventID != event.EventID ||
|
||||
recovered.CorrelationID != event.CorrelationID || string(recovered.Payload) != string(storedBefore.Payload) {
|
||||
t.Fatalf("重放改变了事件身份或内容:%+v", recovered)
|
||||
}
|
||||
var auditCount int64
|
||||
if err := db.Table("test_outbox_recovery_audit").Where("batch_id = ?", batchID).Count(&auditCount).Error; err != nil || auditCount != 1 {
|
||||
t.Fatalf("恢复审计事实缺失:%v,数量:%d", err, auditCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoveryRejectsActiveLeaseUnauthorizedOperatorAndAuditFailure(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
testutil.CreateTemporaryOutboxTable(t, db)
|
||||
createTemporaryRecoveryAuditTable(t, db)
|
||||
now := time.Date(2026, 7, 23, 10, 0, 0, 0, time.UTC)
|
||||
repository := infraoutbox.NewRepository()
|
||||
event, err := repository.Append(context.Background(), db, infraoutbox.Envelope{
|
||||
EventID: "active-lease", EventType: "example.recovery", AggregateType: "example", AggregateID: "2",
|
||||
ResourceType: "example", ResourceID: "2", Payload: struct {
|
||||
Visible bool `json:"visible"`
|
||||
}{Visible: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("准备有效租约事件失败:%v", err)
|
||||
}
|
||||
if err := db.Model(&model.OutboxEvent{}).Where("id = ?", event.ID).Updates(map[string]any{
|
||||
"status": constants.OutboxStatusDelivering, "lease_owner": "active-worker", "lease_expires_at": now.Add(time.Minute),
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("设置有效租约失败:%v", err)
|
||||
}
|
||||
service, _ := outboxapp.NewRecoveryService(db, &databaseAuditWriter{}, func() time.Time { return now })
|
||||
if _, err := service.ReleaseExpiredLeases(context.Background(), outboxapp.Operator{ID: 7, SuperAdmin: true}, []uint{event.ID}, "测试释放"); err == nil {
|
||||
t.Fatal("不得释放仍然有效的租约")
|
||||
}
|
||||
if _, err := service.Replay(context.Background(), outboxapp.Operator{ID: 8, SuperAdmin: false}, []uint{event.ID}, "越权测试"); err == nil {
|
||||
t.Fatal("非超级管理员不得执行人工恢复")
|
||||
}
|
||||
|
||||
if err := db.Model(&model.OutboxEvent{}).Where("id = ?", event.ID).Update("status", constants.OutboxStatusFailed).Error; err != nil {
|
||||
t.Fatalf("设置失败事件失败:%v", err)
|
||||
}
|
||||
failClosed, _ := outboxapp.NewRecoveryService(db, &databaseAuditWriter{fail: true}, func() time.Time { return now })
|
||||
if _, err := failClosed.Replay(context.Background(), outboxapp.Operator{ID: 7, SuperAdmin: true}, []uint{event.ID}, "审计失败测试"); err == nil {
|
||||
t.Fatal("统一审计不可用时恢复事务必须失败")
|
||||
}
|
||||
var unchanged model.OutboxEvent
|
||||
if err := db.First(&unchanged, event.ID).Error; err != nil || unchanged.Status != constants.OutboxStatusFailed {
|
||||
t.Fatalf("审计失败后事件状态未回滚:%v,状态:%d", err, unchanged.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func createTemporaryRecoveryAuditTable(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
if err := db.Exec(`CREATE TEMP TABLE test_outbox_recovery_audit (
|
||||
id bigserial PRIMARY KEY, batch_id varchar(64) NOT NULL,
|
||||
operation_type varchar(100) NOT NULL, event_count integer NOT NULL
|
||||
) ON COMMIT DROP`).Error; err != nil {
|
||||
t.Fatalf("创建恢复审计测试表失败:%v", err)
|
||||
}
|
||||
}
|
||||
178
internal/application/systemconfig/update.go
Normal file
178
internal/application/systemconfig/update.go
Normal file
@@ -0,0 +1,178 @@
|
||||
// Package systemconfig 提供受控系统配置的简单写事务脚本。
|
||||
package systemconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
stderrors "errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
configinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
// ChangeAudit 是系统配置更新交给统一审计 Port 的事实。
|
||||
type ChangeAudit struct {
|
||||
OperatorID uint
|
||||
OperationType string
|
||||
Description string
|
||||
ConfigKey string
|
||||
BeforeData map[string]any
|
||||
AfterData map[string]any
|
||||
RequestID string
|
||||
CorrelationID string
|
||||
}
|
||||
|
||||
// AuditWriter 由 tech-global-audit 提供事务内实现。
|
||||
type AuditWriter interface {
|
||||
WriteConfigChange(ctx context.Context, tx *gorm.DB, audit ChangeAudit) error
|
||||
}
|
||||
|
||||
// UnavailableAuditWriter 是统一审计尚未注入时的失败关闭策略。
|
||||
type UnavailableAuditWriter struct{}
|
||||
|
||||
// WriteConfigChange 拒绝在缺少统一审计时修改敏感配置。
|
||||
func (UnavailableAuditWriter) WriteConfigChange(_ context.Context, _ *gorm.DB, _ ChangeAudit) error {
|
||||
return stderrors.New("统一审计接缝尚未配置")
|
||||
}
|
||||
|
||||
// UpdateService 执行单 Key 校验、事务更新、审计和提交后缓存失效。
|
||||
type UpdateService struct {
|
||||
db *gorm.DB
|
||||
registry *configinfra.Registry
|
||||
cache configinfra.Cache
|
||||
audit AuditWriter
|
||||
alerts configinfra.AlertSink
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewUpdateService 创建系统配置更新事务脚本。
|
||||
func NewUpdateService(
|
||||
db *gorm.DB,
|
||||
registry *configinfra.Registry,
|
||||
cache configinfra.Cache,
|
||||
audit AuditWriter,
|
||||
alerts configinfra.AlertSink,
|
||||
now func() time.Time,
|
||||
) *UpdateService {
|
||||
if audit == nil {
|
||||
audit = UnavailableAuditWriter{}
|
||||
}
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &UpdateService{db: db, registry: registry, cache: cache, audit: audit, alerts: alerts, now: now}
|
||||
}
|
||||
|
||||
// Execute 更新一个已注册且非只读的配置 Key。
|
||||
func (s *UpdateService) Execute(ctx context.Context, key string, request dto.UpdateSystemConfigRequest) (*dto.SystemConfigItem, error) {
|
||||
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin {
|
||||
return nil, errors.New(errors.CodeForbidden)
|
||||
}
|
||||
operatorID := middleware.GetUserIDFromContext(ctx)
|
||||
if operatorID == 0 || key == "" {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
definition, registered := s.registry.Get(key)
|
||||
if !registered {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "系统配置 Key 未注册")
|
||||
}
|
||||
if definition.Readonly {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "系统配置为只读,不能更新")
|
||||
}
|
||||
if err := configinfra.ValidateValue(definition, request.Value); err != nil {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "系统配置值不符合注册规则")
|
||||
}
|
||||
now := s.now().UTC()
|
||||
var saved model.SystemConfig
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 同一 Key 的首次创建和后续更新都由 PostgreSQL 事务级咨询锁串行裁决。
|
||||
if err := tx.Exec("SELECT pg_advisory_xact_lock(hashtext(?))", key).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var existing model.SystemConfig
|
||||
findErr := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("config_key = ?", key).First(&existing).Error
|
||||
beforeValue := definition.DefaultValue
|
||||
if findErr == nil {
|
||||
beforeValue = existing.ConfigValue
|
||||
} else if findErr != gorm.ErrRecordNotFound {
|
||||
return findErr
|
||||
}
|
||||
if findErr == gorm.ErrRecordNotFound {
|
||||
existing = model.SystemConfig{
|
||||
ConfigKey: key, ConfigValue: request.Value, ValueType: definition.ValueType,
|
||||
Module: definition.Module, Description: definition.Description,
|
||||
IsReadonly: definition.Readonly, IsSensitive: definition.Sensitive,
|
||||
Creator: operatorID, Updater: operatorID, CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
if err := tx.Create(&existing).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := tx.Model(&model.SystemConfig{}).Where("id = ?", existing.ID).Updates(map[string]any{
|
||||
"config_value": request.Value, "value_type": definition.ValueType,
|
||||
"module": definition.Module, "description": definition.Description,
|
||||
"is_readonly": definition.Readonly, "is_sensitive": definition.Sensitive,
|
||||
"updater": operatorID, "updated_at": now,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
existing.ConfigValue = request.Value
|
||||
existing.Updater = operatorID
|
||||
existing.UpdatedAt = now
|
||||
}
|
||||
requestID := ""
|
||||
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
|
||||
requestID = *value
|
||||
}
|
||||
if err := s.audit.WriteConfigChange(ctx, tx, ChangeAudit{
|
||||
OperatorID: operatorID, OperationType: "system_config_update", Description: "更新受控系统配置",
|
||||
ConfigKey: key,
|
||||
BeforeData: map[string]any{"config_key": key, "value": auditValue(definition, beforeValue)},
|
||||
AfterData: map[string]any{"config_key": key, "value": auditValue(definition, request.Value)},
|
||||
RequestID: requestID, CorrelationID: requestID,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
saved = existing
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "更新系统配置失败")
|
||||
}
|
||||
s.registry.Remember(key, request.Value)
|
||||
if s.cache != nil {
|
||||
if err := s.cache.Delete(ctx, constants.RedisSystemConfigKey(key)); err != nil {
|
||||
if s.alerts != nil {
|
||||
s.alerts.Warn(ctx, "SYSTEM_CONFIG_CACHE_INVALIDATE_FAILED", "system_config", key, "系统配置缓存失效失败,数据库事实已提交")
|
||||
}
|
||||
}
|
||||
}
|
||||
value := saved.ConfigValue
|
||||
if definition.Sensitive && value != "" {
|
||||
value = "[已配置]"
|
||||
}
|
||||
updatedAt := saved.UpdatedAt
|
||||
return &dto.SystemConfigItem{
|
||||
ConfigKey: key, Value: value, ValueType: definition.ValueType, Module: definition.Module,
|
||||
Description: definition.Description, Readonly: definition.Readonly, Sensitive: definition.Sensitive,
|
||||
Registered: true, Control: definition.Control, EnumValues: definition.EnumValues,
|
||||
Min: definition.Min, Max: definition.Max, UpdatedAt: &updatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func auditValue(definition configinfra.Definition, value string) string {
|
||||
if !definition.Sensitive {
|
||||
return value
|
||||
}
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return "[敏感值 sha256:" + hex.EncodeToString(sum[:8]) + "]"
|
||||
}
|
||||
141
internal/application/systemconfig/update_integration_test.go
Normal file
141
internal/application/systemconfig/update_integration_test.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package systemconfig_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
|
||||
systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
|
||||
systemconfiginfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/config"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
type transactionAuditWriter struct{}
|
||||
|
||||
func (transactionAuditWriter) WriteConfigChange(_ context.Context, tx *gorm.DB, audit systemconfigapp.ChangeAudit) error {
|
||||
before, _ := sonic.Marshal(audit.BeforeData)
|
||||
after, _ := sonic.Marshal(audit.AfterData)
|
||||
return tx.Exec(`INSERT INTO test_system_config_audit
|
||||
(config_key, operator_id, before_data, after_data) VALUES (?, ?, ?::jsonb, ?::jsonb)`,
|
||||
audit.ConfigKey, audit.OperatorID, string(before), string(after)).Error
|
||||
}
|
||||
|
||||
func TestConcurrentFirstSystemConfigUpdatesAreSerializedByPostgres(t *testing.T) {
|
||||
if os.Getenv("JUNHONG_DATABASE_HOST") == "" {
|
||||
t.Skip("未加载 .env.local,跳过依赖真实 PostgreSQL 的并发集成测试")
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("加载测试配置失败:%v", err)
|
||||
}
|
||||
schema := "test_system_config_" + strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||
adminDB := openSchemaDatabase(t, &cfg.Database, "public")
|
||||
if err := adminDB.Exec("CREATE SCHEMA " + schema).Error; err != nil {
|
||||
t.Fatalf("创建隔离 schema 失败:%v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = adminDB.Exec("DROP SCHEMA " + schema + " CASCADE").Error
|
||||
closeDatabase(adminDB)
|
||||
})
|
||||
|
||||
firstDB := openSchemaDatabase(t, &cfg.Database, schema)
|
||||
secondDB := openSchemaDatabase(t, &cfg.Database, schema)
|
||||
t.Cleanup(func() {
|
||||
closeDatabase(firstDB)
|
||||
closeDatabase(secondDB)
|
||||
})
|
||||
createConcurrentConfigTables(t, firstDB)
|
||||
registry := systemconfiginfra.NewRegistry()
|
||||
definition := systemconfiginfra.Definition{
|
||||
Key: "foundation.concurrent.value", Module: "foundation", ValueType: constants.SystemConfigTypeString,
|
||||
DefaultValue: "default", Description: "并发配置测试值",
|
||||
}
|
||||
if err := registry.Register(definition); err != nil {
|
||||
t.Fatalf("注册并发测试配置失败:%v", err)
|
||||
}
|
||||
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{UserID: 7, UserType: constants.UserTypeSuperAdmin})
|
||||
services := []*systemconfigapp.UpdateService{
|
||||
systemconfigapp.NewUpdateService(firstDB, registry, nil, transactionAuditWriter{}, nil, nil),
|
||||
systemconfigapp.NewUpdateService(secondDB, registry, nil, transactionAuditWriter{}, nil, nil),
|
||||
}
|
||||
values := []string{"first", "second"}
|
||||
start := make(chan struct{})
|
||||
errorsFound := make(chan error, len(services))
|
||||
var waitGroup sync.WaitGroup
|
||||
for index := range services {
|
||||
waitGroup.Add(1)
|
||||
go func(index int) {
|
||||
defer waitGroup.Done()
|
||||
<-start
|
||||
_, executeErr := services[index].Execute(ctx, definition.Key, dto.UpdateSystemConfigRequest{Value: values[index]})
|
||||
errorsFound <- executeErr
|
||||
}(index)
|
||||
}
|
||||
close(start)
|
||||
waitGroup.Wait()
|
||||
close(errorsFound)
|
||||
for executeErr := range errorsFound {
|
||||
if executeErr != nil {
|
||||
t.Fatalf("并发更新不应产生唯一键冲突:%v", executeErr)
|
||||
}
|
||||
}
|
||||
var stored model.SystemConfig
|
||||
if err := firstDB.Where("config_key = ?", definition.Key).First(&stored).Error; err != nil {
|
||||
t.Fatalf("读取并发更新结果失败:%v", err)
|
||||
}
|
||||
if stored.ConfigValue != values[0] && stored.ConfigValue != values[1] {
|
||||
t.Fatalf("并发更新保存了非法结果:%q", stored.ConfigValue)
|
||||
}
|
||||
var auditCount int64
|
||||
if err := firstDB.Table("test_system_config_audit").Where("config_key = ?", definition.Key).Count(&auditCount).Error; err != nil || auditCount != 2 {
|
||||
t.Fatalf("并发更新审计事实不完整:错误=%v,数量=%d", err, auditCount)
|
||||
}
|
||||
}
|
||||
|
||||
func openSchemaDatabase(t *testing.T, cfg *config.DatabaseConfig, schema string) *gorm.DB {
|
||||
t.Helper()
|
||||
dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s search_path=%s TimeZone=Asia/Shanghai",
|
||||
cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.DBName, cfg.SSLMode, schema)
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{SkipDefaultTransaction: true})
|
||||
if err != nil {
|
||||
t.Fatalf("连接隔离 PostgreSQL 失败:%v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func closeDatabase(db *gorm.DB) {
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func createConcurrentConfigTables(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
if err := db.Exec(`CREATE TABLE tb_system_config (
|
||||
id bigserial PRIMARY KEY, config_key varchar(150) NOT NULL UNIQUE, config_value text NOT NULL,
|
||||
value_type varchar(20) NOT NULL, module varchar(100) NOT NULL, description varchar(500) NOT NULL,
|
||||
is_readonly boolean NOT NULL DEFAULT false, is_sensitive boolean NOT NULL DEFAULT false,
|
||||
creator bigint NOT NULL DEFAULT 0, updater bigint NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT NOW(), updated_at timestamptz NOT NULL DEFAULT NOW()
|
||||
)`).Error; err != nil {
|
||||
t.Fatalf("创建隔离配置表失败:%v", err)
|
||||
}
|
||||
if err := db.Exec(`CREATE TABLE test_system_config_audit (
|
||||
id bigserial PRIMARY KEY, config_key varchar(150) NOT NULL, operator_id bigint NOT NULL,
|
||||
before_data jsonb NOT NULL, after_data jsonb NOT NULL
|
||||
)`).Error; err != nil {
|
||||
t.Fatalf("创建隔离审计表失败:%v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user